diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..18741fe Binary files /dev/null and b/.DS_Store differ diff --git a/InvestmentTracker/App/ContentView.swift b/InvestmentTracker/App/ContentView.swift deleted file mode 100644 index 264ddbf..0000000 --- a/InvestmentTracker/App/ContentView.swift +++ /dev/null @@ -1,65 +0,0 @@ -import SwiftUI - -struct ContentView: View { - @EnvironmentObject var iapService: IAPService - @EnvironmentObject var adMobService: AdMobService - @AppStorage("onboardingCompleted") private var onboardingCompleted = false - - @State private var selectedTab = 0 - - var body: some View { - Group { - if !onboardingCompleted { - OnboardingView(onboardingCompleted: $onboardingCompleted) - } else { - mainContent - } - } - } - - private var mainContent: some View { - ZStack(alignment: .bottom) { - TabView(selection: $selectedTab) { - DashboardView() - .tabItem { - Label("Dashboard", systemImage: "chart.pie.fill") - } - .tag(0) - - SourceListView() - .tabItem { - Label("Sources", systemImage: "list.bullet") - } - .tag(1) - - ChartsContainerView() - .tabItem { - Label("Charts", systemImage: "chart.xyaxis.line") - } - .tag(2) - - SettingsView() - .tabItem { - Label("Settings", systemImage: "gearshape.fill") - } - .tag(3) - } - - // Banner ad at bottom for free users - if !iapService.isPremium { - VStack(spacing: 0) { - Spacer() - BannerAdView() - .frame(height: 50) - } - .padding(.bottom, 49) // Account for tab bar height - } - } - } -} - -#Preview { - ContentView() - .environmentObject(IAPService()) - .environmentObject(AdMobService()) -} diff --git a/InvestmentTracker/App/InvestmentTrackerApp.swift b/InvestmentTracker/App/InvestmentTrackerApp.swift deleted file mode 100644 index 90246d5..0000000 --- a/InvestmentTracker/App/InvestmentTrackerApp.swift +++ /dev/null @@ -1,20 +0,0 @@ -import SwiftUI -import FirebaseCore - -@main -struct InvestmentTrackerApp: App { - @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - @StateObject private var iapService = IAPService() - @StateObject private var adMobService = AdMobService() - - let coreDataStack = CoreDataStack.shared - - var body: some Scene { - WindowGroup { - ContentView() - .environment(\.managedObjectContext, coreDataStack.viewContext) - .environmentObject(iapService) - .environmentObject(adMobService) - } - } -} diff --git a/InvestmentTracker/Models/CoreData/InvestmentSource+CoreDataClass.swift b/InvestmentTracker/Models/CoreData/InvestmentSource+CoreDataClass.swift deleted file mode 100644 index 6eccf54..0000000 --- a/InvestmentTracker/Models/CoreData/InvestmentSource+CoreDataClass.swift +++ /dev/null @@ -1,144 +0,0 @@ -import Foundation -import CoreData - -@objc(InvestmentSource) -public class InvestmentSource: NSManagedObject, Identifiable { - @nonobjc public class func fetchRequest() -> NSFetchRequest { - return NSFetchRequest(entityName: "InvestmentSource") - } - - @NSManaged public var id: UUID - @NSManaged public var name: String - @NSManaged public var notificationFrequency: String - @NSManaged public var customFrequencyMonths: Int16 - @NSManaged public var isActive: Bool - @NSManaged public var createdAt: Date - @NSManaged public var category: Category? - @NSManaged public var snapshots: NSSet? - - public override func awakeFromInsert() { - super.awakeFromInsert() - id = UUID() - createdAt = Date() - isActive = true - notificationFrequency = NotificationFrequency.monthly.rawValue - customFrequencyMonths = 1 - name = "" - } -} - -// MARK: - Notification Frequency - -enum NotificationFrequency: String, CaseIterable, Identifiable { - case monthly = "monthly" - case quarterly = "quarterly" - case semiannual = "semiannual" - case annual = "annual" - case custom = "custom" - case never = "never" - - var id: String { rawValue } - - var displayName: String { - switch self { - case .monthly: return "Monthly" - case .quarterly: return "Quarterly" - case .semiannual: return "Semi-Annual" - case .annual: return "Annual" - case .custom: return "Custom" - case .never: return "Never" - } - } - - var months: Int { - switch self { - case .monthly: return 1 - case .quarterly: return 3 - case .semiannual: return 6 - case .annual: return 12 - case .custom, .never: return 0 - } - } -} - -// MARK: - Computed Properties - -extension InvestmentSource { - var snapshotsArray: [Snapshot] { - let set = snapshots as? Set ?? [] - return set.sorted { $0.date > $1.date } - } - - var sortedSnapshotsByDateAscending: [Snapshot] { - let set = snapshots as? Set ?? [] - return set.sorted { $0.date < $1.date } - } - - var latestSnapshot: Snapshot? { - snapshotsArray.first - } - - var latestValue: Decimal { - latestSnapshot?.value?.decimalValue ?? Decimal.zero - } - - var snapshotCount: Int { - snapshots?.count ?? 0 - } - - var frequency: NotificationFrequency { - NotificationFrequency(rawValue: notificationFrequency) ?? .monthly - } - - var nextReminderDate: Date? { - guard frequency != .never else { return nil } - - let months = frequency == .custom ? Int(customFrequencyMonths) : frequency.months - guard let lastSnapshot = latestSnapshot else { - return Date() // Remind now if no snapshots - } - - return Calendar.current.date(byAdding: .month, value: months, to: lastSnapshot.date) - } - - var needsUpdate: Bool { - guard let nextDate = nextReminderDate else { return false } - return Date() >= nextDate - } - - // MARK: - Performance Metrics - - var totalReturn: Decimal { - guard let first = sortedSnapshotsByDateAscending.first, - let last = snapshotsArray.first, - let firstValue = first.value?.decimalValue, - firstValue != Decimal.zero else { - return Decimal.zero - } - - let lastValue = last.value?.decimalValue ?? Decimal.zero - return ((lastValue - firstValue) / firstValue) * 100 - } - - var totalContributions: Decimal { - snapshotsArray.reduce(Decimal.zero) { result, snapshot in - result + (snapshot.contribution?.decimalValue ?? Decimal.zero) - } - } -} - -// MARK: - Generated accessors for snapshots - -extension InvestmentSource { - @objc(addSnapshotsObject:) - @NSManaged public func addToSnapshots(_ value: Snapshot) - - @objc(removeSnapshotsObject:) - @NSManaged public func removeFromSnapshots(_ value: Snapshot) - - @objc(addSnapshots:) - @NSManaged public func addToSnapshots(_ values: NSSet) - - @objc(removeSnapshots:) - @NSManaged public func removeFromSnapshots(_ values: NSSet) -} diff --git a/InvestmentTracker/Models/CoreData/InvestmentTracker.xcdatamodeld/InvestmentTracker.xcdatamodel/contents b/InvestmentTracker/Models/CoreData/InvestmentTracker.xcdatamodeld/InvestmentTracker.xcdatamodel/contents deleted file mode 100644 index f4c9feb..0000000 --- a/InvestmentTracker/Models/CoreData/InvestmentTracker.xcdatamodeld/InvestmentTracker.xcdatamodel/contents +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/InvestmentTracker/Models/CoreDataStack.swift b/InvestmentTracker/Models/CoreDataStack.swift deleted file mode 100644 index bb431ae..0000000 --- a/InvestmentTracker/Models/CoreDataStack.swift +++ /dev/null @@ -1,144 +0,0 @@ -import CoreData -import CloudKit - -class CoreDataStack: ObservableObject { - static let shared = CoreDataStack() - - static let appGroupIdentifier = "group.com.yourteam.investmenttracker" - static let cloudKitContainerIdentifier = "iCloud.com.yourteam.investmenttracker" - - lazy var persistentContainer: NSPersistentCloudKitContainer = { - let container = NSPersistentCloudKitContainer(name: "InvestmentTracker") - - // App Group store URL for sharing with widgets - guard let storeURL = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: Self.appGroupIdentifier)? - .appendingPathComponent("InvestmentTracker.sqlite") else { - fatalError("Unable to get App Group container URL") - } - - let description = NSPersistentStoreDescription(url: storeURL) - - // CloudKit configuration - description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions( - containerIdentifier: Self.cloudKitContainerIdentifier - ) - - // History tracking for sync - description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) - description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) - - container.persistentStoreDescriptions = [description] - - container.loadPersistentStores { description, error in - if let error = error as NSError? { - // In production, handle this error appropriately - print("Core Data failed to load: \(error), \(error.userInfo)") - } - } - - // Merge policy - remote changes win - container.viewContext.automaticallyMergesChangesFromParent = true - container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy - - // Performance optimizations - container.viewContext.undoManager = nil - container.viewContext.shouldDeleteInaccessibleFaults = true - - // Listen for remote changes - NotificationCenter.default.addObserver( - self, - selector: #selector(processRemoteChanges), - name: .NSPersistentStoreRemoteChange, - object: container.persistentStoreCoordinator - ) - - return container - }() - - var viewContext: NSManagedObjectContext { - return persistentContainer.viewContext - } - - private init() {} - - // MARK: - Save Context - - func save() { - let context = viewContext - guard context.hasChanges else { return } - - do { - try context.save() - } catch { - let nsError = error as NSError - print("Core Data save error: \(nsError), \(nsError.userInfo)") - } - } - - // MARK: - Background Context - - func newBackgroundContext() -> NSManagedObjectContext { - let context = persistentContainer.newBackgroundContext() - context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy - context.undoManager = nil - return context - } - - func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void) { - persistentContainer.performBackgroundTask(block) - } - - // MARK: - Remote Change Handling - - @objc private func processRemoteChanges(_ notification: Notification) { - // Process remote changes on main context - DispatchQueue.main.async { [weak self] in - self?.objectWillChange.send() - } - } - - // MARK: - Widget Data Refresh - - func refreshWidgetData() { - // Trigger widget timeline refresh when data changes - #if os(iOS) - if #available(iOS 14.0, *) { - import WidgetKit - WidgetCenter.shared.reloadAllTimelines() - } - #endif - } -} - -// MARK: - Shared Container for Widgets - -extension CoreDataStack { - static var sharedStoreURL: URL? { - return FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)? - .appendingPathComponent("InvestmentTracker.sqlite") - } - - /// Creates a lightweight Core Data stack for widgets (read-only) - static func createWidgetContainer() -> NSPersistentContainer { - let container = NSPersistentContainer(name: "InvestmentTracker") - - guard let storeURL = sharedStoreURL else { - fatalError("Unable to get shared store URL") - } - - let description = NSPersistentStoreDescription(url: storeURL) - description.isReadOnly = true - - container.persistentStoreDescriptions = [description] - - container.loadPersistentStores { _, error in - if let error = error { - print("Widget Core Data failed: \(error)") - } - } - - return container - } -} diff --git a/InvestmentTracker/Resources/Info.plist b/InvestmentTracker/Resources/Info.plist deleted file mode 100644 index c52235a..0000000 --- a/InvestmentTracker/Resources/Info.plist +++ /dev/null @@ -1,118 +0,0 @@ - - - - - - CFBundleDevelopmentRegion - en - CFBundleDisplayName - Investment Tracker - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - LSRequiresIPhoneOS - - - - UIRequiredDeviceCapabilities - - armv7 - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - - UILaunchScreen - - - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - - - - GADApplicationIdentifier - ca-app-pub-3940256099942544~1458002511 - - - GADDelayAppMeasurementInit - - - - NSUserTrackingUsageDescription - This app uses tracking to provide personalized ads and improve your experience. Your data is not sold to third parties. - - - NSUbiquitousContainerIsDocumentScopePublic - - - - UIBackgroundModes - - fetch - remote-notification - - - - NSCalendarsUsageDescription - Used to set investment update reminders. - - - CFBundleURLTypes - - - CFBundleURLName - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleURLSchemes - - investmenttracker - - - - - - UISupportsDocumentBrowser - - - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - NSAllowsArbitraryLoadsForMedia - - NSAllowsArbitraryLoadsInWebContent - - - - - UIStatusBarStyle - UIStatusBarStyleDefault - UIViewControllerBasedStatusBarAppearance - - - diff --git a/InvestmentTracker/ViewModels/ChartsViewModel.swift b/InvestmentTracker/ViewModels/ChartsViewModel.swift deleted file mode 100644 index 2b7b6bb..0000000 --- a/InvestmentTracker/ViewModels/ChartsViewModel.swift +++ /dev/null @@ -1,329 +0,0 @@ -import Foundation -import Combine - -@MainActor -class ChartsViewModel: ObservableObject { - // MARK: - Chart Types - - enum ChartType: String, CaseIterable, Identifiable { - case evolution = "Evolution" - case allocation = "Allocation" - case performance = "Performance" - case drawdown = "Drawdown" - case volatility = "Volatility" - case prediction = "Prediction" - - var id: String { rawValue } - - var icon: String { - switch self { - case .evolution: return "chart.line.uptrend.xyaxis" - case .allocation: return "chart.pie.fill" - case .performance: return "chart.bar.fill" - case .drawdown: return "arrow.down.right.circle" - case .volatility: return "waveform.path.ecg" - case .prediction: return "wand.and.stars" - } - } - - var isPremium: Bool { - switch self { - case .evolution: - return false - case .allocation, .performance, .drawdown, .volatility, .prediction: - return true - } - } - - var description: String { - switch self { - case .evolution: - return "Track your portfolio value over time" - case .allocation: - return "See how your investments are distributed" - case .performance: - return "Compare returns across categories" - case .drawdown: - return "Analyze declines from peak values" - case .volatility: - return "Understand investment risk levels" - case .prediction: - return "View 12-month forecasts" - } - } - } - - // MARK: - Published Properties - - @Published var selectedChartType: ChartType = .evolution - @Published var selectedCategory: Category? - @Published var selectedTimeRange: TimeRange = .year - - @Published var evolutionData: [(date: Date, value: Decimal)] = [] - @Published var allocationData: [(category: String, value: Decimal, color: String)] = [] - @Published var performanceData: [(category: String, cagr: Double, color: String)] = [] - @Published var drawdownData: [(date: Date, drawdown: Double)] = [] - @Published var volatilityData: [(date: Date, volatility: Double)] = [] - @Published var predictionData: [Prediction] = [] - - @Published var isLoading = false - @Published var showingPaywall = false - - // MARK: - Time Range - - enum TimeRange: String, CaseIterable, Identifiable { - case month = "1M" - case quarter = "3M" - case halfYear = "6M" - case year = "1Y" - case all = "All" - - var id: String { rawValue } - - var months: Int? { - switch self { - case .month: return 1 - case .quarter: return 3 - case .halfYear: return 6 - case .year: return 12 - case .all: return nil - } - } - } - - // MARK: - Dependencies - - private let sourceRepository: InvestmentSourceRepository - private let categoryRepository: CategoryRepository - private let snapshotRepository: SnapshotRepository - private let calculationService: CalculationService - private let predictionEngine: PredictionEngine - private let freemiumValidator: FreemiumValidator - private var cancellables = Set() - - // MARK: - Initialization - - init( - sourceRepository: InvestmentSourceRepository = InvestmentSourceRepository(), - categoryRepository: CategoryRepository = CategoryRepository(), - snapshotRepository: SnapshotRepository = SnapshotRepository(), - calculationService: CalculationService = .shared, - predictionEngine: PredictionEngine = .shared, - iapService: IAPService - ) { - self.sourceRepository = sourceRepository - self.categoryRepository = categoryRepository - self.snapshotRepository = snapshotRepository - self.calculationService = calculationService - self.predictionEngine = predictionEngine - self.freemiumValidator = FreemiumValidator(iapService: iapService) - - setupObservers() - loadData() - } - - // MARK: - Setup - - private func setupObservers() { - Publishers.CombineLatest3($selectedChartType, $selectedCategory, $selectedTimeRange) - .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) - .sink { [weak self] chartType, category, timeRange in - self?.updateChartData(chartType: chartType, category: category, timeRange: timeRange) - } - .store(in: &cancellables) - } - - // MARK: - Data Loading - - func loadData() { - updateChartData( - chartType: selectedChartType, - category: selectedCategory, - timeRange: selectedTimeRange - ) - - FirebaseService.shared.logScreenView(screenName: "Charts") - } - - func selectChart(_ chartType: ChartType) { - if chartType.isPremium && !freemiumValidator.isPremium { - showingPaywall = true - FirebaseService.shared.logPaywallShown(trigger: "advanced_charts") - return - } - - selectedChartType = chartType - FirebaseService.shared.logChartViewed( - chartType: chartType.rawValue, - isPremium: chartType.isPremium - ) - } - - private func updateChartData(chartType: ChartType, category: Category?, timeRange: TimeRange) { - isLoading = true - - let sources: [InvestmentSource] - if let category = category { - sources = sourceRepository.fetchSources(for: category) - } else { - sources = sourceRepository.sources - } - - var snapshots = sources.flatMap { $0.snapshotsArray } - - // Filter by time range - if let months = timeRange.months { - let cutoff = Calendar.current.date(byAdding: .month, value: -months, to: Date()) ?? Date() - snapshots = snapshots.filter { $0.date >= cutoff } - } - - // Apply freemium filter - snapshots = freemiumValidator.filterSnapshots(snapshots) - - switch chartType { - case .evolution: - calculateEvolutionData(from: snapshots) - case .allocation: - calculateAllocationData() - case .performance: - calculatePerformanceData() - case .drawdown: - calculateDrawdownData(from: snapshots) - case .volatility: - calculateVolatilityData(from: snapshots) - case .prediction: - calculatePredictionData(from: snapshots) - } - - isLoading = false - } - - // MARK: - Chart Calculations - - private func calculateEvolutionData(from snapshots: [Snapshot]) { - let sortedSnapshots = snapshots.sorted { $0.date < $1.date } - - var dateValues: [Date: Decimal] = [:] - - for snapshot in sortedSnapshots { - let day = Calendar.current.startOfDay(for: snapshot.date) - dateValues[day, default: 0] += snapshot.decimalValue - } - - evolutionData = dateValues - .map { (date: $0.key, value: $0.value) } - .sorted { $0.date < $1.date } - } - - private func calculateAllocationData() { - let categories = categoryRepository.categories - let total = categories.reduce(Decimal.zero) { $0 + $1.totalValue } - - allocationData = categories - .filter { $0.totalValue > 0 } - .map { category in - ( - category: category.name, - value: category.totalValue, - color: category.colorHex - ) - } - .sorted { $0.value > $1.value } - } - - private func calculatePerformanceData() { - let categories = categoryRepository.categories - - performanceData = categories.compactMap { category in - let snapshots = category.sourcesArray.flatMap { $0.snapshotsArray } - guard snapshots.count >= 2 else { return nil } - - let metrics = calculationService.calculateMetrics(for: snapshots) - - return ( - category: category.name, - cagr: metrics.cagr, - color: category.colorHex - ) - }.sorted { $0.cagr > $1.cagr } - } - - private func calculateDrawdownData(from snapshots: [Snapshot]) { - let sortedSnapshots = snapshots.sorted { $0.date < $1.date } - guard !sortedSnapshots.isEmpty else { - drawdownData = [] - return - } - - var peak = sortedSnapshots.first!.decimalValue - var data: [(date: Date, drawdown: Double)] = [] - - for snapshot in sortedSnapshots { - let value = snapshot.decimalValue - if value > peak { - peak = value - } - - let drawdown = peak > 0 - ? NSDecimalNumber(decimal: (peak - value) / peak).doubleValue * 100 - : 0 - - data.append((date: snapshot.date, drawdown: -drawdown)) - } - - drawdownData = data - } - - private func calculateVolatilityData(from snapshots: [Snapshot]) { - let sortedSnapshots = snapshots.sorted { $0.date < $1.date } - guard sortedSnapshots.count >= 3 else { - volatilityData = [] - return - } - - var data: [(date: Date, volatility: Double)] = [] - let windowSize = 3 - - for i in windowSize.. 0 ? (stdDev / mean) * 100 : 0 - - data.append((date: sortedSnapshots[i].date, volatility: volatility)) - } - - volatilityData = data - } - - private func calculatePredictionData(from snapshots: [Snapshot]) { - guard freemiumValidator.canViewPredictions() else { - predictionData = [] - return - } - - let result = predictionEngine.predict(snapshots: snapshots) - predictionData = result.predictions - } - - // MARK: - Computed Properties - - var categories: [Category] { - categoryRepository.categories - } - - var availableChartTypes: [ChartType] { - ChartType.allCases - } - - var isPremium: Bool { - freemiumValidator.isPremium - } - - var hasData: Bool { - !sourceRepository.sources.isEmpty - } -} diff --git a/InvestmentTracker/ViewModels/DashboardViewModel.swift b/InvestmentTracker/ViewModels/DashboardViewModel.swift deleted file mode 100644 index 3bc0697..0000000 --- a/InvestmentTracker/ViewModels/DashboardViewModel.swift +++ /dev/null @@ -1,203 +0,0 @@ -import Foundation -import Combine -import CoreData - -@MainActor -class DashboardViewModel: ObservableObject { - // MARK: - Published Properties - - @Published var portfolioSummary: PortfolioSummary = .empty - @Published var categoryMetrics: [CategoryMetrics] = [] - @Published var recentSnapshots: [Snapshot] = [] - @Published var sourcesNeedingUpdate: [InvestmentSource] = [] - @Published var isLoading = false - @Published var errorMessage: String? - - // MARK: - Chart Data - - @Published var evolutionData: [(date: Date, value: Decimal)] = [] - - // MARK: - Dependencies - - private let categoryRepository: CategoryRepository - private let sourceRepository: InvestmentSourceRepository - private let snapshotRepository: SnapshotRepository - private let calculationService: CalculationService - private var cancellables = Set() - - // MARK: - Initialization - - init( - categoryRepository: CategoryRepository = CategoryRepository(), - sourceRepository: InvestmentSourceRepository = InvestmentSourceRepository(), - snapshotRepository: SnapshotRepository = SnapshotRepository(), - calculationService: CalculationService = .shared - ) { - self.categoryRepository = categoryRepository - self.sourceRepository = sourceRepository - self.snapshotRepository = snapshotRepository - self.calculationService = calculationService - - setupObservers() - loadData() - } - - // MARK: - Setup - - private func setupObservers() { - // Observe category changes - categoryRepository.$categories - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refreshData() - } - .store(in: &cancellables) - - // Observe source changes - sourceRepository.$sources - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refreshData() - } - .store(in: &cancellables) - - // Observe Core Data changes - NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange) - .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) - .sink { [weak self] _ in - self?.refreshData() - } - .store(in: &cancellables) - } - - // MARK: - Data Loading - - func loadData() { - isLoading = true - errorMessage = nil - - Task { - await refreshAllData() - isLoading = false - } - } - - func refreshData() { - Task { - await refreshAllData() - } - } - - private func refreshAllData() async { - let categories = categoryRepository.categories - let sources = sourceRepository.sources - let allSnapshots = snapshotRepository.fetchAllSnapshots() - - // Calculate portfolio summary - portfolioSummary = calculationService.calculatePortfolioSummary( - from: sources, - snapshots: allSnapshots - ) - - // Calculate category metrics - categoryMetrics = calculationService.calculateCategoryMetrics( - for: categories, - totalPortfolioValue: portfolioSummary.totalValue - ).sorted { $0.totalValue > $1.totalValue } - - // Get recent snapshots - recentSnapshots = Array(allSnapshots.prefix(10)) - - // Get sources needing update - sourcesNeedingUpdate = sourceRepository.fetchSourcesNeedingUpdate() - - // Calculate evolution data for chart - calculateEvolutionData(from: allSnapshots) - - // Log screen view - FirebaseService.shared.logScreenView(screenName: "Dashboard") - } - - private func calculateEvolutionData(from snapshots: [Snapshot]) { - // Group snapshots by date and sum values - var dateValues: [Date: Decimal] = [:] - - // Get unique dates - let sortedSnapshots = snapshots.sorted { $0.date < $1.date } - - for snapshot in sortedSnapshots { - let startOfDay = Calendar.current.startOfDay(for: snapshot.date) - - // For each date, we need the total portfolio value at that point - // This requires summing the latest value for each source up to that date - let sourcesAtDate = Dictionary(grouping: sortedSnapshots.filter { $0.date <= snapshot.date }) { - $0.source?.id ?? UUID() - } - - var totalAtDate: Decimal = 0 - for (_, sourceSnapshots) in sourcesAtDate { - if let latestForSource = sourceSnapshots.max(by: { $0.date < $1.date }) { - totalAtDate += latestForSource.decimalValue - } - } - - dateValues[startOfDay] = totalAtDate - } - - evolutionData = dateValues - .map { (date: $0.key, value: $0.value) } - .sorted { $0.date < $1.date } - } - - // MARK: - Computed Properties - - var hasData: Bool { - !sourceRepository.sources.isEmpty - } - - var totalSourceCount: Int { - sourceRepository.sourceCount - } - - var totalCategoryCount: Int { - categoryRepository.categories.count - } - - var pendingUpdatesCount: Int { - sourcesNeedingUpdate.count - } - - var topCategories: [CategoryMetrics] { - Array(categoryMetrics.prefix(5)) - } - - // MARK: - Formatting - - var formattedTotalValue: String { - portfolioSummary.formattedTotalValue - } - - var formattedDayChange: String { - portfolioSummary.formattedDayChange - } - - var formattedMonthChange: String { - portfolioSummary.formattedMonthChange - } - - var formattedYearChange: String { - portfolioSummary.formattedYearChange - } - - var isDayChangePositive: Bool { - portfolioSummary.dayChange >= 0 - } - - var isMonthChangePositive: Bool { - portfolioSummary.monthChange >= 0 - } - - var isYearChangePositive: Bool { - portfolioSummary.yearChange >= 0 - } -} diff --git a/InvestmentTracker/ViewModels/SourceDetailViewModel.swift b/InvestmentTracker/ViewModels/SourceDetailViewModel.swift deleted file mode 100644 index 22eb23b..0000000 --- a/InvestmentTracker/ViewModels/SourceDetailViewModel.swift +++ /dev/null @@ -1,229 +0,0 @@ -import Foundation -import Combine -import CoreData - -@MainActor -class SourceDetailViewModel: ObservableObject { - // MARK: - Published Properties - - @Published var source: InvestmentSource - @Published var snapshots: [Snapshot] = [] - @Published var metrics: InvestmentMetrics = .empty - @Published var predictions: [Prediction] = [] - @Published var predictionResult: PredictionResult? - - @Published var isLoading = false - @Published var showingAddSnapshot = false - @Published var showingEditSource = false - @Published var showingPaywall = false - @Published var errorMessage: String? - - // MARK: - Chart Data - - @Published var chartData: [(date: Date, value: Decimal)] = [] - - // MARK: - Dependencies - - private let snapshotRepository: SnapshotRepository - private let sourceRepository: InvestmentSourceRepository - private let calculationService: CalculationService - private let predictionEngine: PredictionEngine - private let freemiumValidator: FreemiumValidator - private var cancellables = Set() - - // MARK: - Initialization - - init( - source: InvestmentSource, - snapshotRepository: SnapshotRepository = SnapshotRepository(), - sourceRepository: InvestmentSourceRepository = InvestmentSourceRepository(), - calculationService: CalculationService = .shared, - predictionEngine: PredictionEngine = .shared, - iapService: IAPService - ) { - self.source = source - self.snapshotRepository = snapshotRepository - self.sourceRepository = sourceRepository - self.calculationService = calculationService - self.predictionEngine = predictionEngine - self.freemiumValidator = FreemiumValidator(iapService: iapService) - - loadData() - setupObservers() - } - - // MARK: - Setup - - private func setupObservers() { - NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange) - .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) - .sink { [weak self] _ in - self?.refreshData() - } - .store(in: &cancellables) - } - - // MARK: - Data Loading - - func loadData() { - isLoading = true - refreshData() - isLoading = false - - FirebaseService.shared.logScreenView(screenName: "SourceDetail") - } - - func refreshData() { - // Fetch snapshots (filtered by freemium limits) - let allSnapshots = snapshotRepository.fetchSnapshots(for: source) - snapshots = freemiumValidator.filterSnapshots(allSnapshots) - - // Calculate metrics - metrics = calculationService.calculateMetrics(for: snapshots) - - // Prepare chart data - chartData = snapshots - .sorted { $0.date < $1.date } - .map { (date: $0.date, value: $0.decimalValue) } - - // Calculate predictions if premium - if freemiumValidator.canViewPredictions() && snapshots.count >= 3 { - predictionResult = predictionEngine.predict(snapshots: snapshots) - predictions = predictionResult?.predictions ?? [] - } else { - predictions = [] - predictionResult = nil - } - } - - // MARK: - Snapshot Actions - - func addSnapshot(date: Date, value: Decimal, contribution: Decimal?, notes: String?) { - snapshotRepository.createSnapshot( - for: source, - date: date, - value: value, - contribution: contribution, - notes: notes - ) - - // Reschedule notification - NotificationService.shared.scheduleReminder(for: source) - - // Log analytics - FirebaseService.shared.logSnapshotAdded(sourceName: source.name, value: value) - - showingAddSnapshot = false - refreshData() - } - - func deleteSnapshot(_ snapshot: Snapshot) { - snapshotRepository.deleteSnapshot(snapshot) - refreshData() - } - - func deleteSnapshot(at offsets: IndexSet) { - snapshotRepository.deleteSnapshot(at: offsets, from: snapshots) - refreshData() - } - - // MARK: - Source Actions - - func updateSource( - name: String, - category: Category, - frequency: NotificationFrequency, - customMonths: Int - ) { - sourceRepository.updateSource( - source, - name: name, - category: category, - notificationFrequency: frequency, - customFrequencyMonths: customMonths - ) - - // Update notification - NotificationService.shared.scheduleReminder(for: source) - - showingEditSource = false - } - - // MARK: - Predictions - - func showPredictions() { - if freemiumValidator.canViewPredictions() { - // Already loaded, just navigate - FirebaseService.shared.logPredictionViewed( - algorithm: predictionResult?.algorithm.rawValue ?? "unknown" - ) - } else { - showingPaywall = true - FirebaseService.shared.logPaywallShown(trigger: "predictions") - } - } - - // MARK: - Computed Properties - - var currentValue: Decimal { - source.latestValue - } - - var formattedCurrentValue: String { - currentValue.currencyString - } - - var totalReturn: Decimal { - metrics.absoluteReturn - } - - var formattedTotalReturn: String { - metrics.formattedAbsoluteReturn - } - - var percentageReturn: Decimal { - metrics.percentageReturn - } - - var formattedPercentageReturn: String { - metrics.formattedPercentageReturn - } - - var isPositiveReturn: Bool { - totalReturn >= 0 - } - - var categoryName: String { - source.category?.name ?? "Uncategorized" - } - - var categoryColor: String { - source.category?.colorHex ?? "#3B82F6" - } - - var lastUpdated: String { - source.latestSnapshot?.date.friendlyDescription ?? "Never" - } - - var snapshotCount: Int { - snapshots.count - } - - var hasEnoughDataForPredictions: Bool { - snapshots.count >= 3 - } - - var canViewPredictions: Bool { - freemiumValidator.canViewPredictions() - } - - var isHistoryLimited: Bool { - !freemiumValidator.isPremium && - snapshotRepository.fetchSnapshots(for: source).count > snapshots.count - } - - var hiddenSnapshotCount: Int { - let allCount = snapshotRepository.fetchSnapshots(for: source).count - return allCount - snapshots.count - } -} diff --git a/InvestmentTracker/Views/Charts/ChartsContainerView.swift b/InvestmentTracker/Views/Charts/ChartsContainerView.swift deleted file mode 100644 index 6655792..0000000 --- a/InvestmentTracker/Views/Charts/ChartsContainerView.swift +++ /dev/null @@ -1,298 +0,0 @@ -import SwiftUI -import Charts - -struct ChartsContainerView: View { - @EnvironmentObject var iapService: IAPService - @StateObject private var viewModel: ChartsViewModel - - init() { - _viewModel = StateObject(wrappedValue: ChartsViewModel(iapService: IAPService())) - } - - var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: 20) { - // Chart Type Selector - chartTypeSelector - - // Time Range Selector - if viewModel.selectedChartType != .allocation && - viewModel.selectedChartType != .performance { - timeRangeSelector - } - - // Category Filter - if viewModel.selectedChartType == .evolution || - viewModel.selectedChartType == .prediction { - categoryFilter - } - - // Chart Content - chartContent - } - .padding() - } - .navigationTitle("Charts") - .sheet(isPresented: $viewModel.showingPaywall) { - PaywallView() - } - } - } - - // MARK: - Chart Type Selector - - private var chartTypeSelector: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - ForEach(ChartsViewModel.ChartType.allCases) { chartType in - ChartTypeButton( - chartType: chartType, - isSelected: viewModel.selectedChartType == chartType, - isPremium: chartType.isPremium, - userIsPremium: viewModel.isPremium - ) { - viewModel.selectChart(chartType) - } - } - } - .padding(.horizontal, 4) - } - } - - // MARK: - Time Range Selector - - private var timeRangeSelector: some View { - HStack(spacing: 8) { - ForEach(ChartsViewModel.TimeRange.allCases) { range in - Button { - viewModel.selectedTimeRange = range - } label: { - Text(range.rawValue) - .font(.subheadline.weight(.medium)) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background( - viewModel.selectedTimeRange == range - ? Color.appPrimary - : Color.gray.opacity(0.1) - ) - .foregroundColor( - viewModel.selectedTimeRange == range - ? .white - : .primary - ) - .cornerRadius(20) - } - } - } - } - - // MARK: - Category Filter - - private var categoryFilter: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - Button { - viewModel.selectedCategory = nil - } label: { - Text("All") - .font(.caption.weight(.medium)) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background( - viewModel.selectedCategory == nil - ? Color.appPrimary - : Color.gray.opacity(0.1) - ) - .foregroundColor( - viewModel.selectedCategory == nil - ? .white - : .primary - ) - .cornerRadius(16) - } - - ForEach(viewModel.categories) { category in - Button { - viewModel.selectedCategory = category - } label: { - HStack(spacing: 4) { - Circle() - .fill(category.color) - .frame(width: 8, height: 8) - Text(category.name) - } - .font(.caption.weight(.medium)) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background( - viewModel.selectedCategory?.id == category.id - ? category.color - : Color.gray.opacity(0.1) - ) - .foregroundColor( - viewModel.selectedCategory?.id == category.id - ? .white - : .primary - ) - .cornerRadius(16) - } - } - } - } - } - - // MARK: - Chart Content - - @ViewBuilder - private var chartContent: some View { - if viewModel.isLoading { - ProgressView() - .frame(height: 300) - } else if !viewModel.hasData { - emptyStateView - } else { - switch viewModel.selectedChartType { - case .evolution: - EvolutionChartView(data: viewModel.evolutionData) - case .allocation: - AllocationPieChart(data: viewModel.allocationData) - case .performance: - PerformanceBarChart(data: viewModel.performanceData) - case .drawdown: - DrawdownChart(data: viewModel.drawdownData) - case .volatility: - VolatilityChartView(data: viewModel.volatilityData) - case .prediction: - PredictionChartView( - predictions: viewModel.predictionData, - historicalData: viewModel.evolutionData - ) - } - } - } - - private var emptyStateView: some View { - VStack(spacing: 16) { - Image(systemName: "chart.bar.xaxis") - .font(.system(size: 48)) - .foregroundColor(.secondary) - - Text("No Data Available") - .font(.headline) - - Text("Add some investment sources and snapshots to see charts.") - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - .frame(height: 300) - } -} - -// MARK: - Chart Type Button - -struct ChartTypeButton: View { - let chartType: ChartsViewModel.ChartType - let isSelected: Bool - let isPremium: Bool - let userIsPremium: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - VStack(spacing: 8) { - ZStack(alignment: .topTrailing) { - Image(systemName: chartType.icon) - .font(.title2) - - if isPremium && !userIsPremium { - Image(systemName: "lock.fill") - .font(.caption2) - .foregroundColor(.appWarning) - .offset(x: 8, y: -4) - } - } - - Text(chartType.rawValue) - .font(.caption) - } - .frame(width: 80, height: 70) - .background( - isSelected - ? Color.appPrimary - : Color(.systemBackground) - ) - .foregroundColor(isSelected ? .white : .primary) - .cornerRadius(AppConstants.UI.cornerRadius) - .shadow(color: .black.opacity(0.05), radius: 4, y: 2) - } - } -} - -// MARK: - Evolution Chart View - -struct EvolutionChartView: View { - let data: [(date: Date, value: Decimal)] - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Portfolio Evolution") - .font(.headline) - - if data.count >= 2 { - Chart(data, id: \.date) { item in - LineMark( - x: .value("Date", item.date), - y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) - ) - .foregroundStyle(Color.appPrimary) - .interpolationMethod(.catmullRom) - - AreaMark( - x: .value("Date", item.date), - y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) - ) - .foregroundStyle( - LinearGradient( - colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)], - startPoint: .top, - endPoint: .bottom - ) - ) - .interpolationMethod(.catmullRom) - } - .chartXAxis { - AxisMarks(values: .stride(by: .month, count: 2)) { value in - AxisValueLabel(format: .dateTime.month(.abbreviated)) - } - } - .chartYAxis { - AxisMarks(position: .leading) { value in - AxisValueLabel { - if let doubleValue = value.as(Double.self) { - Text(Decimal(doubleValue).shortCurrencyString) - .font(.caption) - } - } - } - } - .frame(height: 300) - } else { - Text("Not enough data") - .foregroundColor(.secondary) - .frame(height: 300) - } - } - .padding() - .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) - .shadow(color: .black.opacity(0.05), radius: 8, y: 2) - } -} - -#Preview { - ChartsContainerView() - .environmentObject(IAPService()) -} diff --git a/InvestmentTracker/Views/Dashboard/DashboardView.swift b/InvestmentTracker/Views/Dashboard/DashboardView.swift deleted file mode 100644 index 39bd424..0000000 --- a/InvestmentTracker/Views/Dashboard/DashboardView.swift +++ /dev/null @@ -1,262 +0,0 @@ -import SwiftUI -import Charts - -struct DashboardView: View { - @EnvironmentObject var iapService: IAPService - @StateObject private var viewModel: DashboardViewModel - - init() { - _viewModel = StateObject(wrappedValue: DashboardViewModel()) - } - - var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: 20) { - if viewModel.hasData { - // Total Value Card - TotalValueCard( - totalValue: viewModel.portfolioSummary.formattedTotalValue, - dayChange: viewModel.portfolioSummary.formattedDayChange, - isPositive: viewModel.isDayChangePositive - ) - - // Evolution Chart - if !viewModel.evolutionData.isEmpty { - EvolutionChartCard(data: viewModel.evolutionData) - } - - // Period Returns - PeriodReturnsCard( - monthChange: viewModel.portfolioSummary.formattedMonthChange, - yearChange: viewModel.portfolioSummary.formattedYearChange, - allTimeChange: viewModel.portfolioSummary.formattedAllTimeReturn, - isMonthPositive: viewModel.isMonthChangePositive, - isYearPositive: viewModel.isYearChangePositive, - isAllTimePositive: viewModel.portfolioSummary.allTimeReturn >= 0 - ) - - // Category Breakdown - if !viewModel.categoryMetrics.isEmpty { - CategoryBreakdownCard(categories: viewModel.topCategories) - } - - // Pending Updates - if !viewModel.sourcesNeedingUpdate.isEmpty { - PendingUpdatesCard(sources: viewModel.sourcesNeedingUpdate) - } - } else { - EmptyDashboardView() - } - } - .padding() - } - .navigationTitle("Dashboard") - .refreshable { - viewModel.refreshData() - } - .overlay { - if viewModel.isLoading { - ProgressView() - } - } - } - } -} - -// MARK: - Total Value Card - -struct TotalValueCard: View { - let totalValue: String - let dayChange: String - let isPositive: Bool - - var body: some View { - VStack(spacing: 8) { - Text("Total Portfolio Value") - .font(.subheadline) - .foregroundColor(.secondary) - - Text(totalValue) - .font(.system(size: 42, weight: .bold, design: .rounded)) - .foregroundColor(.primary) - - HStack(spacing: 4) { - Image(systemName: isPositive ? "arrow.up.right" : "arrow.down.right") - .font(.caption) - Text(dayChange) - .font(.subheadline.weight(.medium)) - Text("today") - .font(.subheadline) - .foregroundColor(.secondary) - } - .foregroundColor(isPositive ? .positiveGreen : .negativeRed) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 24) - .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) - .shadow(color: .black.opacity(0.05), radius: 8, y: 2) - } -} - -// MARK: - Period Returns Card - -struct PeriodReturnsCard: View { - let monthChange: String - let yearChange: String - let allTimeChange: String - let isMonthPositive: Bool - let isYearPositive: Bool - let isAllTimePositive: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Returns") - .font(.headline) - - HStack(spacing: 16) { - ReturnPeriodView( - period: "1M", - change: monthChange, - isPositive: isMonthPositive - ) - - Divider() - - ReturnPeriodView( - period: "1Y", - change: yearChange, - isPositive: isYearPositive - ) - - Divider() - - ReturnPeriodView( - period: "All", - change: allTimeChange, - isPositive: isAllTimePositive - ) - } - .frame(maxWidth: .infinity) - } - .padding() - .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) - .shadow(color: .black.opacity(0.05), radius: 8, y: 2) - } -} - -struct ReturnPeriodView: View { - let period: String - let change: String - let isPositive: Bool - - var body: some View { - VStack(spacing: 4) { - Text(period) - .font(.caption) - .foregroundColor(.secondary) - - Text(change) - .font(.subheadline.weight(.semibold)) - .foregroundColor(isPositive ? .positiveGreen : .negativeRed) - .lineLimit(1) - .minimumScaleFactor(0.8) - } - .frame(maxWidth: .infinity) - } -} - -// MARK: - Empty Dashboard View - -struct EmptyDashboardView: View { - var body: some View { - VStack(spacing: 20) { - Image(systemName: "chart.pie") - .font(.system(size: 60)) - .foregroundColor(.secondary) - - Text("Welcome to Investment Tracker") - .font(.title2.weight(.semibold)) - - Text("Start by adding your first investment source to track your portfolio.") - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - - NavigationLink { - AddSourceView() - } label: { - Label("Add Investment Source", systemImage: "plus") - .font(.headline) - .foregroundColor(.white) - .padding() - .frame(maxWidth: .infinity) - .background(Color.appPrimary) - .cornerRadius(AppConstants.UI.cornerRadius) - } - } - .padding() - } -} - -// MARK: - Pending Updates Card - -struct PendingUpdatesCard: View { - let sources: [InvestmentSource] - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Image(systemName: "bell.badge.fill") - .foregroundColor(.appWarning) - Text("Pending Updates") - .font(.headline) - Spacer() - Text("\(sources.count)") - .font(.subheadline) - .foregroundColor(.secondary) - } - - ForEach(sources.prefix(3)) { source in - NavigationLink(destination: SourceDetailView(source: source)) { - HStack { - Circle() - .fill(source.category?.color ?? .gray) - .frame(width: 8, height: 8) - - Text(source.name) - .font(.subheadline) - - Spacer() - - Text(source.latestSnapshot?.date.relativeDescription ?? "Never") - .font(.caption) - .foregroundColor(.secondary) - - Image(systemName: "chevron.right") - .font(.caption) - .foregroundColor(.secondary) - } - } - .buttonStyle(.plain) - } - - if sources.count > 3 { - Text("+ \(sources.count - 3) more") - .font(.caption) - .foregroundColor(.secondary) - } - } - .padding() - .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) - .shadow(color: .black.opacity(0.05), radius: 8, y: 2) - } -} - -#Preview { - DashboardView() - .environmentObject(IAPService()) -} diff --git a/InvestmentTracker/Views/Dashboard/EvolutionChart.swift b/InvestmentTracker/Views/Dashboard/EvolutionChart.swift deleted file mode 100644 index 45e2853..0000000 --- a/InvestmentTracker/Views/Dashboard/EvolutionChart.swift +++ /dev/null @@ -1,158 +0,0 @@ -import SwiftUI -import Charts - -struct EvolutionChartCard: View { - let data: [(date: Date, value: Decimal)] - - @State private var selectedDataPoint: (date: Date, value: Decimal)? - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Text("Portfolio Evolution") - .font(.headline) - - Spacer() - - if let selected = selectedDataPoint { - VStack(alignment: .trailing) { - Text(selected.value.compactCurrencyString) - .font(.subheadline.weight(.semibold)) - Text(selected.date.monthYearString) - .font(.caption) - .foregroundColor(.secondary) - } - } - } - - if data.count >= 2 { - Chart { - ForEach(data, id: \.date) { item in - LineMark( - x: .value("Date", item.date), - y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) - ) - .foregroundStyle(Color.appPrimary) - .interpolationMethod(.catmullRom) - - AreaMark( - x: .value("Date", item.date), - y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) - ) - .foregroundStyle( - LinearGradient( - colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)], - startPoint: .top, - endPoint: .bottom - ) - ) - .interpolationMethod(.catmullRom) - } - - if let selected = selectedDataPoint { - RuleMark(x: .value("Selected", selected.date)) - .foregroundStyle(Color.gray.opacity(0.3)) - .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5])) - - PointMark( - x: .value("Date", selected.date), - y: .value("Value", NSDecimalNumber(decimal: selected.value).doubleValue) - ) - .foregroundStyle(Color.appPrimary) - .symbolSize(100) - } - } - .chartXAxis { - AxisMarks(values: .stride(by: .month, count: 3)) { value in - AxisValueLabel(format: .dateTime.month(.abbreviated)) - } - } - .chartYAxis { - AxisMarks(position: .leading) { value in - AxisValueLabel { - if let doubleValue = value.as(Double.self) { - Text(Decimal(doubleValue).shortCurrencyString) - .font(.caption) - } - } - } - } - .chartOverlay { proxy in - GeometryReader { geometry in - Rectangle() - .fill(.clear) - .contentShape(Rectangle()) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - let x = value.location.x - geometry[proxy.plotAreaFrame].origin.x - guard let date: Date = proxy.value(atX: x) else { return } - - if let closest = data.min(by: { - abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date)) - }) { - selectedDataPoint = closest - } - } - .onEnded { _ in - selectedDataPoint = nil - } - ) - } - } - .frame(height: 200) - } else { - Text("Not enough data to display chart") - .font(.subheadline) - .foregroundColor(.secondary) - .frame(height: 200) - .frame(maxWidth: .infinity) - } - } - .padding() - .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) - .shadow(color: .black.opacity(0.05), radius: 8, y: 2) - } -} - -// MARK: - Mini Sparkline - -struct SparklineView: View { - let data: [(date: Date, value: Decimal)] - let color: Color - - var body: some View { - if data.count >= 2 { - Chart(data, id: \.date) { item in - LineMark( - x: .value("Date", item.date), - y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) - ) - .foregroundStyle(color) - .interpolationMethod(.catmullRom) - } - .chartXAxis(.hidden) - .chartYAxis(.hidden) - .chartLegend(.hidden) - } else { - Rectangle() - .fill(Color.gray.opacity(0.1)) - } - } -} - -#Preview { - let sampleData: [(date: Date, value: Decimal)] = [ - (Date().adding(months: -6), 10000), - (Date().adding(months: -5), 10500), - (Date().adding(months: -4), 10200), - (Date().adding(months: -3), 11000), - (Date().adding(months: -2), 11500), - (Date().adding(months: -1), 11200), - (Date(), 12000) - ] - - return EvolutionChartCard(data: sampleData) - .padding() -} diff --git a/InvestmentTracker/Views/Sources/SourceDetailView.swift b/InvestmentTracker/Views/Sources/SourceDetailView.swift deleted file mode 100644 index e06ce43..0000000 --- a/InvestmentTracker/Views/Sources/SourceDetailView.swift +++ /dev/null @@ -1,426 +0,0 @@ -import SwiftUI -import Charts - -struct SourceDetailView: View { - @EnvironmentObject var iapService: IAPService - @StateObject private var viewModel: SourceDetailViewModel - @Environment(\.dismiss) private var dismiss - - @State private var showingDeleteConfirmation = false - - init(source: InvestmentSource) { - _viewModel = StateObject(wrappedValue: SourceDetailViewModel( - source: source, - iapService: IAPService() - )) - } - - var body: some View { - ScrollView { - VStack(spacing: 20) { - // Header Card - headerCard - - // Quick Actions - quickActions - - // Chart - if !viewModel.chartData.isEmpty { - chartSection - } - - // Metrics - metricsSection - - // Predictions (Premium) - if viewModel.hasEnoughDataForPredictions { - predictionsSection - } - - // Snapshots List - snapshotsSection - } - .padding() - } - .navigationTitle(viewModel.source.name) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Menu { - Button { - viewModel.showingEditSource = true - } label: { - Label("Edit Source", systemImage: "pencil") - } - - Button(role: .destructive) { - showingDeleteConfirmation = true - } label: { - Label("Delete Source", systemImage: "trash") - } - } label: { - Image(systemName: "ellipsis.circle") - } - } - } - .sheet(isPresented: $viewModel.showingAddSnapshot) { - AddSnapshotView(source: viewModel.source) - } - .sheet(isPresented: $viewModel.showingEditSource) { - EditSourceView(source: viewModel.source) - } - .sheet(isPresented: $viewModel.showingPaywall) { - PaywallView() - } - .confirmationDialog( - "Delete Source", - isPresented: $showingDeleteConfirmation, - titleVisibility: .visible - ) { - Button("Delete", role: .destructive) { - // Delete and dismiss - let repository = InvestmentSourceRepository() - repository.deleteSource(viewModel.source) - dismiss() - } - } message: { - Text("This will permanently delete \(viewModel.source.name) and all its snapshots.") - } - } - - // MARK: - Header Card - - private var headerCard: some View { - VStack(spacing: 12) { - // Category badge - HStack { - Image(systemName: viewModel.source.category?.icon ?? "questionmark") - Text(viewModel.categoryName) - } - .font(.caption) - .foregroundColor(Color(hex: viewModel.categoryColor) ?? .gray) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background((Color(hex: viewModel.categoryColor) ?? .gray).opacity(0.1)) - .cornerRadius(20) - - // Current value - Text(viewModel.formattedCurrentValue) - .font(.system(size: 36, weight: .bold, design: .rounded)) - - // Return - HStack(spacing: 4) { - Image(systemName: viewModel.isPositiveReturn ? "arrow.up.right" : "arrow.down.right") - Text(viewModel.formattedTotalReturn) - Text("(\(viewModel.formattedPercentageReturn))") - } - .font(.subheadline.weight(.medium)) - .foregroundColor(viewModel.isPositiveReturn ? .positiveGreen : .negativeRed) - - // Last updated - Text("Last updated: \(viewModel.lastUpdated)") - .font(.caption) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity) - .padding() - .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) - .shadow(color: .black.opacity(0.05), radius: 8, y: 2) - } - - // MARK: - Quick Actions - - private var quickActions: some View { - HStack(spacing: 12) { - Button { - viewModel.showingAddSnapshot = true - } label: { - Label("Add Snapshot", systemImage: "plus.circle.fill") - .font(.subheadline.weight(.semibold)) - .frame(maxWidth: .infinity) - .padding() - .background(Color.appPrimary) - .foregroundColor(.white) - .cornerRadius(AppConstants.UI.cornerRadius) - } - } - } - - // MARK: - Chart Section - - private var chartSection: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Value History") - .font(.headline) - - Chart(viewModel.chartData, id: \.date) { item in - LineMark( - x: .value("Date", item.date), - y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) - ) - .foregroundStyle(Color.appPrimary) - .interpolationMethod(.catmullRom) - - AreaMark( - x: .value("Date", item.date), - y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) - ) - .foregroundStyle( - LinearGradient( - colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)], - startPoint: .top, - endPoint: .bottom - ) - ) - .interpolationMethod(.catmullRom) - } - .chartXAxis { - AxisMarks(values: .stride(by: .month, count: 2)) { value in - AxisValueLabel(format: .dateTime.month(.abbreviated)) - } - } - .chartYAxis { - AxisMarks(position: .leading) { value in - AxisValueLabel { - if let doubleValue = value.as(Double.self) { - Text(Decimal(doubleValue).shortCurrencyString) - .font(.caption) - } - } - } - } - .frame(height: 200) - } - .padding() - .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) - .shadow(color: .black.opacity(0.05), radius: 8, y: 2) - } - - // MARK: - Metrics Section - - private var metricsSection: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Performance Metrics") - .font(.headline) - - LazyVGrid(columns: [ - GridItem(.flexible()), - GridItem(.flexible()) - ], spacing: 16) { - MetricCard(title: "CAGR", value: viewModel.metrics.formattedCAGR) - MetricCard(title: "Volatility", value: viewModel.metrics.formattedVolatility) - MetricCard(title: "Max Drawdown", value: viewModel.metrics.formattedMaxDrawdown) - MetricCard(title: "Sharpe Ratio", value: viewModel.metrics.formattedSharpeRatio) - MetricCard(title: "Win Rate", value: viewModel.metrics.formattedWinRate) - MetricCard(title: "Avg Monthly", value: viewModel.metrics.formattedAverageMonthlyReturn) - } - } - .padding() - .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) - .shadow(color: .black.opacity(0.05), radius: 8, y: 2) - } - - // MARK: - Predictions Section - - private var predictionsSection: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Text("Predictions") - .font(.headline) - - Spacer() - - if !viewModel.canViewPredictions { - Label("Premium", systemImage: "crown.fill") - .font(.caption) - .foregroundColor(.appWarning) - } - } - - if viewModel.canViewPredictions { - if let result = viewModel.predictionResult, !viewModel.predictions.isEmpty { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Algorithm: \(result.algorithm.displayName)") - .font(.caption) - .foregroundColor(.secondary) - - Spacer() - - Text("Confidence: \(result.confidenceLevel.rawValue)") - .font(.caption) - .foregroundColor(Color(hex: result.confidenceLevel.color) ?? .gray) - } - - // Show 12-month prediction - if let lastPrediction = viewModel.predictions.last { - HStack { - VStack(alignment: .leading) { - Text("12-Month Forecast") - .font(.subheadline) - .foregroundColor(.secondary) - Text(lastPrediction.formattedValue) - .font(.title3.weight(.semibold)) - } - - Spacer() - - VStack(alignment: .trailing) { - Text("Range") - .font(.subheadline) - .foregroundColor(.secondary) - Text(lastPrediction.formattedConfidenceRange) - .font(.caption) - } - } - } - } - } - } else { - Button { - viewModel.showingPaywall = true - } label: { - HStack { - Image(systemName: "lock.fill") - Text("Unlock Predictions with Premium") - } - .font(.subheadline) - .frame(maxWidth: .infinity) - .padding() - .background(Color.appPrimary.opacity(0.1)) - .foregroundColor(.appPrimary) - .cornerRadius(AppConstants.UI.smallCornerRadius) - } - } - } - .padding() - .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) - .shadow(color: .black.opacity(0.05), radius: 8, y: 2) - } - - // MARK: - Snapshots Section - - private var snapshotsSection: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Text("Snapshots") - .font(.headline) - - Spacer() - - Text("\(viewModel.snapshotCount)") - .font(.subheadline) - .foregroundColor(.secondary) - } - - if viewModel.isHistoryLimited { - HStack { - Image(systemName: "info.circle") - .foregroundColor(.appWarning) - Text("\(viewModel.hiddenSnapshotCount) older snapshots hidden. Upgrade for full history.") - .font(.caption) - - Spacer() - - Button("Upgrade") { - viewModel.showingPaywall = true - } - .font(.caption.weight(.semibold)) - } - .padding(8) - .background(Color.appWarning.opacity(0.1)) - .cornerRadius(AppConstants.UI.smallCornerRadius) - } - - ForEach(viewModel.snapshots.prefix(10)) { snapshot in - SnapshotRowView(snapshot: snapshot) - .swipeActions(edge: .trailing) { - Button(role: .destructive) { - viewModel.deleteSnapshot(snapshot) - } label: { - Label("Delete", systemImage: "trash") - } - } - - if snapshot.id != viewModel.snapshots.prefix(10).last?.id { - Divider() - } - } - - if viewModel.snapshots.count > 10 { - Text("+ \(viewModel.snapshots.count - 10) more snapshots") - .font(.caption) - .foregroundColor(.secondary) - } - } - .padding() - .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) - .shadow(color: .black.opacity(0.05), radius: 8, y: 2) - } -} - -// MARK: - Metric Card - -struct MetricCard: View { - let title: String - let value: String - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.caption) - .foregroundColor(.secondary) - - Text(value) - .font(.subheadline.weight(.semibold)) - } - .frame(maxWidth: .infinity, alignment: .leading) - } -} - -// MARK: - Snapshot Row View - -struct SnapshotRowView: View { - let snapshot: Snapshot - - var body: some View { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(snapshot.date.friendlyDescription) - .font(.subheadline) - - if let notes = snapshot.notes, !notes.isEmpty { - Text(notes) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - - Spacer() - - VStack(alignment: .trailing, spacing: 2) { - Text(snapshot.formattedValue) - .font(.subheadline.weight(.medium)) - - if snapshot.contribution != nil && snapshot.decimalContribution > 0 { - Text("+ \(snapshot.decimalContribution.currencyString)") - .font(.caption) - .foregroundColor(.appPrimary) - } - } - } - .padding(.vertical, 4) - } -} - -#Preview { - NavigationStack { - Text("Source Detail Preview") - } -} diff --git a/InvestmentTrackerWidget/InvestmentWidget.swift b/InvestmentTrackerWidget/InvestmentWidget.swift deleted file mode 100644 index c3adda2..0000000 --- a/InvestmentTrackerWidget/InvestmentWidget.swift +++ /dev/null @@ -1,299 +0,0 @@ -import WidgetKit -import SwiftUI -import CoreData - -// MARK: - Widget Entry - -struct InvestmentWidgetEntry: TimelineEntry { - let date: Date - let totalValue: Decimal - let dayChange: Decimal - let dayChangePercentage: Double - let topSources: [(name: String, value: Decimal, color: String)] - let sparklineData: [Decimal] -} - -// MARK: - Widget Provider - -struct InvestmentWidgetProvider: TimelineProvider { - func placeholder(in context: Context) -> InvestmentWidgetEntry { - InvestmentWidgetEntry( - date: Date(), - totalValue: 50000, - dayChange: 250, - dayChangePercentage: 0.5, - topSources: [ - ("Stocks", 30000, "#10B981"), - ("Bonds", 15000, "#3B82F6"), - ("Real Estate", 5000, "#F59E0B") - ], - sparklineData: [45000, 46000, 47000, 48000, 49000, 50000] - ) - } - - func getSnapshot(in context: Context, completion: @escaping (InvestmentWidgetEntry) -> Void) { - let entry = fetchData() - completion(entry) - } - - func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { - let entry = fetchData() - - // Refresh every hour - let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: Date()) ?? Date() - let timeline = Timeline(entries: [entry], policy: .after(nextUpdate)) - - completion(timeline) - } - - private func fetchData() -> InvestmentWidgetEntry { - let container = CoreDataStack.createWidgetContainer() - let context = container.viewContext - - // Fetch sources - let sourceRequest: NSFetchRequest = InvestmentSource.fetchRequest() - let sources = (try? context.fetch(sourceRequest)) ?? [] - - // Calculate total value - let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue } - - // Get top sources - let topSources = sources - .sorted { $0.latestValue > $1.latestValue } - .prefix(3) - .map { (name: $0.name, value: $0.latestValue, color: $0.category?.colorHex ?? "#3B82F6") } - - // Calculate day change (simplified - would need proper historical data) - let dayChange: Decimal = 0 - let dayChangePercentage: Double = 0 - - // Get sparkline data (last 6 data points) - let sparklineData: [Decimal] = [] - - return InvestmentWidgetEntry( - date: Date(), - totalValue: totalValue, - dayChange: dayChange, - dayChangePercentage: dayChangePercentage, - topSources: Array(topSources), - sparklineData: sparklineData - ) - } -} - -// MARK: - Small Widget View - -struct SmallWidgetView: View { - let entry: InvestmentWidgetEntry - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Portfolio") - .font(.caption) - .foregroundColor(.secondary) - - Text(entry.totalValue.compactCurrencyString) - .font(.title2.weight(.bold)) - .minimumScaleFactor(0.7) - .lineLimit(1) - - Spacer() - - HStack(spacing: 4) { - Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right") - .font(.caption2) - - Text(String(format: "%.1f%%", entry.dayChangePercentage)) - .font(.caption.weight(.medium)) - } - .foregroundColor(entry.dayChange >= 0 ? .green : .red) - } - .padding() - .containerBackground(.background, for: .widget) - } -} - -// MARK: - Medium Widget View - -struct MediumWidgetView: View { - let entry: InvestmentWidgetEntry - - var body: some View { - HStack(spacing: 16) { - // Left side - Total value - VStack(alignment: .leading, spacing: 8) { - Text("Portfolio") - .font(.caption) - .foregroundColor(.secondary) - - Text(entry.totalValue.compactCurrencyString) - .font(.title.weight(.bold)) - .minimumScaleFactor(0.7) - .lineLimit(1) - - HStack(spacing: 4) { - Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right") - .font(.caption2) - - Text(entry.dayChange.compactCurrencyString) - .font(.caption.weight(.medium)) - - Text("(\(String(format: "%.1f%%", entry.dayChangePercentage)))") - .font(.caption) - .foregroundColor(.secondary) - } - .foregroundColor(entry.dayChange >= 0 ? .green : .red) - } - - Spacer() - - // Right side - Top sources - VStack(alignment: .trailing, spacing: 6) { - ForEach(entry.topSources, id: \.name) { source in - HStack(spacing: 6) { - Text(source.name) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) - - Text(source.value.shortCurrencyString) - .font(.caption.weight(.medium)) - } - } - } - } - .padding() - .containerBackground(.background, for: .widget) - } -} - -// MARK: - Accessory Circular View (Lock Screen) - -struct AccessoryCircularView: View { - let entry: InvestmentWidgetEntry - - var body: some View { - ZStack { - AccessoryWidgetBackground() - - VStack(spacing: 2) { - Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right") - .font(.caption) - - Text(String(format: "%.1f%%", entry.dayChangePercentage)) - .font(.caption2.weight(.semibold)) - } - } - .containerBackground(.background, for: .widget) - } -} - -// MARK: - Accessory Rectangular View (Lock Screen) - -struct AccessoryRectangularView: View { - let entry: InvestmentWidgetEntry - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - Text("Portfolio") - .font(.caption2) - .foregroundColor(.secondary) - - Text(entry.totalValue.compactCurrencyString) - .font(.headline) - - HStack(spacing: 4) { - Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right") - .font(.caption2) - - Text(String(format: "%.1f%%", entry.dayChangePercentage)) - .font(.caption2) - } - .foregroundColor(entry.dayChange >= 0 ? .green : .red) - } - .containerBackground(.background, for: .widget) - } -} - -// MARK: - Widget Configuration - -struct InvestmentWidget: Widget { - let kind: String = "InvestmentWidget" - - var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: InvestmentWidgetProvider()) { entry in - InvestmentWidgetEntryView(entry: entry) - } - .configurationDisplayName("Portfolio Value") - .description("View your total investment portfolio value.") - .supportedFamilies([ - .systemSmall, - .systemMedium, - .accessoryCircular, - .accessoryRectangular - ]) - } -} - -// MARK: - Widget Entry View - -struct InvestmentWidgetEntryView: View { - @Environment(\.widgetFamily) var family - let entry: InvestmentWidgetEntry - - var body: some View { - switch family { - case .systemSmall: - SmallWidgetView(entry: entry) - case .systemMedium: - MediumWidgetView(entry: entry) - case .accessoryCircular: - AccessoryCircularView(entry: entry) - case .accessoryRectangular: - AccessoryRectangularView(entry: entry) - default: - SmallWidgetView(entry: entry) - } - } -} - -// MARK: - Widget Bundle - -@main -struct InvestmentTrackerWidgetBundle: WidgetBundle { - var body: some Widget { - InvestmentWidget() - } -} - -// MARK: - Previews - -#Preview("Small", as: .systemSmall) { - InvestmentWidget() -} timeline: { - InvestmentWidgetEntry( - date: Date(), - totalValue: 50000, - dayChange: 250, - dayChangePercentage: 0.5, - topSources: [], - sparklineData: [] - ) -} - -#Preview("Medium", as: .systemMedium) { - InvestmentWidget() -} timeline: { - InvestmentWidgetEntry( - date: Date(), - totalValue: 50000, - dayChange: 250, - dayChangePercentage: 0.5, - topSources: [ - ("Stocks", 30000, "#10B981"), - ("Bonds", 15000, "#3B82F6"), - ("Real Estate", 5000, "#F59E0B") - ], - sparklineData: [] - ) -} diff --git a/InvestmentTrackerWidgetExtension.entitlements b/InvestmentTrackerWidgetExtension.entitlements deleted file mode 100644 index 9d76b38..0000000 --- a/InvestmentTrackerWidgetExtension.entitlements +++ /dev/null @@ -1,12 +0,0 @@ - - - - - aps-environment - development - com.apple.developer.icloud-container-identifiers - - com.apple.security.application-groups - - - diff --git a/InvestmentTracker.xcodeproj/project.pbxproj b/PortfolioJournal.xcodeproj/project.pbxproj similarity index 69% rename from InvestmentTracker.xcodeproj/project.pbxproj rename to PortfolioJournal.xcodeproj/project.pbxproj index f3cc724..0d15600 100644 --- a/InvestmentTracker.xcodeproj/project.pbxproj +++ b/PortfolioJournal.xcodeproj/project.pbxproj @@ -9,7 +9,11 @@ /* Begin PBXBuildFile section */ 0E241ECF2F0DAA3C00283E2F /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E241ECE2F0DAA3C00283E2F /* WidgetKit.framework */; }; 0E241ED12F0DAA3C00283E2F /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E241ED02F0DAA3C00283E2F /* SwiftUI.framework */; }; - 0E241EE22F0DAA3E00283E2F /* InvestmentTrackerWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0E241ECC2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 0E241EE22F0DAA3E00283E2F /* PortfolioJournalWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0E241ECC2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 0E53752B2F0FD08100F31390 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 0E53752A2F0FD08100F31390 /* FirebaseCore */; }; + 0E53752D2F0FD08600F31390 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 0E53752C2F0FD08600F31390 /* FirebaseAnalytics */; }; + 0E53752F2F0FD09F00F31390 /* CoreData.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E53752E2F0FD09F00F31390 /* CoreData.framework */; }; + 0E5375312F0FD12E00F31390 /* GoogleMobileAds in Frameworks */ = {isa = PBXBuildFile; productRef = 0E5375302F0FD12E00F31390 /* GoogleMobileAds */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -18,7 +22,7 @@ containerPortal = 0E241E312F0DA93A00283E2F /* Project object */; proxyType = 1; remoteGlobalIDString = 0E241ECB2F0DAA3C00283E2F; - remoteInfo = InvestmentTrackerWidgetExtension; + remoteInfo = PortfolioJournalWidgetExtension; }; /* End PBXContainerItemProxy section */ @@ -29,7 +33,7 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( - 0E241EE22F0DAA3E00283E2F /* InvestmentTrackerWidgetExtension.appex in Embed Foundation Extensions */, + 0E241EE22F0DAA3E00283E2F /* PortfolioJournalWidgetExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -37,37 +41,54 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 0E241E392F0DA93A00283E2F /* InvestmentTracker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = InvestmentTracker.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 0E241ECC2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = InvestmentTrackerWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 0E241E392F0DA93A00283E2F /* PortfolioJournal.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PortfolioJournal.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 0E241ECC2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PortfolioJournalWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 0E241ECE2F0DAA3C00283E2F /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 0E241ED02F0DAA3C00283E2F /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; - 0E241EED2F0DAC7D00283E2F /* InvestmentTrackerWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = InvestmentTrackerWidgetExtension.entitlements; sourceTree = ""; }; + 0E241EED2F0DAC7D00283E2F /* PortfolioJournalWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PortfolioJournalWidgetExtension.entitlements; sourceTree = ""; }; + 0E53752E2F0FD09F00F31390 /* CoreData.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreData.framework; path = System/Library/Frameworks/CoreData.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - 0E8318942F0DB2FB0030C2F9 /* Exceptions for "InvestmentTracker" folder in "InvestmentTracker" target */ = { + 0E44005C2F184C7100525EE3 /* Exceptions for "PortfolioJournal" folder in "PortfolioJournalWidgetExtension" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - Assets.xcassets, - Resources/Info.plist, - Resources/Localizable.strings, + Models/CoreData/PortfolioJournal.xcdatamodeld, ); - target = 0E241E382F0DA93A00283E2F /* InvestmentTracker */; + target = 0E241ECB2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension */; + }; + 0E8318942F0DB2FB0030C2F9 /* Exceptions for "PortfolioJournal" folder in "PortfolioJournal" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Resources/Info.plist, + ); + target = 0E241E382F0DA93A00283E2F /* PortfolioJournal */; + }; + 0E8318952F0DB2FB0030C2F9 /* Exceptions for "PortfolioJournalWidget" folder in "PortfolioJournalWidgetExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 0E241ECB2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension */; }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - 0E241E3B2F0DA93A00283E2F /* InvestmentTracker */ = { + 0E241E3B2F0DA93A00283E2F /* PortfolioJournal */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( - 0E8318942F0DB2FB0030C2F9 /* Exceptions for "InvestmentTracker" folder in "InvestmentTracker" target */, + 0E8318942F0DB2FB0030C2F9 /* Exceptions for "PortfolioJournal" folder in "PortfolioJournal" target */, + 0E44005C2F184C7100525EE3 /* Exceptions for "PortfolioJournal" folder in "PortfolioJournalWidgetExtension" target */, ); - path = InvestmentTracker; + path = PortfolioJournal; sourceTree = ""; }; - 0E241ED22F0DAA3C00283E2F /* InvestmentTrackerWidget */ = { + 0E241ED22F0DAA3C00283E2F /* PortfolioJournalWidget */ = { isa = PBXFileSystemSynchronizedRootGroup; - path = InvestmentTrackerWidget; + exceptions = ( + 0E8318952F0DB2FB0030C2F9 /* Exceptions for "PortfolioJournalWidget" folder in "PortfolioJournalWidgetExtension" target */, + ); + path = PortfolioJournalWidget; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -77,6 +98,10 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 0E5375312F0FD12E00F31390 /* GoogleMobileAds in Frameworks */, + 0E53752D2F0FD08600F31390 /* FirebaseAnalytics in Frameworks */, + 0E53752F2F0FD09F00F31390 /* CoreData.framework in Frameworks */, + 0E53752B2F0FD08100F31390 /* FirebaseCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -95,9 +120,9 @@ 0E241E302F0DA93A00283E2F = { isa = PBXGroup; children = ( - 0E241EED2F0DAC7D00283E2F /* InvestmentTrackerWidgetExtension.entitlements */, - 0E241E3B2F0DA93A00283E2F /* InvestmentTracker */, - 0E241ED22F0DAA3C00283E2F /* InvestmentTrackerWidget */, + 0E241EED2F0DAC7D00283E2F /* PortfolioJournalWidgetExtension.entitlements */, + 0E241E3B2F0DA93A00283E2F /* PortfolioJournal */, + 0E241ED22F0DAA3C00283E2F /* PortfolioJournalWidget */, 0E241ECD2F0DAA3C00283E2F /* Frameworks */, 0E241E3A2F0DA93A00283E2F /* Products */, ); @@ -106,8 +131,8 @@ 0E241E3A2F0DA93A00283E2F /* Products */ = { isa = PBXGroup; children = ( - 0E241E392F0DA93A00283E2F /* InvestmentTracker.app */, - 0E241ECC2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension.appex */, + 0E241E392F0DA93A00283E2F /* PortfolioJournal.app */, + 0E241ECC2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension.appex */, ); name = Products; sourceTree = ""; @@ -115,6 +140,7 @@ 0E241ECD2F0DAA3C00283E2F /* Frameworks */ = { isa = PBXGroup; children = ( + 0E53752E2F0FD09F00F31390 /* CoreData.framework */, 0E241ECE2F0DAA3C00283E2F /* WidgetKit.framework */, 0E241ED02F0DAA3C00283E2F /* SwiftUI.framework */, ); @@ -124,9 +150,9 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 0E241E382F0DA93A00283E2F /* InvestmentTracker */ = { + 0E241E382F0DA93A00283E2F /* PortfolioJournal */ = { isa = PBXNativeTarget; - buildConfigurationList = 0E241E4A2F0DA93C00283E2F /* Build configuration list for PBXNativeTarget "InvestmentTracker" */; + buildConfigurationList = 0E241E4A2F0DA93C00283E2F /* Build configuration list for PBXNativeTarget "PortfolioJournal" */; buildPhases = ( 0E241E352F0DA93A00283E2F /* Sources */, 0E241E362F0DA93A00283E2F /* Frameworks */, @@ -139,18 +165,21 @@ 0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( - 0E241E3B2F0DA93A00283E2F /* InvestmentTracker */, + 0E241E3B2F0DA93A00283E2F /* PortfolioJournal */, ); - name = InvestmentTracker; + name = PortfolioJournal; packageProductDependencies = ( + 0E53752A2F0FD08100F31390 /* FirebaseCore */, + 0E53752C2F0FD08600F31390 /* FirebaseAnalytics */, + 0E5375302F0FD12E00F31390 /* GoogleMobileAds */, ); - productName = InvestmentTracker; - productReference = 0E241E392F0DA93A00283E2F /* InvestmentTracker.app */; + productName = PortfolioJournal; + productReference = 0E241E392F0DA93A00283E2F /* PortfolioJournal.app */; productType = "com.apple.product-type.application"; }; - 0E241ECB2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension */ = { + 0E241ECB2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension */ = { isa = PBXNativeTarget; - buildConfigurationList = 0E241EE32F0DAA3E00283E2F /* Build configuration list for PBXNativeTarget "InvestmentTrackerWidgetExtension" */; + buildConfigurationList = 0E241EE32F0DAA3E00283E2F /* Build configuration list for PBXNativeTarget "PortfolioJournalWidgetExtension" */; buildPhases = ( 0E241EC82F0DAA3C00283E2F /* Sources */, 0E241EC92F0DAA3C00283E2F /* Frameworks */, @@ -161,13 +190,13 @@ dependencies = ( ); fileSystemSynchronizedGroups = ( - 0E241ED22F0DAA3C00283E2F /* InvestmentTrackerWidget */, + 0E241ED22F0DAA3C00283E2F /* PortfolioJournalWidget */, ); - name = InvestmentTrackerWidgetExtension; + name = PortfolioJournalWidgetExtension; packageProductDependencies = ( ); - productName = InvestmentTrackerWidgetExtension; - productReference = 0E241ECC2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension.appex */; + productName = PortfolioJournalWidgetExtension; + productReference = 0E241ECC2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension.appex */; productType = "com.apple.product-type.app-extension"; }; /* End PBXNativeTarget section */ @@ -188,12 +217,14 @@ }; }; }; - buildConfigurationList = 0E241E342F0DA93A00283E2F /* Build configuration list for PBXProject "InvestmentTracker" */; + buildConfigurationList = 0E241E342F0DA93A00283E2F /* Build configuration list for PBXProject "PortfolioJournal" */; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, + es, + "es-ES", ); mainGroup = 0E241E302F0DA93A00283E2F; minimizedProjectReferenceProxies = 1; @@ -206,8 +237,8 @@ projectDirPath = ""; projectRoot = ""; targets = ( - 0E241E382F0DA93A00283E2F /* InvestmentTracker */, - 0E241ECB2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension */, + 0E241E382F0DA93A00283E2F /* PortfolioJournal */, + 0E241ECB2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension */, ); }; /* End PBXProject section */ @@ -249,7 +280,7 @@ /* Begin PBXTargetDependency section */ 0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = 0E241ECB2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension */; + target = 0E241ECB2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension */; targetProxy = 0E241EE02F0DAA3E00283E2F /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ @@ -260,13 +291,16 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = InvestmentTracker/InvestmentTracker.entitlements; + CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets; + DEVELOPMENT_TEAM = 2825Q76T7H; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = InvestmentTracker/Info.plist; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = PortfolioJournal/Resources/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Journal"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -277,7 +311,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.InvestmentTracker; + PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -294,13 +328,16 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = InvestmentTracker/InvestmentTracker.entitlements; + CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournal.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets; + DEVELOPMENT_TEAM = 2825Q76T7H; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = InvestmentTracker/Info.plist; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = PortfolioJournal/Resources/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Journal"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -311,7 +348,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.InvestmentTracker; + PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -447,12 +484,14 @@ buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; - CODE_SIGN_ENTITLEMENTS = InvestmentTrackerWidgetExtension.entitlements; + CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = ""; - INFOPLIST_KEY_CFBundleDisplayName = InvestmentTrackerWidget; + DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets; + DEVELOPMENT_TEAM = 2825Q76T7H; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = PortfolioJournalWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Journal Widget"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -460,7 +499,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.InvestmentTracker.InvestmentTrackerWidget; + PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -477,12 +516,14 @@ buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; - CODE_SIGN_ENTITLEMENTS = InvestmentTrackerWidgetExtension.entitlements; + CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = ""; - INFOPLIST_KEY_CFBundleDisplayName = InvestmentTrackerWidget; + DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets; + DEVELOPMENT_TEAM = 2825Q76T7H; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = PortfolioJournalWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Journal Widget"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -490,7 +531,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.InvestmentTracker.InvestmentTrackerWidget; + PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -505,7 +546,7 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 0E241E342F0DA93A00283E2F /* Build configuration list for PBXProject "InvestmentTracker" */ = { + 0E241E342F0DA93A00283E2F /* Build configuration list for PBXProject "PortfolioJournal" */ = { isa = XCConfigurationList; buildConfigurations = ( 0E241E4D2F0DA93C00283E2F /* Debug */, @@ -514,7 +555,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 0E241E4A2F0DA93C00283E2F /* Build configuration list for PBXNativeTarget "InvestmentTracker" */ = { + 0E241E4A2F0DA93C00283E2F /* Build configuration list for PBXNativeTarget "PortfolioJournal" */ = { isa = XCConfigurationList; buildConfigurations = ( 0E241E4B2F0DA93C00283E2F /* Debug */, @@ -523,7 +564,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 0E241EE32F0DAA3E00283E2F /* Build configuration list for PBXNativeTarget "InvestmentTrackerWidgetExtension" */ = { + 0E241EE32F0DAA3E00283E2F /* Build configuration list for PBXNativeTarget "PortfolioJournalWidgetExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( 0E241EE42F0DAA3E00283E2F /* Debug */, @@ -552,6 +593,24 @@ }; }; /* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 0E53752A2F0FD08100F31390 /* FirebaseCore */ = { + isa = XCSwiftPackageProductDependency; + package = 0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseCore; + }; + 0E53752C2F0FD08600F31390 /* FirebaseAnalytics */ = { + isa = XCSwiftPackageProductDependency; + package = 0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAnalytics; + }; + 0E5375302F0FD12E00F31390 /* GoogleMobileAds */ = { + isa = XCSwiftPackageProductDependency; + package = 0E241EEC2F0DAC2D00283E2F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */; + productName = GoogleMobileAds; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 0E241E312F0DA93A00283E2F /* Project object */; } diff --git a/InvestmentTracker.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/PortfolioJournal.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from InvestmentTracker.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to PortfolioJournal.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/InvestmentTracker.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/PortfolioJournal.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings similarity index 100% rename from InvestmentTracker.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings rename to PortfolioJournal.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings diff --git a/InvestmentTracker.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/PortfolioJournal.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved similarity index 100% rename from InvestmentTracker.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved rename to PortfolioJournal.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/PortfolioJournal.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate b/PortfolioJournal.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..117fd35 Binary files /dev/null and b/PortfolioJournal.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/InvestmentTracker.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/WorkspaceSettings.xcsettings b/PortfolioJournal.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/WorkspaceSettings.xcsettings similarity index 100% rename from InvestmentTracker.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/WorkspaceSettings.xcsettings rename to PortfolioJournal.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/WorkspaceSettings.xcsettings diff --git a/InvestmentTracker.xcodeproj/xcshareddata/xcschemes/InvestmentTracker.xcscheme b/PortfolioJournal.xcodeproj/xcshareddata/xcschemes/PortfolioJournal.xcscheme similarity index 82% rename from InvestmentTracker.xcodeproj/xcshareddata/xcschemes/InvestmentTracker.xcscheme rename to PortfolioJournal.xcodeproj/xcshareddata/xcschemes/PortfolioJournal.xcscheme index 0894426..ae4f789 100644 --- a/InvestmentTracker.xcodeproj/xcshareddata/xcschemes/InvestmentTracker.xcscheme +++ b/PortfolioJournal.xcodeproj/xcshareddata/xcschemes/PortfolioJournal.xcscheme @@ -16,9 +16,9 @@ + BuildableName = "PortfolioJournal.app" + BlueprintName = "PortfolioJournal" + ReferencedContainer = "container:PortfolioJournal.xcodeproj"> @@ -45,9 +45,9 @@ + BuildableName = "PortfolioJournal.app" + BlueprintName = "PortfolioJournal" + ReferencedContainer = "container:PortfolioJournal.xcodeproj"> @@ -62,9 +62,9 @@ + BuildableName = "PortfolioJournal.app" + BlueprintName = "PortfolioJournal" + ReferencedContainer = "container:PortfolioJournal.xcodeproj"> diff --git a/InvestmentTracker.xcodeproj/xcshareddata/xcschemes/InvestmentTrackerWidgetExtension.xcscheme b/PortfolioJournal.xcodeproj/xcshareddata/xcschemes/PortfolioJournalWidgetExtension.xcscheme similarity index 80% rename from InvestmentTracker.xcodeproj/xcshareddata/xcschemes/InvestmentTrackerWidgetExtension.xcscheme rename to PortfolioJournal.xcodeproj/xcshareddata/xcschemes/PortfolioJournalWidgetExtension.xcscheme index f253391..38d3c88 100644 --- a/InvestmentTracker.xcodeproj/xcshareddata/xcschemes/InvestmentTrackerWidgetExtension.xcscheme +++ b/PortfolioJournal.xcodeproj/xcshareddata/xcschemes/PortfolioJournalWidgetExtension.xcscheme @@ -17,9 +17,9 @@ + BuildableName = "PortfolioJournalWidgetExtension.appex" + BlueprintName = "PortfolioJournalWidgetExtension" + ReferencedContainer = "container:PortfolioJournal.xcodeproj"> + BuildableName = "PortfolioJournal.app" + BlueprintName = "PortfolioJournal" + ReferencedContainer = "container:PortfolioJournal.xcodeproj"> @@ -62,9 +62,9 @@ + BuildableName = "PortfolioJournal.app" + BlueprintName = "PortfolioJournal" + ReferencedContainer = "container:PortfolioJournal.xcodeproj"> @@ -81,9 +81,9 @@ + BuildableName = "PortfolioJournal.app" + BlueprintName = "PortfolioJournal" + ReferencedContainer = "container:PortfolioJournal.xcodeproj"> diff --git a/InvestmentTracker.xcodeproj/xcuserdata/alexandrev.xcuserdatad/xcschemes/xcschememanagement.plist b/PortfolioJournal.xcodeproj/xcuserdata/alexandrev.xcuserdatad/xcschemes/xcschememanagement.plist similarity index 85% rename from InvestmentTracker.xcodeproj/xcuserdata/alexandrev.xcuserdatad/xcschemes/xcschememanagement.plist rename to PortfolioJournal.xcodeproj/xcuserdata/alexandrev.xcuserdatad/xcschemes/xcschememanagement.plist index 2412e25..e0c0940 100644 --- a/InvestmentTracker.xcodeproj/xcuserdata/alexandrev.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/PortfolioJournal.xcodeproj/xcuserdata/alexandrev.xcuserdatad/xcschemes/xcschememanagement.plist @@ -4,12 +4,12 @@ SchemeUserState - InvestmentTracker.xcscheme_^#shared#^_ + PortfolioJournal.xcscheme_^#shared#^_ orderHint 1 - InvestmentTrackerWidgetExtension.xcscheme_^#shared#^_ + PortfolioJournalWidgetExtension.xcscheme_^#shared#^_ orderHint 0 diff --git a/PortfolioJournal/.DS_Store b/PortfolioJournal/.DS_Store new file mode 100644 index 0000000..c6315b6 Binary files /dev/null and b/PortfolioJournal/.DS_Store differ diff --git a/InvestmentTracker/App/AppDelegate.swift b/PortfolioJournal/App/AppDelegate.swift similarity index 73% rename from InvestmentTracker/App/AppDelegate.swift rename to PortfolioJournal/App/AppDelegate.swift index 13d522f..7427286 100644 --- a/InvestmentTracker/App/AppDelegate.swift +++ b/PortfolioJournal/App/AppDelegate.swift @@ -1,17 +1,27 @@ import UIKit import UserNotifications +import FirebaseCore +import FirebaseAnalytics +import GoogleMobileAds class AppDelegate: NSObject, UIApplicationDelegate { func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { + // Initialize Firebase (only if GoogleService-Info.plist exists) + if Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist") != nil { + FirebaseApp.configure() + } else { + print("Warning: GoogleService-Info.plist not found. Firebase disabled.") + } + + // Initialize Google Mobile Ads + MobileAds.shared.start() + // Request notification permissions requestNotificationPermissions() - // Register background tasks - registerBackgroundTasks() - return true } @@ -25,11 +35,6 @@ class AppDelegate: NSObject, UIApplicationDelegate { } } - private func registerBackgroundTasks() { - // Background fetch for updating widget data and notification badges - UIApplication.shared.setMinimumBackgroundFetchInterval(UIApplication.backgroundFetchIntervalMinimum) - } - func application( _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data diff --git a/PortfolioJournal/App/ContentView.swift b/PortfolioJournal/App/ContentView.swift new file mode 100644 index 0000000..b50bd71 --- /dev/null +++ b/PortfolioJournal/App/ContentView.swift @@ -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()) +} diff --git a/PortfolioJournal/App/PortfolioJournalApp.swift b/PortfolioJournal/App/PortfolioJournalApp.swift new file mode 100644 index 0000000..eddd04b --- /dev/null +++ b/PortfolioJournal/App/PortfolioJournalApp.swift @@ -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() + } + } + } +} diff --git a/InvestmentTracker/Assets.xcassets/AccentColor.colorset/Contents.json b/PortfolioJournal/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from InvestmentTracker/Assets.xcassets/AccentColor.colorset/Contents.json rename to PortfolioJournal/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/PortfolioJournal/Assets.xcassets/AppIcon.appiconset/AppIcon-dark.png b/PortfolioJournal/Assets.xcassets/AppIcon.appiconset/AppIcon-dark.png new file mode 100644 index 0000000..da68db9 Binary files /dev/null and b/PortfolioJournal/Assets.xcassets/AppIcon.appiconset/AppIcon-dark.png differ diff --git a/PortfolioJournal/Assets.xcassets/AppIcon.appiconset/AppIcon-tinted.png b/PortfolioJournal/Assets.xcassets/AppIcon.appiconset/AppIcon-tinted.png new file mode 100644 index 0000000..f732919 Binary files /dev/null and b/PortfolioJournal/Assets.xcassets/AppIcon.appiconset/AppIcon-tinted.png differ diff --git a/PortfolioJournal/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/PortfolioJournal/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..f51c7e5 Binary files /dev/null and b/PortfolioJournal/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/InvestmentTrackerWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/PortfolioJournal/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 84% rename from InvestmentTrackerWidget/Assets.xcassets/AppIcon.appiconset/Contents.json rename to PortfolioJournal/Assets.xcassets/AppIcon.appiconset/Contents.json index 2305880..4f4b8fb 100644 --- a/InvestmentTrackerWidget/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/PortfolioJournal/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -12,6 +13,7 @@ "value" : "dark" } ], + "filename" : "AppIcon-dark.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -23,6 +25,7 @@ "value" : "tinted" } ], + "filename" : "AppIcon-tinted.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/PortfolioJournal/Assets.xcassets/BrandMark.imageset/BrandMark@1x.png b/PortfolioJournal/Assets.xcassets/BrandMark.imageset/BrandMark@1x.png new file mode 100644 index 0000000..8067035 Binary files /dev/null and b/PortfolioJournal/Assets.xcassets/BrandMark.imageset/BrandMark@1x.png differ diff --git a/PortfolioJournal/Assets.xcassets/BrandMark.imageset/BrandMark@2x.png b/PortfolioJournal/Assets.xcassets/BrandMark.imageset/BrandMark@2x.png new file mode 100644 index 0000000..fb64278 Binary files /dev/null and b/PortfolioJournal/Assets.xcassets/BrandMark.imageset/BrandMark@2x.png differ diff --git a/PortfolioJournal/Assets.xcassets/BrandMark.imageset/BrandMark@3x.png b/PortfolioJournal/Assets.xcassets/BrandMark.imageset/BrandMark@3x.png new file mode 100644 index 0000000..2bf9b97 Binary files /dev/null and b/PortfolioJournal/Assets.xcassets/BrandMark.imageset/BrandMark@3x.png differ diff --git a/PortfolioJournal/Assets.xcassets/BrandMark.imageset/Contents.json b/PortfolioJournal/Assets.xcassets/BrandMark.imageset/Contents.json new file mode 100644 index 0000000..eb28959 --- /dev/null +++ b/PortfolioJournal/Assets.xcassets/BrandMark.imageset/Contents.json @@ -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 + } +} diff --git a/InvestmentTracker/Assets.xcassets/Contents.json b/PortfolioJournal/Assets.xcassets/Contents.json similarity index 100% rename from InvestmentTracker/Assets.xcassets/Contents.json rename to PortfolioJournal/Assets.xcassets/Contents.json diff --git a/PortfolioJournal/Models/CategoryEvolutionPoint.swift b/PortfolioJournal/Models/CategoryEvolutionPoint.swift new file mode 100644 index 0000000..23b3efb --- /dev/null +++ b/PortfolioJournal/Models/CategoryEvolutionPoint.swift @@ -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)" + } +} diff --git a/PortfolioJournal/Models/CoreData/Account+CoreDataClass.swift b/PortfolioJournal/Models/CoreData/Account+CoreDataClass.swift new file mode 100644 index 0000000..abc5977 --- /dev/null +++ b/PortfolioJournal/Models/CoreData/Account+CoreDataClass.swift @@ -0,0 +1,54 @@ +import Foundation +import CoreData + +@objc(Account) +public class Account: NSManagedObject, Identifiable { + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(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 ?? [] + return set.sorted { $0.name < $1.name } + } + + var goalsArray: [Goal] { + let set = goals as? Set ?? [] + return set.sorted { $0.createdAt < $1.createdAt } + } + + var frequency: NotificationFrequency { + NotificationFrequency(rawValue: notificationFrequency) ?? .monthly + } + + var currencyCode: String? { + currency?.isEmpty == false ? currency : nil + } +} + diff --git a/InvestmentTracker/Models/CoreData/AppSettings+CoreDataClass.swift b/PortfolioJournal/Models/CoreData/AppSettings+CoreDataClass.swift similarity index 77% rename from InvestmentTracker/Models/CoreData/AppSettings+CoreDataClass.swift rename to PortfolioJournal/Models/CoreData/AppSettings+CoreDataClass.swift index 6be2078..e972494 100644 --- a/InvestmentTracker/Models/CoreData/AppSettings+CoreDataClass.swift +++ b/PortfolioJournal/Models/CoreData/AppSettings+CoreDataClass.swift @@ -14,13 +14,21 @@ public class AppSettings: NSManagedObject, Identifiable { @NSManaged public var onboardingCompleted: Bool @NSManaged public var lastSyncDate: Date? @NSManaged public var createdAt: Date + @NSManaged public var inputMode: String + @NSManaged public var selectedAccountId: UUID? + @NSManaged public var showAllAccounts: Bool public override func awakeFromInsert() { super.awakeFromInsert() id = UUID() - currency = "EUR" + let localeCode = Locale.current.currency?.identifier + currency = CurrencyPicker.commonCodes.contains(localeCode ?? "") + ? (localeCode ?? "EUR") + : "EUR" enableAnalytics = true onboardingCompleted = false + inputMode = InputMode.simple.rawValue + showAllAccounts = true createdAt = Date() // Default notification time: 9:00 AM @@ -35,8 +43,10 @@ public class AppSettings: NSManagedObject, Identifiable { extension AppSettings { var currencySymbol: String { - let locale = Locale.current - return locale.currencySymbol ?? "€" + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = currency + return formatter.currencySymbol ?? "€" } var notificationTimeString: String { diff --git a/PortfolioJournal/Models/CoreData/Asset+CoreDataClass.swift b/PortfolioJournal/Models/CoreData/Asset+CoreDataClass.swift new file mode 100644 index 0000000..ac2c191 --- /dev/null +++ b/PortfolioJournal/Models/CoreData/Asset+CoreDataClass.swift @@ -0,0 +1,50 @@ +import Foundation +import CoreData + +@objc(Asset) +public class Asset: NSManagedObject, Identifiable { + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(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" + } + } +} + diff --git a/InvestmentTracker/Models/CoreData/Category+CoreDataClass.swift b/PortfolioJournal/Models/CoreData/Category+CoreDataClass.swift similarity index 100% rename from InvestmentTracker/Models/CoreData/Category+CoreDataClass.swift rename to PortfolioJournal/Models/CoreData/Category+CoreDataClass.swift diff --git a/PortfolioJournal/Models/CoreData/Goal+CoreDataClass.swift b/PortfolioJournal/Models/CoreData/Goal+CoreDataClass.swift new file mode 100644 index 0000000..d99b175 --- /dev/null +++ b/PortfolioJournal/Models/CoreData/Goal+CoreDataClass.swift @@ -0,0 +1,34 @@ +import Foundation +import CoreData + +@objc(Goal) +public class Goal: NSManagedObject, Identifiable { + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(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 + } +} + diff --git a/PortfolioJournal/Models/CoreData/InvestmentSource+CoreDataClass.swift b/PortfolioJournal/Models/CoreData/InvestmentSource+CoreDataClass.swift new file mode 100644 index 0000000..cd2cccf --- /dev/null +++ b/PortfolioJournal/Models/CoreData/InvestmentSource+CoreDataClass.swift @@ -0,0 +1,239 @@ +import Foundation +import CoreData + +@objc(InvestmentSource) +public class InvestmentSource: NSManagedObject, Identifiable { + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(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 ?? [] + 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 ?? [] + 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, !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 ?? [] + 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, 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 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 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 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 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) +} diff --git a/PortfolioJournal/Models/CoreData/PortfolioJournal.xcdatamodeld/PortfolioJournal.xcdatamodel/contents b/PortfolioJournal/Models/CoreData/PortfolioJournal.xcdatamodeld/PortfolioJournal.xcdatamodel/contents new file mode 100644 index 0000000..4137f6f --- /dev/null +++ b/PortfolioJournal/Models/CoreData/PortfolioJournal.xcdatamodeld/PortfolioJournal.xcdatamodel/contents @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/InvestmentTracker/Models/CoreData/PredictionCache+CoreDataClass.swift b/PortfolioJournal/Models/CoreData/PredictionCache+CoreDataClass.swift similarity index 96% rename from InvestmentTracker/Models/CoreData/PredictionCache+CoreDataClass.swift rename to PortfolioJournal/Models/CoreData/PredictionCache+CoreDataClass.swift index 25db4af..1dd200f 100644 --- a/InvestmentTracker/Models/CoreData/PredictionCache+CoreDataClass.swift +++ b/PortfolioJournal/Models/CoreData/PredictionCache+CoreDataClass.swift @@ -30,6 +30,7 @@ enum PredictionAlgorithm: String, CaseIterable, Identifiable { case linear = "linear" case exponentialSmoothing = "exponential_smoothing" case movingAverage = "moving_average" + case holtTrend = "holt_trend" var id: String { rawValue } @@ -38,6 +39,7 @@ enum PredictionAlgorithm: String, CaseIterable, Identifiable { case .linear: return "Linear Regression" case .exponentialSmoothing: return "Exponential Smoothing" case .movingAverage: return "Moving Average" + case .holtTrend: return "Holt Trend" } } @@ -49,6 +51,8 @@ enum PredictionAlgorithm: String, CaseIterable, Identifiable { return "Gives more weight to recent data points" case .movingAverage: return "Smooths out short-term fluctuations" + case .holtTrend: + return "Captures level and trend changes over time" } } } diff --git a/InvestmentTracker/Models/CoreData/PremiumStatus+CoreDataClass.swift b/PortfolioJournal/Models/CoreData/PremiumStatus+CoreDataClass.swift similarity index 100% rename from InvestmentTracker/Models/CoreData/PremiumStatus+CoreDataClass.swift rename to PortfolioJournal/Models/CoreData/PremiumStatus+CoreDataClass.swift diff --git a/InvestmentTracker/Models/CoreData/Snapshot+CoreDataClass.swift b/PortfolioJournal/Models/CoreData/Snapshot+CoreDataClass.swift similarity index 90% rename from InvestmentTracker/Models/CoreData/Snapshot+CoreDataClass.swift rename to PortfolioJournal/Models/CoreData/Snapshot+CoreDataClass.swift index 031f32c..76fda71 100644 --- a/InvestmentTracker/Models/CoreData/Snapshot+CoreDataClass.swift +++ b/PortfolioJournal/Models/CoreData/Snapshot+CoreDataClass.swift @@ -35,11 +35,7 @@ extension Snapshot { } var formattedValue: String { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - formatter.currencyCode = "EUR" - formatter.maximumFractionDigits = 2 - return formatter.string(from: value ?? 0) ?? "€0.00" + CurrencyFormatter.format(value?.decimalValue ?? Decimal.zero, style: .currency, maximumFractionDigits: 2) } var formattedDate: String { diff --git a/PortfolioJournal/Models/CoreData/Transaction+CoreDataClass.swift b/PortfolioJournal/Models/CoreData/Transaction+CoreDataClass.swift new file mode 100644 index 0000000..65a36dc --- /dev/null +++ b/PortfolioJournal/Models/CoreData/Transaction+CoreDataClass.swift @@ -0,0 +1,75 @@ +import Foundation +import CoreData + +@objc(Transaction) +public class Transaction: NSManagedObject, Identifiable { + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(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 + } +} + diff --git a/PortfolioJournal/Models/CoreDataStack.swift b/PortfolioJournal/Models/CoreDataStack.swift new file mode 100644 index 0000000..010ca65 --- /dev/null +++ b/PortfolioJournal/Models/CoreDataStack.swift @@ -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(entityName: "Snapshot") + snapshotRequest.resultType = .countResultType + let snapshotCount = (try? context.count(for: snapshotRequest)) ?? 0 + if snapshotCount > 0 { + return true + } + + let sourceRequest = NSFetchRequest(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 + } +} diff --git a/PortfolioJournal/Models/InputMode.swift b/PortfolioJournal/Models/InputMode.swift new file mode 100644 index 0000000..5bace90 --- /dev/null +++ b/PortfolioJournal/Models/InputMode.swift @@ -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." + } + } +} + diff --git a/InvestmentTracker/Models/InvestmentMetrics.swift b/PortfolioJournal/Models/InvestmentMetrics.swift similarity index 83% rename from InvestmentTracker/Models/InvestmentMetrics.swift rename to PortfolioJournal/Models/InvestmentMetrics.swift index a479476..e1a80b5 100644 --- a/InvestmentTracker/Models/InvestmentMetrics.swift +++ b/PortfolioJournal/Models/InvestmentMetrics.swift @@ -87,11 +87,7 @@ extension InvestmentMetrics { } private func formatCurrency(_ value: Decimal) -> String { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - formatter.currencyCode = "EUR" - formatter.maximumFractionDigits = 2 - return formatter.string(from: value as NSDecimalNumber) ?? "€0.00" + CurrencyFormatter.format(value, style: .currency, maximumFractionDigits: 2) } } @@ -132,11 +128,7 @@ struct CategoryMetrics: Identifiable { let metrics: InvestmentMetrics var formattedTotalValue: String { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - formatter.currencyCode = "EUR" - formatter.maximumFractionDigits = 0 - return formatter.string(from: totalValue as NSDecimalNumber) ?? "€0" + CurrencyFormatter.format(totalValue, style: .currency, maximumFractionDigits: 0) } var formattedPercentage: String { @@ -163,11 +155,7 @@ struct PortfolioSummary { let lastUpdated: Date? var formattedTotalValue: String { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - formatter.currencyCode = "EUR" - formatter.maximumFractionDigits = 0 - return formatter.string(from: totalValue as NSDecimalNumber) ?? "€0" + CurrencyFormatter.format(totalValue, style: .currency, maximumFractionDigits: 0) } var formattedDayChange: String { @@ -187,13 +175,8 @@ struct PortfolioSummary { } private func formatChange(_ absolute: Decimal, _ percentage: Double) -> String { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - formatter.currencyCode = "EUR" - formatter.maximumFractionDigits = 0 - let prefix = absolute >= 0 ? "+" : "" - let absString = formatter.string(from: absolute as NSDecimalNumber) ?? "€0" + let absString = CurrencyFormatter.format(absolute, style: .currency, maximumFractionDigits: 0) let pctString = String(format: "%.2f%%", percentage) return "\(prefix)\(absString) (\(prefix)\(pctString))" diff --git a/PortfolioJournal/Models/MonthlyCheckInStats.swift b/PortfolioJournal/Models/MonthlyCheckInStats.swift new file mode 100644 index 0000000..f8e440a --- /dev/null +++ b/PortfolioJournal/Models/MonthlyCheckInStats.swift @@ -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) + } +} diff --git a/PortfolioJournal/Models/MonthlySummary.swift b/PortfolioJournal/Models/MonthlySummary.swift new file mode 100644 index 0000000..338ba6e --- /dev/null +++ b/PortfolioJournal/Models/MonthlySummary.swift @@ -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") + } +} diff --git a/InvestmentTracker/Models/Prediction.swift b/PortfolioJournal/Models/Prediction.swift similarity index 77% rename from InvestmentTracker/Models/Prediction.swift rename to PortfolioJournal/Models/Prediction.swift index 0783837..e7dd728 100644 --- a/InvestmentTracker/Models/Prediction.swift +++ b/PortfolioJournal/Models/Prediction.swift @@ -44,11 +44,7 @@ extension PredictionAlgorithm: Codable {} extension Prediction { var formattedValue: String { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - formatter.currencyCode = "EUR" - formatter.maximumFractionDigits = 0 - return formatter.string(from: predictedValue as NSDecimalNumber) ?? "€0" + CurrencyFormatter.format(predictedValue, style: .currency, maximumFractionDigits: 0) } var formattedDate: String { @@ -58,13 +54,16 @@ extension Prediction { } var formattedConfidenceRange: String { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - formatter.currencyCode = "EUR" - formatter.maximumFractionDigits = 0 - - let lower = formatter.string(from: confidenceInterval.lower as NSDecimalNumber) ?? "€0" - let upper = formatter.string(from: confidenceInterval.upper as NSDecimalNumber) ?? "€0" + let lower = CurrencyFormatter.format( + confidenceInterval.lower, + style: .currency, + maximumFractionDigits: 0 + ) + let upper = CurrencyFormatter.format( + confidenceInterval.upper, + style: .currency, + maximumFractionDigits: 0 + ) return "\(lower) - \(upper)" } diff --git a/InvestmentTracker/InvestmentTracker.entitlements b/PortfolioJournal/PortfolioJournal.entitlements similarity index 70% rename from InvestmentTracker/InvestmentTracker.entitlements rename to PortfolioJournal/PortfolioJournal.entitlements index e45c380..72e48d1 100644 --- a/InvestmentTracker/InvestmentTracker.entitlements +++ b/PortfolioJournal/PortfolioJournal.entitlements @@ -6,15 +6,17 @@ development com.apple.developer.icloud-container-identifiers - iCloud.com.yourteam.investmenttracker + iCloud.com.alexandrevazquez.portfoliojournal com.apple.developer.icloud-services CloudKit com.apple.developer.ubiquity-kvstore-identifier - $(TeamIdentifierPrefix)$(CFBundleIdentifier) + $(TeamIdentifierPrefix)com.alexandrevazquez.portfoliojournal com.apple.security.application-groups - + + group.com.alexandrevazquez.portfoliojournal + diff --git a/PortfolioJournal/PortfolioJournalDebug.entitlements b/PortfolioJournal/PortfolioJournalDebug.entitlements new file mode 100644 index 0000000..72e48d1 --- /dev/null +++ b/PortfolioJournal/PortfolioJournalDebug.entitlements @@ -0,0 +1,22 @@ + + + + + aps-environment + development + com.apple.developer.icloud-container-identifiers + + iCloud.com.alexandrevazquez.portfoliojournal + + com.apple.developer.icloud-services + + CloudKit + + com.apple.developer.ubiquity-kvstore-identifier + $(TeamIdentifierPrefix)com.alexandrevazquez.portfoliojournal + com.apple.security.application-groups + + group.com.alexandrevazquez.portfoliojournal + + + diff --git a/PortfolioJournal/Repositories/AccountRepository.swift b/PortfolioJournal/Repositories/AccountRepository.swift new file mode 100644 index 0000000..7f9d577 --- /dev/null +++ b/PortfolioJournal/Repositories/AccountRepository.swift @@ -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.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.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.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)") + } + } +} diff --git a/InvestmentTracker/Repositories/CategoryRepository.swift b/PortfolioJournal/Repositories/CategoryRepository.swift similarity index 73% rename from InvestmentTracker/Repositories/CategoryRepository.swift rename to PortfolioJournal/Repositories/CategoryRepository.swift index 606beab..8af6679 100644 --- a/InvestmentTracker/Repositories/CategoryRepository.swift +++ b/PortfolioJournal/Repositories/CategoryRepository.swift @@ -23,23 +23,27 @@ class CategoryRepository: ObservableObject { } @objc private func contextDidChange(_ notification: Notification) { + guard isRelevantChange(notification) else { return } fetchCategories() } // MARK: - Fetch func fetchCategories() { - let request: NSFetchRequest = Category.fetchRequest() - request.sortDescriptors = [ - NSSortDescriptor(keyPath: \Category.sortOrder, ascending: true), - NSSortDescriptor(keyPath: \Category.name, ascending: true) - ] + context.perform { [weak self] in + guard let self else { return } + let request: NSFetchRequest = Category.fetchRequest() + request.sortDescriptors = [ + NSSortDescriptor(keyPath: \Category.sortOrder, ascending: true), + NSSortDescriptor(keyPath: \Category.name, ascending: true) + ] - do { - categories = try context.fetch(request) - } catch { - print("Failed to fetch categories: \(error)") - categories = [] + do { + self.categories = try self.context.fetch(request) + } catch { + print("Failed to fetch categories: \(error)") + self.categories = [] + } } } @@ -140,8 +144,28 @@ class CategoryRepository: ObservableObject { do { try context.save() fetchCategories() + CoreDataStack.shared.refreshWidgetData() } catch { print("Failed to save context: \(error)") } } + + private func isRelevantChange(_ notification: Notification) -> Bool { + guard let info = notification.userInfo else { return false } + let keys: [String] = [ + NSInsertedObjectsKey, + NSUpdatedObjectsKey, + NSDeletedObjectsKey, + NSRefreshedObjectsKey + ] + + for key in keys { + if let objects = info[key] as? Set { + if objects.contains(where: { $0 is Category }) { + return true + } + } + } + return false + } } diff --git a/PortfolioJournal/Repositories/GoalRepository.swift b/PortfolioJournal/Repositories/GoalRepository.swift new file mode 100644 index 0000000..3493cb7 --- /dev/null +++ b/PortfolioJournal/Repositories/GoalRepository.swift @@ -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.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)") + } + } +} diff --git a/InvestmentTracker/Repositories/InvestmentSourceRepository.swift b/PortfolioJournal/Repositories/InvestmentSourceRepository.swift similarity index 69% rename from InvestmentTracker/Repositories/InvestmentSourceRepository.swift rename to PortfolioJournal/Repositories/InvestmentSourceRepository.swift index db1da91..e79ea59 100644 --- a/InvestmentTracker/Repositories/InvestmentSourceRepository.swift +++ b/PortfolioJournal/Repositories/InvestmentSourceRepository.swift @@ -23,22 +23,29 @@ class InvestmentSourceRepository: ObservableObject { } @objc private func contextDidChange(_ notification: Notification) { + guard isRelevantChange(notification) else { return } fetchSources() } // MARK: - Fetch - func fetchSources() { - let request: NSFetchRequest = InvestmentSource.fetchRequest() - request.sortDescriptors = [ - NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true) - ] + func fetchSources(account: Account? = nil) { + context.perform { [weak self] in + guard let self else { return } + let request: NSFetchRequest = InvestmentSource.fetchRequest() + if let account = account { + request.predicate = NSPredicate(format: "account == %@", account) + } + request.sortDescriptors = [ + NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true) + ] - do { - sources = try context.fetch(request) - } catch { - print("Failed to fetch sources: \(error)") - sources = [] + do { + self.sources = try self.context.fetch(request) + } catch { + print("Failed to fetch sources: \(error)") + self.sources = [] + } } } @@ -63,8 +70,9 @@ class InvestmentSourceRepository: ObservableObject { sources.filter { $0.isActive } } - func fetchSourcesNeedingUpdate() -> [InvestmentSource] { - sources.filter { $0.needsUpdate } + func fetchSourcesNeedingUpdate(for account: Account? = nil) -> [InvestmentSource] { + let filtered = account == nil ? sources : sources.filter { $0.account?.id == account?.id } + return filtered.filter { $0.needsUpdate } } // MARK: - Create @@ -74,13 +82,15 @@ class InvestmentSourceRepository: ObservableObject { name: String, category: Category, notificationFrequency: NotificationFrequency = .monthly, - customFrequencyMonths: Int = 1 + customFrequencyMonths: Int = 1, + account: Account? = nil ) -> InvestmentSource { let source = InvestmentSource(context: context) source.name = name source.category = category source.notificationFrequency = notificationFrequency.rawValue source.customFrequencyMonths = Int16(customFrequencyMonths) + source.account = account save() return source @@ -94,7 +104,8 @@ class InvestmentSourceRepository: ObservableObject { category: Category? = nil, notificationFrequency: NotificationFrequency? = nil, customFrequencyMonths: Int? = nil, - isActive: Bool? = nil + isActive: Bool? = nil, + account: Account? = nil ) { if let name = name { source.name = name @@ -111,6 +122,9 @@ class InvestmentSourceRepository: ObservableObject { if let isActive = isActive { source.isActive = isActive } + if let account = account { + source.account = account + } save() } @@ -170,8 +184,28 @@ class InvestmentSourceRepository: ObservableObject { do { try context.save() fetchSources() + CoreDataStack.shared.refreshWidgetData() } catch { print("Failed to save context: \(error)") } } + + private func isRelevantChange(_ notification: Notification) -> Bool { + guard let info = notification.userInfo else { return false } + let keys: [String] = [ + NSInsertedObjectsKey, + NSUpdatedObjectsKey, + NSDeletedObjectsKey, + NSRefreshedObjectsKey + ] + + for key in keys { + if let objects = info[key] as? Set { + if objects.contains(where: { $0 is InvestmentSource }) { + return true + } + } + } + return false + } } diff --git a/InvestmentTracker/Repositories/SnapshotRepository.swift b/PortfolioJournal/Repositories/SnapshotRepository.swift similarity index 57% rename from InvestmentTracker/Repositories/SnapshotRepository.swift rename to PortfolioJournal/Repositories/SnapshotRepository.swift index 88b0a53..9b8e3a7 100644 --- a/InvestmentTracker/Repositories/SnapshotRepository.swift +++ b/PortfolioJournal/Repositories/SnapshotRepository.swift @@ -4,11 +4,27 @@ import Combine class SnapshotRepository: ObservableObject { private let context: NSManagedObjectContext + private let cache = NSCache() + @Published private(set) var cacheVersion: Int = 0 @Published private(set) var snapshots: [Snapshot] = [] + // MARK: - Performance: Shared DateFormatter + private static let monthYearFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM" + return formatter + }() + + // MARK: - Performance: Cached Calendar + private static let calendar = Calendar.current + init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) { self.context = context + // Performance: Increase cache limits for better hit rate + cache.countLimit = 12 + cache.totalCostLimit = 4_000_000 // 4 MB + setupNotificationObserver() } // MARK: - Fetch @@ -19,6 +35,7 @@ class SnapshotRepository: ObservableObject { request.sortDescriptors = [ NSSortDescriptor(keyPath: \Snapshot.date, ascending: false) ] + request.fetchBatchSize = 200 return (try? context.fetch(request)) ?? [] } @@ -35,6 +52,18 @@ class SnapshotRepository: ObservableObject { request.sortDescriptors = [ NSSortDescriptor(keyPath: \Snapshot.date, ascending: false) ] + request.fetchBatchSize = 200 + + return (try? context.fetch(request)) ?? [] + } + + func fetchSnapshots(for account: Account) -> [Snapshot] { + let request: NSFetchRequest = Snapshot.fetchRequest() + request.predicate = NSPredicate(format: "source.account == %@", account) + request.sortDescriptors = [ + NSSortDescriptor(keyPath: \Snapshot.date, ascending: false) + ] + request.fetchBatchSize = 200 return (try? context.fetch(request)) ?? [] } @@ -49,6 +78,7 @@ class SnapshotRepository: ObservableObject { request.sortDescriptors = [ NSSortDescriptor(keyPath: \Snapshot.date, ascending: true) ] + request.fetchBatchSize = 200 return (try? context.fetch(request)) ?? [] } @@ -91,7 +121,9 @@ class SnapshotRepository: ObservableObject { date: Date? = nil, value: Decimal? = nil, contribution: Decimal? = nil, - notes: String? = nil + notes: String? = nil, + clearContribution: Bool = false, + clearNotes: Bool = false ) { if let date = date { snapshot.date = date @@ -99,10 +131,14 @@ class SnapshotRepository: ObservableObject { if let value = value { snapshot.value = NSDecimalNumber(decimal: value) } - if let contribution = contribution { + if clearContribution { + snapshot.contribution = nil + } else if let contribution = contribution { snapshot.contribution = NSDecimalNumber(decimal: contribution) } - if let notes = notes { + if clearNotes { + snapshot.notes = nil + } else if let notes = notes { snapshot.notes = notes } @@ -138,7 +174,8 @@ class SnapshotRepository: ObservableObject { var snapshots = fetchSnapshots(for: source) if let months = months { - let cutoffDate = Calendar.current.date( + // Performance: Use cached calendar + let cutoffDate = Self.calendar.date( byAdding: .month, value: -months, to: Date() @@ -150,16 +187,59 @@ class SnapshotRepository: ObservableObject { return snapshots } + func fetchSnapshots( + for sourceIds: [UUID], + months: Int? = nil + ) -> [Snapshot] { + guard !sourceIds.isEmpty else { return [] } + + // Performance: Use cached calendar + let cutoffDate: Date? = { + guard let months = months else { return nil } + return Self.calendar.date(byAdding: .month, value: -months, to: Date()) + }() + + let key = cacheKey( + sourceIds: sourceIds, + months: months + ) + + if let cached = cache.object(forKey: key) as? [Snapshot] { + return cached + } + + let request: NSFetchRequest = Snapshot.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 = [] + processedMonths.reserveCapacity(60) - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM" + // Performance: Use shared formatter + let formatter = Self.monthYearFormatter for snapshot in snapshots { let monthKey = formatter.string(from: snapshot.date) @@ -175,11 +255,15 @@ class SnapshotRepository: ObservableObject { func getPortfolioHistory() -> [(date: Date, totalValue: Decimal)] { let allSnapshots = fetchAllSnapshots() - // Group by date + // Performance: Pre-allocate dictionary var dateValues: [Date: Decimal] = [:] + dateValues.reserveCapacity(min(allSnapshots.count, 365)) + + // Performance: Use cached calendar + let calendar = Self.calendar for snapshot in allSnapshots { - let startOfDay = Calendar.current.startOfDay(for: snapshot.date) + let startOfDay = calendar.startOfDay(for: snapshot.date) dateValues[startOfDay, default: Decimal.zero] += snapshot.decimalValue } @@ -194,8 +278,39 @@ class SnapshotRepository: ObservableObject { guard context.hasChanges else { return } do { try context.save() + invalidateCache() + CoreDataStack.shared.refreshWidgetData() } catch { print("Failed to save context: \(error)") } } + + // MARK: - Cache + + private func cacheKey( + sourceIds: [UUID], + months: Int? + ) -> NSString { + let sortedIds = sourceIds.sorted().map { $0.uuidString }.joined(separator: ",") + let monthsPart = months.map(String.init) ?? "all" + return NSString(string: "\(cacheVersion)|\(monthsPart)|\(sortedIds)") + } + + private func invalidateCache() { + cache.removeAllObjects() + cacheVersion &+= 1 + } + + private func setupNotificationObserver() { + NotificationCenter.default.addObserver( + self, + selector: #selector(contextDidChange), + name: .NSManagedObjectContextObjectsDidChange, + object: context + ) + } + + @objc private func contextDidChange(_ notification: Notification) { + invalidateCache() + } } diff --git a/PortfolioJournal/Repositories/TransactionRepository.swift b/PortfolioJournal/Repositories/TransactionRepository.swift new file mode 100644 index 0000000..2ee5dff --- /dev/null +++ b/PortfolioJournal/Repositories/TransactionRepository.swift @@ -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.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)") + } + } +} diff --git a/PortfolioJournal/Resources/GoogleService-Info.plist b/PortfolioJournal/Resources/GoogleService-Info.plist new file mode 100644 index 0000000..127b24a --- /dev/null +++ b/PortfolioJournal/Resources/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyAregLaXQ-WqRQTltTRyjQx3-lfxLl4Cng + GCM_SENDER_ID + 334225114072 + PLIST_VERSION + 1 + BUNDLE_ID + com.alexandrevazquez.portfoliojournal + PROJECT_ID + portfoliojournal-ef2d7 + STORAGE_BUCKET + portfoliojournal-ef2d7.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:334225114072:ios:81bad412ffe1c6df3d28ad + + \ No newline at end of file diff --git a/PortfolioJournal/Resources/Info.plist b/PortfolioJournal/Resources/Info.plist new file mode 100644 index 0000000..74c86e0 --- /dev/null +++ b/PortfolioJournal/Resources/Info.plist @@ -0,0 +1,92 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Portfolio Journal + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleURLTypes + + + CFBundleURLName + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleURLSchemes + + portfoliojournal + + + + CFBundleVersion + 1 + GADApplicationIdentifier + ca-app-pub-1549720748100858~9632507420 + GADDelayAppMeasurementInit + + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsArbitraryLoadsForMedia + + NSAllowsArbitraryLoadsInWebContent + + + NSCalendarsUsageDescription + Used to set investment update reminders. + NSFaceIDUsageDescription + Use Face ID to unlock your portfolio data. + NSUbiquitousContainerIsDocumentScopePublic + + NSUserTrackingUsageDescription + This app uses tracking to provide personalized ads and improve your experience. Your data is not sold to third parties. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIBackgroundModes + + remote-notification + + UILaunchScreen + + UIRequiredDeviceCapabilities + + armv7 + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportsDocumentBrowser + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/PortfolioJournal/Resources/StoreKitConfiguration.storekit b/PortfolioJournal/Resources/StoreKitConfiguration.storekit new file mode 100644 index 0000000..05088f8 --- /dev/null +++ b/PortfolioJournal/Resources/StoreKitConfiguration.storekit @@ -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" : [ ] +} diff --git a/InvestmentTracker/Resources/Localizable.strings b/PortfolioJournal/Resources/en.lproj/Localizable.strings similarity index 72% rename from InvestmentTracker/Resources/Localizable.strings rename to PortfolioJournal/Resources/en.lproj/Localizable.strings index 68c776d..31a85b6 100644 --- a/InvestmentTracker/Resources/Localizable.strings +++ b/PortfolioJournal/Resources/en.lproj/Localizable.strings @@ -1,12 +1,12 @@ /* Localizable.strings - InvestmentTracker + PortfolioJournal English (Base) localization */ // MARK: - General -"app_name" = "Investment Tracker"; +"app_name" = "Portfolio Journal"; "ok" = "OK"; "cancel" = "Cancel"; "save" = "Save"; @@ -22,13 +22,13 @@ "loading" = "Loading..."; // MARK: - Tab Bar -"tab_dashboard" = "Dashboard"; +"tab_dashboard" = "Home"; "tab_sources" = "Sources"; "tab_charts" = "Charts"; "tab_settings" = "Settings"; // MARK: - Dashboard -"dashboard_title" = "Dashboard"; +"dashboard_title" = "Home"; "total_portfolio_value" = "Total Portfolio Value"; "today" = "today"; "returns" = "Returns"; @@ -186,3 +186,51 @@ "placeholder_source_name" = "e.g., Vanguard 401k"; "placeholder_value" = "0.00"; "placeholder_notes" = "Add notes..."; + +// MARK: - Monthly Check-in Moods +"mood_energized_title" = "On Fire"; +"mood_confident_title" = "Confident"; +"mood_balanced_title" = "Steady"; +"mood_cautious_title" = "Cautious"; +"mood_stressed_title" = "Stressed"; +"mood_energized_detail" = "Feeling unbeatable"; +"mood_confident_detail" = "On track and composed"; +"mood_balanced_detail" = "Calm and patient"; +"mood_cautious_detail" = "Watching the moves"; +"mood_stressed_detail" = "Need a reset"; + +// MARK: - Monthly Check-in Achievements +"achievement_streak_3_title" = "3-Month Streak"; +"achievement_streak_3_detail" = "Kept your check-ins on time for three months straight."; +"achievement_streak_6_title" = "Half-Year Hot Streak"; +"achievement_streak_6_detail" = "Six consecutive on-time check-ins."; +"achievement_streak_12_title" = "Year of Momentum"; +"achievement_streak_12_detail" = "A full year without missing the deadline."; +"achievement_perfect_on_time_title" = "Never Late"; +"achievement_perfect_on_time_detail" = "Every check-in landed before the deadline."; +"achievement_clutch_finish_title" = "Clutch Finish"; +"achievement_clutch_finish_detail" = "Submitted with hours to spare and still on time."; +"achievement_early_bird_title" = "Early Bird"; +"achievement_early_bird_detail" = "On average you finish with plenty of time left."; +"achievements_title" = "Achievements"; +"achievements_view_all" = "View all achievements"; +"achievements_nav_title" = "Achievements"; +"achievements_progress_title" = "Progress"; +"achievements_unlocked_title" = "Unlocked"; +"achievements_unlocked_empty" = "Complete check-ins to unlock achievements."; +"achievements_locked_title" = "Locked"; +"achievements_locked_empty" = "All achievements unlocked. Nice work!"; + +// MARK: - Accessibility +"rating_accessibility" = "Rating %d of 5"; +"achievements_unlocked_count" = "%d of %d unlocked"; +"last_check_in" = "Last check-in: %@"; +"next_check_in" = "Next check-in: %@"; +"on_time_rate" = "%@ on-time"; +"on_time_count" = "%d/%d on time"; +"tightest_finish" = "Tightest finish: %@ before deadline."; +"date_today" = "Today"; +"date_yesterday" = "Yesterday"; +"date_never" = "Never"; +"calendar_event_title" = "%@: Monthly Check-in"; +"calendar_event_notes" = "Open %@ and complete your monthly check-in."; diff --git a/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings b/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings new file mode 100644 index 0000000..5209cc2 --- /dev/null +++ b/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings @@ -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"; diff --git a/PortfolioJournal/Services/AccountStore.swift b/PortfolioJournal/Services/AccountStore.swift new file mode 100644 index 0000000..5f42994 --- /dev/null +++ b/PortfolioJournal/Services/AccountStore.swift @@ -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() + + 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 + } +} diff --git a/InvestmentTracker/Services/AdMobService.swift b/PortfolioJournal/Services/AdMobService.swift similarity index 83% rename from InvestmentTracker/Services/AdMobService.swift rename to PortfolioJournal/Services/AdMobService.swift index f46d992..7931c93 100644 --- a/InvestmentTracker/Services/AdMobService.swift +++ b/PortfolioJournal/Services/AdMobService.swift @@ -1,4 +1,6 @@ import Foundation +import SwiftUI +import Combine import GoogleMobileAds import AppTrackingTransparency import AdSupport @@ -36,7 +38,6 @@ class AdMobService: ObservableObject { } func requestConsent() async { - // Request App Tracking Transparency authorization if #available(iOS 14.5, *) { let status = await ATTrackingManager.requestTrackingAuthorization() @@ -109,48 +110,46 @@ class AdMobService: ObservableObject { // MARK: - Banner Ad Coordinator -class BannerAdCoordinator: NSObject, GADBannerViewDelegate { +class BannerAdCoordinator: NSObject, BannerViewDelegate { weak var adMobService: AdMobService? - func bannerViewDidReceiveAd(_ bannerView: GADBannerView) { + func bannerViewDidReceiveAd(_ bannerView: BannerView) { print("Banner ad received") adMobService?.logAdImpression() } - func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) { + func bannerView(_ bannerView: BannerView, didFailToReceiveAdWithError error: Error) { print("Banner ad failed to load: \(error.localizedDescription)") } - func bannerViewDidRecordImpression(_ bannerView: GADBannerView) { + func bannerViewDidRecordImpression(_ bannerView: BannerView) { // Impression recorded } - func bannerViewDidRecordClick(_ bannerView: GADBannerView) { + func bannerViewDidRecordClick(_ bannerView: BannerView) { adMobService?.logAdClick() } - func bannerViewWillPresentScreen(_ bannerView: GADBannerView) { + func bannerViewWillPresentScreen(_ bannerView: BannerView) { // Ad will present full screen } - func bannerViewWillDismissScreen(_ bannerView: GADBannerView) { + func bannerViewWillDismissScreen(_ bannerView: BannerView) { // Ad will dismiss } - func bannerViewDidDismissScreen(_ bannerView: GADBannerView) { + func bannerViewDidDismissScreen(_ bannerView: BannerView) { // Ad dismissed } } // MARK: - UIKit Banner View Wrapper -import SwiftUI - struct BannerAdView: UIViewRepresentable { @EnvironmentObject var adMobService: AdMobService - func makeUIView(context: Context) -> GADBannerView { - let bannerView = GADBannerView(adSize: GADAdSizeBanner) + func makeUIView(context: Context) -> BannerView { + let bannerView = BannerView(adSize: AdSizeBanner) bannerView.adUnitID = AdMobService.bannerAdUnitID // Get root view controller @@ -160,12 +159,12 @@ struct BannerAdView: UIViewRepresentable { } bannerView.delegate = context.coordinator - bannerView.load(GADRequest()) + bannerView.load(Request()) return bannerView } - func updateUIView(_ uiView: GADBannerView, context: Context) { + func updateUIView(_ uiView: BannerView, context: Context) { // Banner updates automatically } diff --git a/InvestmentTracker/Services/CalculationService.swift b/PortfolioJournal/Services/CalculationService.swift similarity index 53% rename from InvestmentTracker/Services/CalculationService.swift rename to PortfolioJournal/Services/CalculationService.swift index eaf6c16..02de4e5 100644 --- a/InvestmentTracker/Services/CalculationService.swift +++ b/PortfolioJournal/Services/CalculationService.swift @@ -3,8 +3,25 @@ import Foundation class CalculationService { static let shared = CalculationService() + // MARK: - Performance: Caching + private var cachedMonthlyReturns: [ObjectIdentifier: [InvestmentMetrics.MonthlyReturn]] = [:] + private var cacheVersion: Int = 0 + private init() {} + /// Call this when underlying data changes to invalidate caches + func invalidateCache() { + cachedMonthlyReturns.removeAll() + cacheVersion += 1 + } + + // MARK: - Shared DateFormatter (avoid repeated allocations) + private static let monthYearFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM" + return formatter + }() + // MARK: - Portfolio Summary func calculatePortfolioSummary( @@ -135,6 +152,51 @@ class CalculationService { ) } + // MARK: - Monthly Summary + + func calculateMonthlySummary( + sources: [InvestmentSource], + snapshots: [Snapshot], + range: DateRange = .thisMonth + ) -> MonthlySummary { + let startDate = range.start + let endDate = range.end + + var startingValue: Decimal = 0 + var endingValue: Decimal = 0 + var contributions: Decimal = 0 + + let snapshotsBySource = Dictionary(grouping: snapshots) { $0.source?.id } + + for source in sources { + let sourceSnapshots = snapshotsBySource[source.id] ?? [] + let sorted = sourceSnapshots.sorted { $0.date < $1.date } + + let startSnapshot = sorted.last { $0.date <= startDate } + let endSnapshot = sorted.last { $0.date <= endDate } + + startingValue += startSnapshot?.decimalValue ?? 0 + endingValue += endSnapshot?.decimalValue ?? 0 + + let rangeContributions = sorted + .filter { $0.date >= startDate && $0.date <= endDate } + .reduce(Decimal.zero) { $0 + $1.decimalContribution } + contributions += rangeContributions + } + + let netPerformance = endingValue - startingValue - contributions + + return MonthlySummary( + periodLabel: "This Month", + startDate: startDate, + endDate: endDate, + startingValue: startingValue, + endingValue: endingValue, + contributions: contributions, + netPerformance: netPerformance + ) + } + // MARK: - CAGR (Compound Annual Growth Rate) func calculateCAGR( @@ -254,13 +316,12 @@ class CalculationService { func calculateMonthlyReturns(from snapshots: [Snapshot]) -> [InvestmentMetrics.MonthlyReturn] { guard snapshots.count >= 2 else { return [] } - var monthlyReturns: [InvestmentMetrics.MonthlyReturn] = [] - let calendar = Calendar.current + // Performance: Use shared formatter instead of creating new one each call + let formatter = Self.monthYearFormatter - // Group snapshots by month + // Group snapshots by month - pre-allocate capacity var monthlySnapshots: [String: [Snapshot]] = [:] - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM" + monthlySnapshots.reserveCapacity(min(snapshots.count, 60)) // Reasonable max months for snapshot in snapshots { let key = formatter.string(from: snapshot.date) @@ -269,6 +330,11 @@ class CalculationService { // Sort months let sortedMonths = monthlySnapshots.keys.sorted() + guard sortedMonths.count >= 2 else { return [] } + + // Pre-allocate result array + var monthlyReturns: [InvestmentMetrics.MonthlyReturn] = [] + monthlyReturns.reserveCapacity(sortedMonths.count - 1) for i in 1.. [CategoryMetrics] { categories.map { category in - let allSnapshots = category.sourcesArray.flatMap { $0.snapshotsArray } - let metrics = calculateMetrics(for: allSnapshots) + let categorySources = sources.filter { $0.category?.id == category.id } + let allSnapshots = categorySources.flatMap { $0.snapshotsArray } + let metrics = calculateCategoryMetrics(from: allSnapshots) + let categoryValue = categorySources.reduce(Decimal.zero) { $0 + $1.latestValue } let percentage = totalPortfolioValue > 0 - ? NSDecimalNumber(decimal: category.totalValue / totalPortfolioValue).doubleValue * 100 + ? NSDecimalNumber(decimal: categoryValue / totalPortfolioValue).doubleValue * 100 : 0 return CategoryMetrics( @@ -316,12 +385,196 @@ class CalculationService { categoryName: category.name, colorHex: category.colorHex, icon: category.icon, - totalValue: category.totalValue, + totalValue: categoryValue, percentageOfPortfolio: percentage, metrics: metrics ) } } + + private struct SeriesPoint { + let date: Date + let value: Decimal + let contribution: Decimal + } + + private func calculateCategoryMetrics(from snapshots: [Snapshot]) -> InvestmentMetrics { + let series = buildCategorySeries(from: snapshots) + guard !series.isEmpty else { return .empty } + + let sortedSeries = series.sorted { $0.date < $1.date } + let values = sortedSeries.map { $0.value } + + guard let firstValue = values.first, + let lastValue = values.last, + firstValue != 0 else { + return .empty + } + + let totalValue = lastValue + let totalContributions = sortedSeries.reduce(Decimal.zero) { $0 + $1.contribution } + let absoluteReturn = lastValue - firstValue + let percentageReturn = (absoluteReturn / firstValue) * 100 + + let monthlyReturns = calculateMonthlyReturns(from: sortedSeries) + + let cagr = calculateCAGR( + startValue: firstValue, + endValue: lastValue, + startDate: sortedSeries.first?.date ?? Date(), + endDate: sortedSeries.last?.date ?? Date() + ) + + let twr = calculateTWR(series: sortedSeries) + let volatility = calculateVolatility(monthlyReturns: monthlyReturns) + let maxDrawdown = calculateMaxDrawdown(values: values) + let sharpeRatio = calculateSharpeRatio( + averageReturn: monthlyReturns.map { $0.returnPercentage }.average(), + volatility: volatility + ) + + let winRate = calculateWinRate(monthlyReturns: monthlyReturns) + let averageMonthlyReturn = monthlyReturns.map { $0.returnPercentage }.average() + + return InvestmentMetrics( + totalValue: totalValue, + totalContributions: totalContributions, + absoluteReturn: absoluteReturn, + percentageReturn: percentageReturn, + cagr: cagr, + twr: twr, + volatility: volatility, + maxDrawdown: maxDrawdown, + sharpeRatio: sharpeRatio, + bestMonth: monthlyReturns.max(by: { $0.returnPercentage < $1.returnPercentage }), + worstMonth: monthlyReturns.min(by: { $0.returnPercentage < $1.returnPercentage }), + winRate: winRate, + averageMonthlyReturn: averageMonthlyReturn, + startDate: sortedSeries.first?.date, + endDate: sortedSeries.last?.date, + totalMonths: monthlyReturns.count + ) + } + + private func buildCategorySeries(from snapshots: [Snapshot]) -> [SeriesPoint] { + let sortedSnapshots = snapshots.sorted { $0.date < $1.date } + let uniqueDates = Array(Set(sortedSnapshots.map { Calendar.current.startOfDay(for: $0.date) })) + .sorted() + guard !uniqueDates.isEmpty else { return [] } + + var snapshotsBySource: [UUID: [(date: Date, value: Decimal)]] = [:] + var contributionsByDate: [Date: Decimal] = [:] + + for snapshot in sortedSnapshots { + guard let sourceId = snapshot.source?.id else { continue } + snapshotsBySource[sourceId, default: []].append( + (date: snapshot.date, value: snapshot.decimalValue) + ) + let day = Calendar.current.startOfDay(for: snapshot.date) + contributionsByDate[day, default: 0] += snapshot.decimalContribution + } + + var indices: [UUID: Int] = [:] + var series: [SeriesPoint] = [] + + for (index, date) in uniqueDates.enumerated() { + let nextDate = index + 1 < uniqueDates.count + ? uniqueDates[index + 1] + : Date.distantFuture + var total: Decimal = 0 + + for (sourceId, sourceSnapshots) in snapshotsBySource { + var currentIndex = indices[sourceId] ?? 0 + var latest: (date: Date, value: Decimal)? + + while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextDate { + latest = sourceSnapshots[currentIndex] + currentIndex += 1 + } + + indices[sourceId] = currentIndex + + if let latest { + total += latest.value + } + } + + series.append( + SeriesPoint( + date: date, + value: total, + contribution: contributionsByDate[date] ?? 0 + ) + ) + } + + return series + } + + private func calculateMonthlyReturns(from series: [SeriesPoint]) -> [InvestmentMetrics.MonthlyReturn] { + guard series.count >= 2 else { return [] } + + // Performance: Use shared formatter + let formatter = Self.monthYearFormatter + + var monthlySeries: [String: [SeriesPoint]] = [:] + monthlySeries.reserveCapacity(min(series.count, 60)) + + for point in series { + let key = formatter.string(from: point.date) + monthlySeries[key, default: []].append(point) + } + + let sortedMonths = monthlySeries.keys.sorted() + guard sortedMonths.count >= 2 else { return [] } + + var monthlyReturns: [InvestmentMetrics.MonthlyReturn] = [] + monthlyReturns.reserveCapacity(sortedMonths.count - 1) + + for i in 1.. 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.. 0 else { continue } + + let periodReturn = (currentValue - contribution) / previousValue + twr *= NSDecimalNumber(decimal: periodReturn).doubleValue + } + + return (twr - 1) * 100 + } } // MARK: - Array Extension diff --git a/InvestmentTracker/Services/ExportService.swift b/PortfolioJournal/Services/ExportService.swift similarity index 61% rename from InvestmentTracker/Services/ExportService.swift rename to PortfolioJournal/Services/ExportService.swift index 5ec391a..b6e0595 100644 --- a/InvestmentTracker/Services/ExportService.swift +++ b/PortfolioJournal/Services/ExportService.swift @@ -35,9 +35,11 @@ class ExportService { sources: [InvestmentSource], categories: [Category] ) -> String { - var csv = "Category,Source,Date,Value (EUR),Contribution (EUR),Notes\n" + let currencyCode = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency + var csv = "Account,Category,Source,Date,Value (\(currencyCode)),Contribution (\(currencyCode)),Notes\n" for source in sources.sorted(by: { $0.name < $1.name }) { + let accountName = source.account?.name ?? "Default" let categoryName = source.category?.name ?? "Uncategorized" for snapshot in source.snapshotsArray { @@ -48,7 +50,7 @@ class ExportService { : "" let notes = escapeCSV(snapshot.notes ?? "") - csv += "\(escapeCSV(categoryName)),\(escapeCSV(source.name)),\(date),\(value),\(contribution),\(notes)\n" + csv += "\(escapeCSV(accountName)),\(escapeCSV(categoryName)),\(escapeCSV(source.name)),\(date),\(value),\(contribution),\(notes)\n" } } @@ -61,59 +63,76 @@ class ExportService { ) -> String { var exportData: [String: Any] = [:] exportData["exportDate"] = ISO8601DateFormatter().string(from: Date()) - exportData["currency"] = "EUR" + exportData["version"] = 2 + exportData["currency"] = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency - // Export categories - var categoriesArray: [[String: Any]] = [] - for category in categories { - var categoryDict: [String: Any] = [ - "id": category.id.uuidString, - "name": category.name, - "color": category.colorHex, - "icon": category.icon + let accounts = Dictionary(grouping: sources) { $0.account?.id.uuidString ?? "default" } + var accountsArray: [[String: Any]] = [] + + for (_, accountSources) in accounts { + let account = accountSources.first?.account + var accountDict: [String: Any] = [ + "name": account?.name ?? "Default", + "currency": account?.currency ?? exportData["currency"] as? String ?? "EUR", + "inputMode": account?.inputMode ?? InputMode.simple.rawValue, + "notificationFrequency": account?.notificationFrequency ?? NotificationFrequency.monthly.rawValue, + "customFrequencyMonths": account?.customFrequencyMonths ?? 1 ] - // Export sources in this category - var sourcesArray: [[String: Any]] = [] - for source in category.sourcesArray { - var sourceDict: [String: Any] = [ - "id": source.id.uuidString, - "name": source.name, - "isActive": source.isActive, - "notificationFrequency": source.notificationFrequency + // Export categories for this account + let categoriesById = Dictionary(uniqueKeysWithValues: categories.map { ($0.id, $0) }) + let sourcesByCategory = Dictionary(grouping: accountSources) { $0.category?.id ?? UUID() } + var categoriesArray: [[String: Any]] = [] + + for (categoryId, categorySources) in sourcesByCategory { + let category = categoriesById[categoryId] + var categoryDict: [String: Any] = [ + "name": category?.name ?? "Uncategorized", + "color": category?.colorHex ?? "#3B82F6", + "icon": category?.icon ?? "chart.pie.fill" ] - // Export snapshots - var snapshotsArray: [[String: Any]] = [] - for snapshot in source.snapshotsArray { - var snapshotDict: [String: Any] = [ - "id": snapshot.id.uuidString, - "date": ISO8601DateFormatter().string(from: snapshot.date), - "value": NSDecimalNumber(decimal: snapshot.decimalValue).doubleValue + var sourcesArray: [[String: Any]] = [] + for source in categorySources { + var sourceDict: [String: Any] = [ + "name": source.name, + "isActive": source.isActive, + "notificationFrequency": source.notificationFrequency ] - if snapshot.contribution != nil { - snapshotDict["contribution"] = NSDecimalNumber( - decimal: snapshot.decimalContribution - ).doubleValue + 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) } - if let notes = snapshot.notes, !notes.isEmpty { - snapshotDict["notes"] = notes - } - - snapshotsArray.append(snapshotDict) + sourceDict["snapshots"] = snapshotsArray + sourcesArray.append(sourceDict) } - sourceDict["snapshots"] = snapshotsArray - sourcesArray.append(sourceDict) + categoryDict["sources"] = sourcesArray + categoriesArray.append(categoryDict) } - categoryDict["sources"] = sourcesArray - categoriesArray.append(categoryDict) + accountDict["categories"] = categoriesArray + accountsArray.append(accountDict) } - exportData["categories"] = categoriesArray + exportData["accounts"] = accountsArray // Add summary let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue } diff --git a/InvestmentTracker/Services/FirebaseService.swift b/PortfolioJournal/Services/FirebaseService.swift similarity index 82% rename from InvestmentTracker/Services/FirebaseService.swift rename to PortfolioJournal/Services/FirebaseService.swift index c67a45c..7b26da1 100644 --- a/InvestmentTracker/Services/FirebaseService.swift +++ b/PortfolioJournal/Services/FirebaseService.swift @@ -1,14 +1,20 @@ import Foundation import FirebaseAnalytics +import FirebaseCore class FirebaseService { static let shared = FirebaseService() + private var isConfigured: Bool { + FirebaseApp.app() != nil + } + private init() {} // MARK: - User Properties func setUserTier(_ tier: UserTier) { + guard isConfigured else { return } Analytics.setUserProperty(tier.rawValue, forName: "user_tier") } @@ -20,6 +26,7 @@ class FirebaseService { // MARK: - Screen Tracking func logScreenView(screenName: String, screenClass: String? = nil) { + guard isConfigured else { return } Analytics.logEvent(AnalyticsEventScreenView, parameters: [ AnalyticsParameterScreenName: screenName, AnalyticsParameterScreenClass: screenClass ?? screenName @@ -29,6 +36,7 @@ class FirebaseService { // MARK: - Investment Events func logSourceAdded(categoryName: String, sourceCount: Int) { + guard isConfigured else { return } Analytics.logEvent("source_added", parameters: [ "category_name": categoryName, "total_sources": sourceCount @@ -36,12 +44,14 @@ class FirebaseService { } func logSourceDeleted(categoryName: String) { + guard isConfigured else { return } Analytics.logEvent("source_deleted", parameters: [ "category_name": categoryName ]) } func logSnapshotAdded(sourceName: String, value: Decimal) { + guard isConfigured else { return } Analytics.logEvent("snapshot_added", parameters: [ "source_name": sourceName, "value": NSDecimalNumber(decimal: value).doubleValue @@ -49,6 +59,7 @@ class FirebaseService { } func logCategoryCreated(name: String) { + guard isConfigured else { return } Analytics.logEvent("category_created", parameters: [ "category_name": name ]) @@ -57,18 +68,21 @@ class FirebaseService { // MARK: - Purchase Events func logPaywallShown(trigger: String) { + guard isConfigured else { return } Analytics.logEvent("paywall_shown", parameters: [ "trigger": trigger ]) } func logPurchaseAttempt(productId: String) { + guard isConfigured else { return } Analytics.logEvent("purchase_attempt", parameters: [ "product_id": productId ]) } func logPurchaseSuccess(productId: String, price: Decimal, isFamilyShared: Bool) { + guard isConfigured else { return } Analytics.logEvent(AnalyticsEventPurchase, parameters: [ AnalyticsParameterItemID: productId, AnalyticsParameterPrice: NSDecimalNumber(decimal: price).doubleValue, @@ -78,6 +92,7 @@ class FirebaseService { } func logPurchaseFailure(productId: String, error: String) { + guard isConfigured else { return } Analytics.logEvent("purchase_failed", parameters: [ "product_id": productId, "error": error @@ -85,6 +100,7 @@ class FirebaseService { } func logRestorePurchases(success: Bool) { + guard isConfigured else { return } Analytics.logEvent("restore_purchases", parameters: [ "success": success ]) @@ -93,6 +109,7 @@ class FirebaseService { // MARK: - Feature Usage Events func logChartViewed(chartType: String, isPremium: Bool) { + guard isConfigured else { return } Analytics.logEvent("chart_viewed", parameters: [ "chart_type": chartType, "is_premium_chart": isPremium @@ -100,12 +117,14 @@ class FirebaseService { } func logPredictionViewed(algorithm: String) { + guard isConfigured else { return } Analytics.logEvent("prediction_viewed", parameters: [ "algorithm": algorithm ]) } func logExportAttempt(format: String, success: Bool) { + guard isConfigured else { return } Analytics.logEvent("export_attempt", parameters: [ "format": format, "success": success @@ -113,6 +132,7 @@ class FirebaseService { } func logNotificationScheduled(frequency: String) { + guard isConfigured else { return } Analytics.logEvent("notification_scheduled", parameters: [ "frequency": frequency ]) @@ -121,12 +141,14 @@ class FirebaseService { // MARK: - Ad Events func logAdImpression(adType: String) { + guard isConfigured else { return } Analytics.logEvent("ad_impression", parameters: [ "ad_type": adType ]) } func logAdClick(adType: String) { + guard isConfigured else { return } Analytics.logEvent("ad_click", parameters: [ "ad_type": adType ]) @@ -135,18 +157,21 @@ class FirebaseService { // MARK: - Engagement Events func logOnboardingCompleted(stepCount: Int) { + guard isConfigured else { return } Analytics.logEvent("onboarding_completed", parameters: [ "steps_completed": stepCount ]) } func logWidgetUsed(widgetType: String) { + guard isConfigured else { return } Analytics.logEvent("widget_used", parameters: [ "widget_type": widgetType ]) } func logAppOpened(fromWidget: Bool) { + guard isConfigured else { return } Analytics.logEvent("app_opened", parameters: [ "from_widget": fromWidget ]) @@ -155,6 +180,7 @@ class FirebaseService { // MARK: - Portfolio Events func logPortfolioMilestone(totalValue: Decimal, milestone: String) { + guard isConfigured else { return } Analytics.logEvent("portfolio_milestone", parameters: [ "total_value": NSDecimalNumber(decimal: totalValue).doubleValue, "milestone": milestone @@ -164,6 +190,7 @@ class FirebaseService { // MARK: - Error Events func logError(type: String, message: String, context: String? = nil) { + guard isConfigured else { return } Analytics.logEvent("app_error", parameters: [ "error_type": type, "error_message": message, @@ -171,3 +198,4 @@ class FirebaseService { ]) } } + diff --git a/PortfolioJournal/Services/GoalShareService.swift b/PortfolioJournal/Services/GoalShareService.swift new file mode 100644 index 0000000..712d035 --- /dev/null +++ b/PortfolioJournal/Services/GoalShareService.swift @@ -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) + } + } +} diff --git a/InvestmentTracker/Services/IAPService.swift b/PortfolioJournal/Services/IAPService.swift similarity index 84% rename from InvestmentTracker/Services/IAPService.swift rename to PortfolioJournal/Services/IAPService.swift index 594bb52..bfa2652 100644 --- a/InvestmentTracker/Services/IAPService.swift +++ b/PortfolioJournal/Services/IAPService.swift @@ -10,16 +10,20 @@ class IAPService: ObservableObject { @Published private(set) var products: [Product] = [] @Published private(set) var purchaseState: PurchaseState = .idle @Published private(set) var isFamilyShared = false +#if DEBUG + @Published var debugOverrideEnabled = false +#endif // MARK: - Constants - static let premiumProductID = "com.investmenttracker.premium" + static let premiumProductID = "com.portfoliojournal.premium" static let premiumPrice = "€4.69" // MARK: - Private Properties private var updateListenerTask: Task? private var cancellables = Set() + private let sharedDefaults = UserDefaults(suiteName: AppConstants.appGroupIdentifier) // MARK: - Purchase State @@ -34,6 +38,9 @@ class IAPService: ObservableObject { // MARK: - Initialization init() { +#if DEBUG + debugOverrideEnabled = UserDefaults.standard.bool(forKey: "debugPremiumOverride") +#endif updateListenerTask = listenForTransactions() Task { @@ -132,7 +139,15 @@ class IAPService: ObservableObject { var isEntitled = false var familyShared = false - for await result in Transaction.currentEntitlements { +#if DEBUG + if debugOverrideEnabled { + isPremium = true + isFamilyShared = false + return + } +#endif + + for await result in StoreKit.Transaction.currentEntitlements { if case .verified(let transaction) = result { if transaction.productID == Self.premiumProductID { isEntitled = true @@ -144,6 +159,7 @@ class IAPService: ObservableObject { isPremium = isEntitled isFamilyShared = familyShared + sharedDefaults?.set(isEntitled, forKey: "premiumUnlocked") // Update Core Data let context = CoreDataStack.shared.viewContext @@ -156,11 +172,25 @@ class IAPService: ObservableObject { ) } +#if DEBUG + func setDebugPremiumOverride(_ enabled: Bool) { + debugOverrideEnabled = enabled + UserDefaults.standard.set(enabled, forKey: "debugPremiumOverride") + if enabled { + isPremium = true + isFamilyShared = false + sharedDefaults?.set(true, forKey: "premiumUnlocked") + } else { + Task { await updatePremiumStatus() } + } + } +#endif + // MARK: - Transaction Listener private func listenForTransactions() -> Task { return Task.detached { [weak self] in - for await result in Transaction.updates { + for await result in StoreKit.Transaction.updates { if case .verified(let transaction) = result { await transaction.finish() await self?.updatePremiumStatus() @@ -214,6 +244,7 @@ enum IAPError: LocalizedError { extension IAPService { static let premiumFeatures: [(icon: String, title: String, description: String)] = [ + ("person.2", "Multiple Accounts", "Separate portfolios for business or family"), ("infinity", "Unlimited Sources", "Track as many investments as you want"), ("clock.arrow.circlepath", "Full History", "Access your complete investment history"), ("chart.bar.xaxis", "Advanced Charts", "5 types of detailed analytics charts"), diff --git a/PortfolioJournal/Services/ImportService.swift b/PortfolioJournal/Services/ImportService.swift new file mode 100644 index 0000000..28b458c --- /dev/null +++ b/PortfolioJournal/Services/ImportService.swift @@ -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] + } +} diff --git a/InvestmentTracker/Services/NotificationService.swift b/PortfolioJournal/Services/NotificationService.swift similarity index 96% rename from InvestmentTracker/Services/NotificationService.swift rename to PortfolioJournal/Services/NotificationService.swift index 255acfc..585d738 100644 --- a/InvestmentTracker/Services/NotificationService.swift +++ b/PortfolioJournal/Services/NotificationService.swift @@ -1,4 +1,5 @@ import Foundation +import Combine import UserNotifications import UIKit @@ -120,16 +121,12 @@ class NotificationService: ObservableObject { let needsUpdate = repository.fetchSourcesNeedingUpdate() pendingCount = needsUpdate.count - DispatchQueue.main.async { - UIApplication.shared.applicationIconBadgeNumber = self.pendingCount - } + center.setBadgeCount(pendingCount) { _ in } } func clearBadge() { pendingCount = 0 - DispatchQueue.main.async { - UIApplication.shared.applicationIconBadgeNumber = 0 - } + center.setBadgeCount(0) { _ in } } // MARK: - Pending Notifications diff --git a/InvestmentTracker/Services/PredictionEngine.swift b/PortfolioJournal/Services/PredictionEngine.swift similarity index 78% rename from InvestmentTracker/Services/PredictionEngine.swift rename to PortfolioJournal/Services/PredictionEngine.swift index 4f42992..a64740d 100644 --- a/InvestmentTracker/Services/PredictionEngine.swift +++ b/PortfolioJournal/Services/PredictionEngine.swift @@ -5,6 +5,9 @@ class PredictionEngine { private let context = CoreDataStack.shared.viewContext + // MARK: - Performance: Cached Calendar reference + private static let calendar = Calendar.current + private init() {} // MARK: - Main Prediction Interface @@ -46,6 +49,9 @@ class PredictionEngine { case .movingAverage: predictions = predictMovingAverage(snapshots: sortedSnapshots, monthsAhead: monthsAhead) accuracy = calculateMAAccuracy(snapshots: sortedSnapshots) + case .holtTrend: + predictions = predictHoltTrend(snapshots: sortedSnapshots, monthsAhead: monthsAhead) + accuracy = calculateHoltAccuracy(snapshots: sortedSnapshots) } return PredictionResult( @@ -60,12 +66,12 @@ class PredictionEngine { private func selectBestAlgorithm(volatility: Double) -> PredictionAlgorithm { switch volatility { - case 0..<10: - return .linear // Low volatility - linear works well - case 10..<25: - return .exponentialSmoothing // Medium volatility - weight recent data + case 0..<8: + return .holtTrend + case 8..<20: + return .exponentialSmoothing default: - return .movingAverage // High volatility - smooth out fluctuations + return .movingAverage } } @@ -88,7 +94,7 @@ class PredictionEngine { let lastDate = snapshots.last!.date for month in 1...monthsAhead { - guard let futureDate = Calendar.current.date( + guard let futureDate = Self.calendar.date( byAdding: .month, value: month, to: lastDate @@ -219,7 +225,7 @@ class PredictionEngine { let lastDate = snapshots.last!.date for month in 1...monthsAhead { - guard let futureDate = Calendar.current.date( + guard let futureDate = Self.calendar.date( byAdding: .month, value: month, to: lastDate @@ -297,7 +303,7 @@ class PredictionEngine { let lastDate = snapshots.last!.date for month in 1...monthsAhead { - guard let futureDate = Calendar.current.date( + guard let futureDate = Self.calendar.date( byAdding: .month, value: month, to: lastDate @@ -347,6 +353,90 @@ class PredictionEngine { return max(0, min(1.0, 1 - (ssRes / ssTot))) } + // MARK: - Holt Trend (Double Exponential Smoothing) + + func predictHoltTrend( + snapshots: [Snapshot], + monthsAhead: Int = 12, + alpha: Double = 0.4, + beta: Double = 0.3 + ) -> [Prediction] { + guard snapshots.count >= 3 else { return [] } + + let values = snapshots.map { $0.decimalValue.doubleValue } + var level = values[0] + var trend = values[1] - values[0] + + var fitted: [Double] = [] + for value in values { + let lastLevel = level + level = alpha * value + (1 - alpha) * (level + trend) + trend = beta * (level - lastLevel) + (1 - beta) * trend + fitted.append(level + trend) + } + + let residuals = zip(values, fitted).map { $0 - $1 } + let stdDev = calculateStdDev(values: residuals) + + var predictions: [Prediction] = [] + let lastDate = snapshots.last!.date + + for month in 1...monthsAhead { + guard let futureDate = Self.calendar.date( + byAdding: .month, + value: month, + to: lastDate + ) else { continue } + + let predictedValue = max(0, level + Double(month) * trend) + let intervalWidth = stdDev * 1.96 * (1.0 + Double(month) * 0.04) + + predictions.append(Prediction( + date: futureDate, + predictedValue: Decimal(predictedValue), + algorithm: .holtTrend, + confidenceInterval: Prediction.ConfidenceInterval( + lower: Decimal(max(0, predictedValue - intervalWidth)), + upper: Decimal(predictedValue + intervalWidth) + ) + )) + } + + return predictions + } + + private func calculateHoltAccuracy(snapshots: [Snapshot]) -> Double { + guard snapshots.count >= 5 else { return 0.5 } + + let values = snapshots.map { $0.decimalValue.doubleValue } + let splitIndex = Int(Double(values.count) * 0.8) + guard splitIndex >= 2 else { return 0.5 } + + var level = values[0] + var trend = values[1] - values[0] + + for value in values.prefix(splitIndex) { + let lastLevel = level + level = 0.4 * value + 0.6 * (level + trend) + trend = 0.3 * (level - lastLevel) + 0.7 * trend + } + + let validationValues = Array(values.suffix(from: splitIndex)) + let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count) + + var ssRes: Double = 0 + var ssTot: Double = 0 + + for (i, actual) in validationValues.enumerated() { + let predicted = level + Double(i + 1) * trend + ssRes += pow(actual - predicted, 2) + ssTot += pow(actual - meanValidation, 2) + } + + guard ssTot != 0 else { return 0.5 } + return max(0, min(1.0, 1 - (ssRes / ssTot))) + } + // MARK: - Helpers private func calculateVolatility(snapshots: [Snapshot]) -> Double { @@ -373,11 +463,3 @@ class PredictionEngine { return sqrt(variance) } } - -// MARK: - Decimal Extension - -private extension Decimal { - var doubleValue: Double { - NSDecimalNumber(decimal: self).doubleValue - } -} diff --git a/PortfolioJournal/Services/SampleDataService.swift b/PortfolioJournal/Services/SampleDataService.swift new file mode 100644 index 0000000..984494b --- /dev/null +++ b/PortfolioJournal/Services/SampleDataService.swift @@ -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) + } + } +} diff --git a/PortfolioJournal/Services/ShareService.swift b/PortfolioJournal/Services/ShareService.swift new file mode 100644 index 0000000..6671c40 --- /dev/null +++ b/PortfolioJournal/Services/ShareService.swift @@ -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: "\\,") + } +} diff --git a/PortfolioJournal/Utilities/AllocationTargetStore.swift b/PortfolioJournal/Utilities/AllocationTargetStore.swift new file mode 100644 index 0000000..fb0443e --- /dev/null +++ b/PortfolioJournal/Utilities/AllocationTargetStore.swift @@ -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) + } + } +} diff --git a/PortfolioJournal/Utilities/AppLockService.swift b/PortfolioJournal/Utilities/AppLockService.swift new file mode 100644 index 0000000..6d08ddf --- /dev/null +++ b/PortfolioJournal/Utilities/AppLockService.swift @@ -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) + } + } + } +} diff --git a/InvestmentTracker/Utilities/Constants/AppConstants.swift b/PortfolioJournal/Utilities/Constants/AppConstants.swift similarity index 88% rename from InvestmentTracker/Utilities/Constants/AppConstants.swift rename to PortfolioJournal/Utilities/Constants/AppConstants.swift index 43d63c8..14d6c05 100644 --- a/InvestmentTracker/Utilities/Constants/AppConstants.swift +++ b/PortfolioJournal/Utilities/Constants/AppConstants.swift @@ -3,19 +3,19 @@ import Foundation enum AppConstants { // MARK: - App Info - static let appName = "Investment Tracker" + static let appName = "Portfolio Journal" static let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" static let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" // MARK: - Bundle Identifiers - static let bundleIdentifier = "com.yourteam.investmenttracker" - static let appGroupIdentifier = "group.com.yourteam.investmenttracker" - static let cloudKitContainerIdentifier = "iCloud.com.yourteam.investmenttracker" + static let bundleIdentifier = "com.alexandrevazquez.portfoliojournal" + static let appGroupIdentifier = "group.com.alexandrevazquez.portfoliojournal" + static let cloudKitContainerIdentifier = "iCloud.com.alexandrevazquez.portfoliojournal" // MARK: - StoreKit - static let premiumProductID = "com.investmenttracker.premium" + static let premiumProductID = "com.portfoliojournal.premium" static let premiumPrice = "€4.69" // MARK: - AdMob @@ -98,7 +98,7 @@ enum AppConstants { // MARK: - Deep Links enum DeepLinks { - static let scheme = "investmenttracker" + static let scheme = "portfoliojournal" static let sourceDetail = "source" static let addSnapshot = "addSnapshot" static let premium = "premium" @@ -107,9 +107,9 @@ enum AppConstants { // MARK: - URLs enum URLs { - static let privacyPolicy = "https://yourwebsite.com/privacy" - static let termsOfService = "https://yourwebsite.com/terms" - static let support = "https://yourwebsite.com/support" + static let privacyPolicy = "https://portfoliojournal.app/privacy.html" + static let termsOfService = "https://portfoliojournal.app/terms.html" + static let support = "https://portfoliojournal.app/support.html" static let appStore = "https://apps.apple.com/app/idXXXXXXXXXX" } diff --git a/PortfolioJournal/Utilities/CurrencyFormatter.swift b/PortfolioJournal/Utilities/CurrencyFormatter.swift new file mode 100644 index 0000000..e775b97 --- /dev/null +++ b/PortfolioJournal/Utilities/CurrencyFormatter.swift @@ -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 + } +} diff --git a/PortfolioJournal/Utilities/CurrencyPicker.swift b/PortfolioJournal/Utilities/CurrencyPicker.swift new file mode 100644 index 0000000..ba6642e --- /dev/null +++ b/PortfolioJournal/Utilities/CurrencyPicker.swift @@ -0,0 +1,9 @@ +import Foundation + +enum CurrencyPicker { + static let commonCodes: [String] = [ + "EUR", "USD", "GBP", "CHF", "JPY", + "CAD", "AUD", "SEK", "NOK", "DKK" + ] +} + diff --git a/PortfolioJournal/Utilities/DashboardLayoutStore.swift b/PortfolioJournal/Utilities/DashboardLayoutStore.swift new file mode 100644 index 0000000..8126e0f --- /dev/null +++ b/PortfolioJournal/Utilities/DashboardLayoutStore.swift @@ -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 + ) + } + } +} diff --git a/InvestmentTracker/Utilities/Extensions/Color+Extensions.swift b/PortfolioJournal/Utilities/Extensions/Color+Extensions.swift similarity index 100% rename from InvestmentTracker/Utilities/Extensions/Color+Extensions.swift rename to PortfolioJournal/Utilities/Extensions/Color+Extensions.swift diff --git a/InvestmentTracker/Utilities/Extensions/Date+Extensions.swift b/PortfolioJournal/Utilities/Extensions/Date+Extensions.swift similarity index 95% rename from InvestmentTracker/Utilities/Extensions/Date+Extensions.swift rename to PortfolioJournal/Utilities/Extensions/Date+Extensions.swift index 102ebc7..7a2297b 100644 --- a/InvestmentTracker/Utilities/Extensions/Date+Extensions.swift +++ b/PortfolioJournal/Utilities/Extensions/Date+Extensions.swift @@ -138,9 +138,9 @@ extension Date { var friendlyDescription: String { if isToday { - return "Today" + return String(localized: "date_today") } else if isYesterday { - return "Yesterday" + return String(localized: "date_yesterday") } else if isThisMonth { let formatter = DateFormatter() formatter.dateFormat = "EEEE, d" @@ -183,6 +183,10 @@ struct DateRange { return DateRange(start: lastYear.startOfYear, end: now.startOfYear.adding(days: -1)) } + static func month(containing date: Date) -> DateRange { + DateRange(start: date.startOfMonth, end: date.endOfMonth) + } + static func last(months: Int) -> DateRange { let now = Date() let start = now.adding(months: -months) diff --git a/InvestmentTracker/Utilities/Extensions/Decimal+Extensions.swift b/PortfolioJournal/Utilities/Extensions/Decimal+Extensions.swift similarity index 57% rename from InvestmentTracker/Utilities/Extensions/Decimal+Extensions.swift rename to PortfolioJournal/Utilities/Extensions/Decimal+Extensions.swift index 011f5d6..de5199b 100644 --- a/InvestmentTracker/Utilities/Extensions/Decimal+Extensions.swift +++ b/PortfolioJournal/Utilities/Extensions/Decimal+Extensions.swift @@ -1,43 +1,72 @@ import Foundation extension Decimal { + // MARK: - Performance: Shared formatters (avoid creating on every call) + private static let percentFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .percent + formatter.maximumFractionDigits = 2 + formatter.multiplier = 1 + return formatter + }() + + private static let decimalFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 2 + return formatter + }() + + // MARK: - Performance: Cached currency symbol + private static var _cachedCurrencySymbol: String? + private static var currencySymbolCacheTime: Date? + + private static var cachedCurrencySymbol: String { + // Refresh cache every 60 seconds to pick up settings changes + let now = Date() + if let cached = _cachedCurrencySymbol, + let cacheTime = currencySymbolCacheTime, + now.timeIntervalSince(cacheTime) < 60 { + return cached + } + let symbol = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol + _cachedCurrencySymbol = symbol + currencySymbolCacheTime = now + return symbol + } + + /// Call this when currency settings change to invalidate the cache + static func invalidateCurrencyCache() { + _cachedCurrencySymbol = nil + currencySymbolCacheTime = nil + } + // MARK: - Formatting var currencyString: String { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - formatter.currencyCode = "EUR" - formatter.maximumFractionDigits = 2 - return formatter.string(from: self as NSDecimalNumber) ?? "€0.00" + CurrencyFormatter.format(self, style: .currency, maximumFractionDigits: 2) } var compactCurrencyString: String { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - formatter.currencyCode = "EUR" - formatter.maximumFractionDigits = 0 - return formatter.string(from: self as NSDecimalNumber) ?? "€0" + CurrencyFormatter.format(self, style: .currency, maximumFractionDigits: 0) } var shortCurrencyString: String { let value = NSDecimalNumber(decimal: self).doubleValue + let symbol = Self.cachedCurrencySymbol - switch abs(value) { + switch Swift.abs(value) { case 1_000_000...: - return String(format: "€%.1fM", value / 1_000_000) + return String(format: "%@%.1fM", symbol, value / 1_000_000) case 1_000...: - return String(format: "€%.1fK", value / 1_000) + return String(format: "%@%.1fK", symbol, value / 1_000) default: return compactCurrencyString } } var percentageString: String { - let formatter = NumberFormatter() - formatter.numberStyle = .percent - formatter.maximumFractionDigits = 2 - formatter.multiplier = 1 - return formatter.string(from: self as NSDecimalNumber) ?? "0%" + Self.percentFormatter.string(from: self as NSDecimalNumber) ?? "0%" } var signedPercentageString: String { @@ -46,10 +75,7 @@ extension Decimal { } var decimalString: String { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 2 - return formatter.string(from: self as NSDecimalNumber) ?? "0" + Self.decimalFormatter.string(from: self as NSDecimalNumber) ?? "0" } // MARK: - Conversions diff --git a/InvestmentTracker/Utilities/FreemiumValidator.swift b/PortfolioJournal/Utilities/FreemiumValidator.swift similarity index 93% rename from InvestmentTracker/Utilities/FreemiumValidator.swift rename to PortfolioJournal/Utilities/FreemiumValidator.swift index 290c2ea..81471f4 100644 --- a/InvestmentTracker/Utilities/FreemiumValidator.swift +++ b/PortfolioJournal/Utilities/FreemiumValidator.swift @@ -1,4 +1,5 @@ import Foundation +import Combine enum FreemiumLimits { static let maxSources = 5 @@ -94,6 +95,8 @@ class FreemiumValidator: ObservableObject { if iapService.isPremium { return true } switch feature { + case .multipleAccounts: + return false case .unlimitedSources: return false case .fullHistory: @@ -112,6 +115,7 @@ class FreemiumValidator: ObservableObject { // MARK: - Premium Features Enum enum PremiumFeature: String, CaseIterable, Identifiable { + case multipleAccounts = "multiple_accounts" case unlimitedSources = "unlimited_sources" case fullHistory = "full_history" case advancedCharts = "advanced_charts" @@ -123,6 +127,7 @@ class FreemiumValidator: ObservableObject { var title: String { switch self { + case .multipleAccounts: return "Multiple Accounts" case .unlimitedSources: return "Unlimited Sources" case .fullHistory: return "Full History" case .advancedCharts: return "Advanced Charts" @@ -134,6 +139,7 @@ class FreemiumValidator: ObservableObject { var icon: String { switch self { + case .multipleAccounts: return "person.2" case .unlimitedSources: return "infinity" case .fullHistory: return "clock.arrow.circlepath" case .advancedCharts: return "chart.bar.xaxis" @@ -145,6 +151,8 @@ class FreemiumValidator: ObservableObject { var description: String { switch self { + case .multipleAccounts: + return "Track separate portfolios for family or business" case .unlimitedSources: return "Track as many investment sources as you want" case .fullHistory: diff --git a/PortfolioJournal/Utilities/KeychainService.swift b/PortfolioJournal/Utilities/KeychainService.swift new file mode 100644 index 0000000..88c68b8 --- /dev/null +++ b/PortfolioJournal/Utilities/KeychainService.swift @@ -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) + } +} diff --git a/PortfolioJournal/Utilities/MonthlyCheckInStore.swift b/PortfolioJournal/Utilities/MonthlyCheckInStore.swift new file mode 100644 index 0000000..fc46453 --- /dev/null +++ b/PortfolioJournal/Utilities/MonthlyCheckInStore.swift @@ -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 + } +} diff --git a/PortfolioJournal/Utilities/TabSelectionStore.swift b/PortfolioJournal/Utilities/TabSelectionStore.swift new file mode 100644 index 0000000..aef6fe3 --- /dev/null +++ b/PortfolioJournal/Utilities/TabSelectionStore.swift @@ -0,0 +1,7 @@ +import Foundation +import Combine + +@MainActor +final class TabSelectionStore: ObservableObject { + @Published var selectedTab = 0 +} diff --git a/PortfolioJournal/ViewModels/ChartsViewModel.swift b/PortfolioJournal/ViewModels/ChartsViewModel.swift new file mode 100644 index 0000000..f98f245 --- /dev/null +++ b/PortfolioJournal/ViewModels/ChartsViewModel.swift @@ -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() + 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.. [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.. 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.. 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.. 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.. 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 + } +} diff --git a/PortfolioJournal/ViewModels/DashboardViewModel.swift b/PortfolioJournal/ViewModels/DashboardViewModel.swift new file mode 100644 index 0000000..a037895 --- /dev/null +++ b/PortfolioJournal/ViewModels/DashboardViewModel.swift @@ -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() + 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 { + 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() + 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" + } +} diff --git a/PortfolioJournal/ViewModels/GoalsViewModel.swift b/PortfolioJournal/ViewModels/GoalsViewModel.swift new file mode 100644 index 0000000..386f055 --- /dev/null +++ b/PortfolioJournal/ViewModels/GoalsViewModel.swift @@ -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() + + // 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 +} diff --git a/PortfolioJournal/ViewModels/JournalViewModel.swift b/PortfolioJournal/ViewModels/JournalViewModel.swift new file mode 100644 index 0000000..788cd1d --- /dev/null +++ b/PortfolioJournal/ViewModels/JournalViewModel.swift @@ -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() + + 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? +} diff --git a/PortfolioJournal/ViewModels/MonthlyCheckInViewModel.swift b/PortfolioJournal/ViewModels/MonthlyCheckInViewModel.swift new file mode 100644 index 0000000..319595e --- /dev/null +++ b/PortfolioJournal/ViewModels/MonthlyCheckInViewModel.swift @@ -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() + + @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 + } + } +} diff --git a/InvestmentTracker/ViewModels/SettingsViewModel.swift b/PortfolioJournal/ViewModels/SettingsViewModel.swift similarity index 78% rename from InvestmentTracker/ViewModels/SettingsViewModel.swift rename to PortfolioJournal/ViewModels/SettingsViewModel.swift index e89718d..fd3c037 100644 --- a/InvestmentTracker/ViewModels/SettingsViewModel.swift +++ b/PortfolioJournal/ViewModels/SettingsViewModel.swift @@ -1,5 +1,6 @@ import Foundation import Combine +import CoreData import UIKit @MainActor @@ -11,10 +12,13 @@ class SettingsViewModel: ObservableObject { @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? @@ -38,14 +42,14 @@ class SettingsViewModel: ObservableObject { init( iapService: IAPService, - notificationService: NotificationService = .shared, - sourceRepository: InvestmentSourceRepository = InvestmentSourceRepository(), - categoryRepository: CategoryRepository = CategoryRepository() + notificationService: NotificationService? = nil, + sourceRepository: InvestmentSourceRepository? = nil, + categoryRepository: CategoryRepository? = nil ) { self.iapService = iapService - self.notificationService = notificationService - self.sourceRepository = sourceRepository - self.categoryRepository = categoryRepository + self.notificationService = notificationService ?? .shared + self.sourceRepository = sourceRepository ?? InvestmentSourceRepository() + self.categoryRepository = categoryRepository ?? CategoryRepository() self.freemiumValidator = FreemiumValidator(iapService: iapService) setupObservers() @@ -76,6 +80,8 @@ class SettingsViewModel: ObservableObject { defaultNotificationTime = settings.defaultNotificationTime ?? Date() analyticsEnabled = settings.enableAnalytics + currencyCode = settings.currency + inputMode = InputMode(rawValue: settings.inputMode) ?? .simple // Load statistics totalSources = sourceRepository.sourceCount @@ -173,6 +179,26 @@ class SettingsViewModel: ObservableObject { // 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() { @@ -185,6 +211,18 @@ class SettingsViewModel: ObservableObject { for category in categoryRepository.categories { context.delete(category) } + let assetRequest: NSFetchRequest = Asset.fetchRequest() + let accountRequest: NSFetchRequest = Account.fetchRequest() + let goalRequest: NSFetchRequest = 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() @@ -193,6 +231,7 @@ class SettingsViewModel: ObservableObject { // Recreate default categories categoryRepository.createDefaultCategoriesIfNeeded() + _ = AccountRepository().createDefaultAccountIfNeeded() // Reload data loadSettings() diff --git a/InvestmentTracker/ViewModels/SnapshotFormViewModel.swift b/PortfolioJournal/ViewModels/SnapshotFormViewModel.swift similarity index 79% rename from InvestmentTracker/ViewModels/SnapshotFormViewModel.swift rename to PortfolioJournal/ViewModels/SnapshotFormViewModel.swift index 31ff91a..f8a33d9 100644 --- a/InvestmentTracker/ViewModels/SnapshotFormViewModel.swift +++ b/PortfolioJournal/ViewModels/SnapshotFormViewModel.swift @@ -10,6 +10,8 @@ class SnapshotFormViewModel: ObservableObject { @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? @@ -33,6 +35,18 @@ class SnapshotFormViewModel: ObservableObject { 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() @@ -86,7 +100,7 @@ class SnapshotFormViewModel: ObservableObject { private func parseDecimal(_ string: String) -> Decimal? { let cleaned = string - .replacingOccurrences(of: "€", with: "") + .replacingOccurrences(of: currencySymbol, with: "") .replacingOccurrences(of: ",", with: ".") .trimmingCharacters(in: .whitespaces) @@ -120,7 +134,7 @@ class SnapshotFormViewModel: ObservableObject { } var formattedValue: String { - guard let value = value else { return "€0.00" } + guard let value = value else { return CurrencyFormatter.format(Decimal.zero) } return value.currencyString } @@ -178,6 +192,21 @@ class SnapshotFormViewModel: ObservableObject { 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 { diff --git a/PortfolioJournal/ViewModels/SourceDetailViewModel.swift b/PortfolioJournal/ViewModels/SourceDetailViewModel.swift new file mode 100644 index 0000000..3a9e6f8 --- /dev/null +++ b/PortfolioJournal/ViewModels/SourceDetailViewModel.swift @@ -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() + 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 { + 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 + } +} diff --git a/InvestmentTracker/ViewModels/SourceListViewModel.swift b/PortfolioJournal/ViewModels/SourceListViewModel.swift similarity index 78% rename from InvestmentTracker/ViewModels/SourceListViewModel.swift rename to PortfolioJournal/ViewModels/SourceListViewModel.swift index 070e798..72fb391 100644 --- a/InvestmentTracker/ViewModels/SourceListViewModel.swift +++ b/PortfolioJournal/ViewModels/SourceListViewModel.swift @@ -10,6 +10,8 @@ class SourceListViewModel: ObservableObject { @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 @@ -25,12 +27,12 @@ class SourceListViewModel: ObservableObject { // MARK: - Initialization init( - sourceRepository: InvestmentSourceRepository = InvestmentSourceRepository(), - categoryRepository: CategoryRepository = CategoryRepository(), + sourceRepository: InvestmentSourceRepository? = nil, + categoryRepository: CategoryRepository? = nil, iapService: IAPService ) { - self.sourceRepository = sourceRepository - self.categoryRepository = categoryRepository + self.sourceRepository = sourceRepository ?? InvestmentSourceRepository() + self.categoryRepository = categoryRepository ?? CategoryRepository() self.freemiumValidator = FreemiumValidator(iapService: iapService) setupObservers() @@ -40,13 +42,7 @@ class SourceListViewModel: ObservableObject { // MARK: - Setup private func setupObservers() { - sourceRepository.$sources - .receive(on: DispatchQueue.main) - .sink { [weak self] sources in - self?.filterAndSortSources(sources) - } - .store(in: &cancellables) - + // Performance: Update categories separately (less frequent) categoryRepository.$categories .receive(on: DispatchQueue.main) .sink { [weak self] categories in @@ -54,18 +50,22 @@ class SourceListViewModel: ObservableObject { } .store(in: &cancellables) - $searchText - .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) - .sink { [weak self] _ in - self?.filterAndSortSources(self?.sourceRepository.sources ?? []) - } - .store(in: &cancellables) - - $selectedCategory - .sink { [weak self] _ in - self?.filterAndSortSources(self?.sourceRepository.sources ?? []) - } - .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 @@ -83,6 +83,10 @@ class SourceListViewModel: ObservableObject { 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 } @@ -115,13 +119,15 @@ class SourceListViewModel: ObservableObject { name: String, category: Category, frequency: NotificationFrequency, - customMonths: Int = 1 + customMonths: Int = 1, + account: Account? = nil ) { let source = sourceRepository.createSource( name: name, category: category, notificationFrequency: frequency, - customFrequencyMonths: customMonths + customFrequencyMonths: customMonths, + account: account ) // Schedule notification diff --git a/PortfolioJournal/Views/Accounts/AccountEditorView.swift b/PortfolioJournal/Views/Accounts/AccountEditorView.swift new file mode 100644 index 0000000..5c5de48 --- /dev/null +++ b/PortfolioJournal/Views/Accounts/AccountEditorView.swift @@ -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())) +} diff --git a/PortfolioJournal/Views/Accounts/AccountsView.swift b/PortfolioJournal/Views/Accounts/AccountsView.swift new file mode 100644 index 0000000..ec0a3cc --- /dev/null +++ b/PortfolioJournal/Views/Accounts/AccountsView.swift @@ -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())) +} diff --git a/InvestmentTracker/Views/Charts/AllocationPieChart.swift b/PortfolioJournal/Views/Charts/AllocationPieChart.swift similarity index 66% rename from InvestmentTracker/Views/Charts/AllocationPieChart.swift rename to PortfolioJournal/Views/Charts/AllocationPieChart.swift index d8257b0..4e0c8b9 100644 --- a/InvestmentTracker/Views/Charts/AllocationPieChart.swift +++ b/PortfolioJournal/Views/Charts/AllocationPieChart.swift @@ -97,6 +97,8 @@ struct AllocationPieChart: View { } .frame(maxWidth: .infinity, alignment: .leading) } + + AllocationTargetsComparisonChart(data: data) } else { Text("No allocation data available") .foregroundColor(.secondary) @@ -111,6 +113,106 @@ struct AllocationPieChart: View { } } +// 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 { diff --git a/PortfolioJournal/Views/Charts/ChartsContainerView.swift b/PortfolioJournal/Views/Charts/ChartsContainerView.swift new file mode 100644 index 0000000..ccc154a --- /dev/null +++ b/PortfolioJournal/Views/Charts/ChartsContainerView.swift @@ -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())) +} diff --git a/InvestmentTracker/Views/Charts/DrawdownChart.swift b/PortfolioJournal/Views/Charts/DrawdownChart.swift similarity index 100% rename from InvestmentTracker/Views/Charts/DrawdownChart.swift rename to PortfolioJournal/Views/Charts/DrawdownChart.swift diff --git a/InvestmentTracker/Views/Charts/PerformanceBarChart.swift b/PortfolioJournal/Views/Charts/PerformanceBarChart.swift similarity index 100% rename from InvestmentTracker/Views/Charts/PerformanceBarChart.swift rename to PortfolioJournal/Views/Charts/PerformanceBarChart.swift diff --git a/InvestmentTracker/Views/Charts/PredictionChartView.swift b/PortfolioJournal/Views/Charts/PredictionChartView.swift similarity index 93% rename from InvestmentTracker/Views/Charts/PredictionChartView.swift rename to PortfolioJournal/Views/Charts/PredictionChartView.swift index 9e94004..85871b5 100644 --- a/InvestmentTracker/Views/Charts/PredictionChartView.swift +++ b/PortfolioJournal/Views/Charts/PredictionChartView.swift @@ -8,7 +8,7 @@ struct PredictionChartView: View { var body: some View { VStack(alignment: .leading, spacing: 16) { HStack { - Text("12-Month Prediction") + Text(predictions.isEmpty ? "Prediction" : "\(predictions.count)-Month Prediction") .font(.headline) Spacer() @@ -41,6 +41,13 @@ struct PredictionChartView: View { ) .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 @@ -61,6 +68,13 @@ struct PredictionChartView: View { ) .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 diff --git a/PortfolioJournal/Views/Components/AppBackground.swift b/PortfolioJournal/Views/Components/AppBackground.swift new file mode 100644 index 0000000..5d94bab --- /dev/null +++ b/PortfolioJournal/Views/Components/AppBackground.swift @@ -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() + } + } +} + diff --git a/InvestmentTracker/Views/Components/LoadingView.swift b/PortfolioJournal/Views/Components/LoadingView.swift similarity index 100% rename from InvestmentTracker/Views/Components/LoadingView.swift rename to PortfolioJournal/Views/Components/LoadingView.swift diff --git a/InvestmentTracker/Views/Dashboard/CategoryBreakdown.swift b/PortfolioJournal/Views/Dashboard/CategoryBreakdown.swift similarity index 82% rename from InvestmentTracker/Views/Dashboard/CategoryBreakdown.swift rename to PortfolioJournal/Views/Dashboard/CategoryBreakdown.swift index 3167d24..18d914b 100644 --- a/InvestmentTracker/Views/Dashboard/CategoryBreakdown.swift +++ b/PortfolioJournal/Views/Dashboard/CategoryBreakdown.swift @@ -1,6 +1,7 @@ import SwiftUI struct CategoryBreakdownCard: View { + @EnvironmentObject private var iapService: IAPService let categories: [CategoryMetrics] var body: some View { @@ -12,7 +13,7 @@ struct CategoryBreakdownCard: View { Spacer() NavigationLink { - ChartsContainerView() + ChartsContainerView(iapService: iapService) } label: { Text("See All") .font(.subheadline) @@ -33,6 +34,16 @@ struct CategoryBreakdownCard: View { 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) { @@ -55,6 +66,12 @@ struct CategoryRowView: View { Text(category.formattedPercentage) .font(.caption) .foregroundColor(.secondary) + + if let target = targetPercentage { + Text("Target \(String(format: "%.0f%%", target)) | Drift \(driftText ?? "")") + .font(.caption2) + .foregroundColor(.secondary) + } } Spacer() @@ -64,13 +81,14 @@ struct CategoryRowView: View { Text(category.formattedTotalValue) .font(.subheadline.weight(.semibold)) - HStack(spacing: 2) { - Image(systemName: category.metrics.cagr >= 0 ? "arrow.up.right" : "arrow.down.right") + HStack(spacing: 4) { + Text("CAGR") .font(.caption2) + .foregroundColor(.secondary) Text(category.metrics.formattedCAGR) - .font(.caption) + .font(.caption.weight(.semibold)) + .foregroundColor(category.metrics.cagr >= 0 ? .positiveGreen : .negativeRed) } - .foregroundColor(category.metrics.cagr >= 0 ? .positiveGreen : .negativeRed) } } .padding(.vertical, 4) @@ -188,4 +206,5 @@ struct SimpleCategoryList: View { return CategoryBreakdownCard(categories: sampleCategories) .padding() + .environmentObject(IAPService()) } diff --git a/PortfolioJournal/Views/Dashboard/DashboardView.swift b/PortfolioJournal/Views/Dashboard/DashboardView.swift new file mode 100644 index 0000000..e6a25eb --- /dev/null +++ b/PortfolioJournal/Views/Dashboard/DashboardView.swift @@ -0,0 +1,1041 @@ +import SwiftUI +import Charts + +struct DashboardView: View { + @EnvironmentObject var iapService: IAPService + @EnvironmentObject var accountStore: AccountStore + @StateObject private var viewModel: DashboardViewModel + @StateObject private var goalsViewModel = GoalsViewModel() + @State private var showingImport = false + @State private var showingAddSource = false + @State private var showingCustomize = false + @State private var sectionConfigs = DashboardLayoutStore.load() + @AppStorage("calmModeEnabled") private var calmModeEnabled = true + + init() { + _viewModel = StateObject(wrappedValue: DashboardViewModel()) + } + + var body: some View { + NavigationStack { + ZStack { + AppBackground() + + ScrollView { + VStack(spacing: 20) { + if viewModel.hasData { + ForEach(visibleSections) { config in + sectionView(for: config) + } + } else { + EmptyDashboardView( + onAddSource: { showingAddSource = true }, + onImport: { showingImport = true }, + onLoadSample: { SampleDataService.shared.seedSampleData() } + ) + } + } + .padding() + } + } + .navigationTitle("Home") + .refreshable { + viewModel.refreshData() + } + .overlay { + if viewModel.isLoading { + ProgressView() + } + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + accountFilterMenu + } + ToolbarItem(placement: .navigationBarLeading) { + Button { + showingCustomize = true + } label: { + Image(systemName: "slider.horizontal.3") + } + } + } + .onAppear { + viewModel.selectedAccount = accountStore.selectedAccount + viewModel.showAllAccounts = accountStore.showAllAccounts + viewModel.refreshData() + goalsViewModel.selectedAccount = accountStore.selectedAccount + goalsViewModel.showAllAccounts = accountStore.showAllAccounts + goalsViewModel.refresh() + } + // Performance: Combine account selection changes into a single handler + .onChange(of: accountStore.selectedAccount) { _, newAccount in + viewModel.selectedAccount = newAccount + viewModel.refreshData() + goalsViewModel.selectedAccount = newAccount + goalsViewModel.refresh() + } + .onChange(of: accountStore.showAllAccounts) { _, showAll in + viewModel.showAllAccounts = showAll + viewModel.refreshData() + goalsViewModel.showAllAccounts = showAll + goalsViewModel.refresh() + } + .sheet(isPresented: $showingImport) { + ImportDataView() + } + .sheet(isPresented: $showingAddSource) { + AddSourceView() + } + .sheet(isPresented: $showingCustomize) { + DashboardCustomizeView(configs: $sectionConfigs) + } + } + } + + 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") + } + } + + private var visibleSections: [DashboardSectionConfig] { + sectionConfigs.filter { $0.isVisible } + } + + @ViewBuilder + private func sectionView(for config: DashboardSectionConfig) -> some View { + if let section = DashboardSection(rawValue: config.id) { + switch section { + case .totalValue: + if config.isCollapsed { + CompactCard(title: "Portfolio", subtitle: viewModel.portfolioSummary.formattedTotalValue) + } else { + TotalValueCard( + totalValue: viewModel.portfolioSummary.formattedTotalValue, + changeText: calmModeEnabled + ? "\(viewModel.latestPortfolioChange.formattedAbsolute) (\(viewModel.latestPortfolioChange.formattedPercentage))" + : viewModel.portfolioSummary.formattedDayChange, + changeLabel: calmModeEnabled ? "since last update" : "today", + isPositive: calmModeEnabled + ? viewModel.latestPortfolioChange.absolute >= 0 + : viewModel.isDayChangePositive + ) + } + case .monthlyCheckIn: + if config.isCollapsed { + CompactCard(title: "Monthly Check-in", subtitle: "Last update: \(viewModel.formattedLastUpdate)") + } else { + MonthlyCheckInCard( + lastUpdated: viewModel.formattedLastUpdate, + lastUpdatedDate: viewModel.portfolioSummary.lastUpdated + ) + } + case .momentumStreaks: + if config.isCollapsed { + MomentumStreaksCompactCard() + } else { + MomentumStreaksCard() + } + case .monthlySummary: + if viewModel.monthlySummary.contributions != 0 { + if config.isCollapsed { + CompactCard( + title: "Cashflow vs Growth", + subtitle: "\(viewModel.monthlySummary.formattedContributions) contributions" + ) + } else { + MonthlySummaryCard(summary: viewModel.monthlySummary) + } + } + case .evolution: + if config.isCollapsed { + EvolutionCompactCard(data: viewModel.evolutionData) + } else if !viewModel.evolutionData.isEmpty { + EvolutionChartCard( + data: viewModel.evolutionData, + categoryData: viewModel.categoryEvolutionData, + goals: goalsViewModel.goals + ) + } + case .categoryBreakdown: + if config.isCollapsed { + let top = viewModel.topCategories.first + CompactCard( + title: "Top Category", + subtitle: "\(top?.categoryName ?? "—") \(top?.formattedPercentage ?? "")" + ) + } else if !viewModel.categoryMetrics.isEmpty { + CategoryBreakdownCard(categories: viewModel.topCategories) + } + case .goals: + if config.isCollapsed { + CompactCard(title: "Goals", subtitle: "\(goalsViewModel.goals.count) active") + } else { + GoalsSummaryCard( + goals: goalsViewModel.goals, + progressProvider: goalsViewModel.progress(for:), + currentValueProvider: goalsViewModel.totalValue(for:), + paceStatusProvider: goalsViewModel.paceStatus(for:), + etaProvider: { goal in + let currentValue = goalsViewModel.totalValue(for: goal) + return viewModel.goalEtaText(for: goal, currentValue: currentValue) + } + ) + } + case .pendingUpdates: + if config.isCollapsed { + CompactCard(title: "Pending Updates", subtitle: "\(viewModel.sourcesNeedingUpdate.count) sources") + } else if !viewModel.sourcesNeedingUpdate.isEmpty { + PendingUpdatesCard(sources: viewModel.sourcesNeedingUpdate) + } + case .periodReturns: + if !calmModeEnabled { + if config.isCollapsed { + CompactCard(title: "Returns", subtitle: viewModel.portfolioSummary.formattedMonthChange) + } else { + PeriodReturnsCard( + monthChange: viewModel.portfolioSummary.formattedMonthChange, + yearChange: viewModel.portfolioSummary.formattedYearChange, + allTimeChange: viewModel.portfolioSummary.formattedAllTimeReturn, + isMonthPositive: viewModel.isMonthChangePositive, + isYearPositive: viewModel.isYearChangePositive, + isAllTimePositive: viewModel.portfolioSummary.allTimeReturn >= 0 + ) + } + } + } + } else { + EmptyView() + } + } +} + +// MARK: - Total Value Card + +struct TotalValueCard: View { + let totalValue: String + let changeText: String + let changeLabel: String + let isPositive: Bool + + var body: some View { + VStack(spacing: 8) { + Text("Total Portfolio Value") + .font(.subheadline) + .foregroundColor(.white.opacity(0.85)) + + Text(totalValue) + .font(.system(size: 42, weight: .bold, design: .rounded)) + .foregroundColor(.white) + + HStack(spacing: 4) { + Image(systemName: isPositive ? "arrow.up.right" : "arrow.down.right") + .font(.caption) + Text(changeText) + .font(.subheadline.weight(.medium)) + Text(changeLabel) + .font(.subheadline) + .foregroundColor(.white.opacity(0.8)) + } + .foregroundColor(.white) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + .background(LinearGradient.appPrimaryGradient) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } +} + +// MARK: - Monthly Check-in Card + +struct MonthlyCheckInCard: View { + let lastUpdated: String + let lastUpdatedDate: Date? + @State private var showingStartOptions = false + @State private var startDestinationActive = false + @State private var shouldDuplicatePrevious = false + + private var effectiveLastCheckInDate: Date? { + MonthlyCheckInStore.latestCompletionDate() ?? lastUpdatedDate + } + + private var checkInProgress: Double { + guard let last = effectiveLastCheckInDate, + let next = nextCheckInDate else { return 1 } + let totalDays = Double(max(1, last.startOfDay.daysBetween(next.startOfDay))) + guard totalDays > 0 else { return 1 } + let elapsedDays = Double(last.startOfDay.daysBetween(Date())) + return min(max(elapsedDays / totalDays, 0), 1) + } + + private var nextCheckInDate: Date? { + guard let last = effectiveLastCheckInDate else { return nil } + return last.adding(months: 1) + } + + private var reminderDate: Date? { + guard let nextCheckInDate else { return nil } + let settings = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext) + let reminderTime = settings.defaultNotificationTime ?? defaultReminderTime + let timeComponents = Calendar.current.dateComponents([.hour, .minute], from: reminderTime) + return Calendar.current.date( + bySettingHour: timeComponents.hour ?? 9, + minute: timeComponents.minute ?? 0, + second: 0, + of: nextCheckInDate + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Monthly Check-in") + .font(.headline) + + Text("Keep a calm, deliberate rhythm. Update your sources and add a short note.") + .font(.subheadline) + .foregroundColor(.secondary) + + HStack { + Text("Last update: \(lastUpdated)") + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + + NavigationLink { + AchievementsView(referenceDate: Date()) + } label: { + Image(systemName: "trophy.fill") + } + .font(.subheadline.weight(.semibold)) + + if let reminderDate { + Button { + let title = String( + format: NSLocalizedString("calendar_event_title", comment: ""), + appDisplayName + ) + let notes = String( + format: NSLocalizedString("calendar_event_notes", comment: ""), + appDisplayName + ) + ShareService.shared.shareCalendarEvent( + title: title, + notes: notes, + startDate: reminderDate + ) + } label: { + Image(systemName: "calendar.badge.plus") + } + .font(.subheadline.weight(.semibold)) + } + + Button("Start") { + showingStartOptions = true + } + .font(.subheadline.weight(.semibold)) + } + + ProgressView(value: checkInProgress) + .tint(.appSecondary) + + if let nextDate = nextCheckInDate { + Text("Next check-in: \(nextDate.mediumDateString)") + .font(.caption) + .foregroundColor(.secondary) + } + + NavigationLink( + isActive: $startDestinationActive + ) { + MonthlyCheckInView(duplicatePrevious: shouldDuplicatePrevious) + } label: { + EmptyView() + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + .confirmationDialog( + "Start Monthly Check-in", + isPresented: $showingStartOptions, + titleVisibility: .visible + ) { + Button("Start from scratch") { + shouldDuplicatePrevious = false + startDestinationActive = true + } + Button("Duplicate previous month") { + shouldDuplicatePrevious = true + startDestinationActive = true + } + Button("Cancel", role: .cancel) {} + } + } + + private var defaultReminderTime: Date { + var components = DateComponents() + components.hour = 9 + components.minute = 0 + return Calendar.current.date(from: components) ?? Date() + } + + private var appDisplayName: String { + if let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String { + return name + } + if let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String { + return name + } + return "Portfolio Journal" + } +} + +// MARK: - Momentum & Streaks Card + +struct MomentumStreaksCard: View { + @State private var stats: MonthlyCheckInStats = .empty + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline) { + Text("Momentum & Streaks") + .font(.headline) + Spacer() + if stats.totalCheckIns > 0 { + Text(String(format: NSLocalizedString("on_time_rate", comment: ""), onTimeRateText)) + .font(.subheadline.weight(.semibold)) + .foregroundColor(.appSecondary) + } else { + Text("Log a check-in to start a streak") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + + HStack(spacing: 12) { + statTile( + title: "Streak", + value: "\(stats.currentStreak)x", + subtitle: "On-time in a row" + ) + statTile( + title: "Best", + value: "\(stats.bestStreak)x", + subtitle: "Personal best" + ) + statTile( + title: "Avg early", + value: formattedDaysText(for: stats.averageDaysBeforeDeadline), + subtitle: "vs deadline" + ) + } + + ProgressView(value: stats.onTimeRate, total: 1) + .tint(.appSecondary) + + HStack { + Text("On-time score") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text( + String( + format: NSLocalizedString("on_time_count", comment: ""), + stats.onTimeCount, + stats.totalCheckIns + ) + ) + .font(.caption) + .foregroundColor(.secondary) + } + + if let closest = stats.closestCutoffDays { + Text( + String( + format: NSLocalizedString("tightest_finish", comment: ""), + formattedDaysLabel(for: closest) + ) + ) + .font(.caption) + .foregroundColor(.secondary) + } + + if !stats.achievements.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "achievements_title")) + .font(.subheadline.weight(.semibold)) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(stats.achievements) { achievement in + achievementBadge(achievement) + } + } + } + } + } + + NavigationLink { + AchievementsView(referenceDate: Date()) + } label: { + HStack { + Text(String(localized: "achievements_view_all")) + .font(.subheadline.weight(.semibold)) + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.vertical, 8) + } + .buttonStyle(.plain) + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + .onAppear(perform: refreshStats) + .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in + refreshStats() + } + } + + private var onTimeRateText: String { + String(format: "%.0f%%", stats.onTimeRate * 100) + } + + private func refreshStats() { + stats = MonthlyCheckInStore.stats(referenceDate: Date()) + } + + @ViewBuilder + private func statTile(title: String, value: String, subtitle: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.caption) + .foregroundColor(.secondary) + Text(value) + .font(.title3.weight(.semibold)) + Text(subtitle) + .font(.caption2) + .foregroundColor(.secondary) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.gray.opacity(0.08)) + .cornerRadius(AppConstants.UI.smallCornerRadius) + } + + @ViewBuilder + private func achievementBadge(_ achievement: MonthlyCheckInAchievement) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: achievement.icon) + .font(.headline) + .foregroundColor(.appSecondary) + VStack(alignment: .leading, spacing: 2) { + Text(achievement.title) + .font(.caption.weight(.semibold)) + Text(achievement.detail) + .font(.caption2) + .foregroundColor(.secondary) + } + } + .padding(10) + .background(Color.appSecondary.opacity(0.12)) + .cornerRadius(AppConstants.UI.smallCornerRadius) + } + + private func formattedDaysText(for days: Double?) -> String { + guard let days, days >= 0 else { return "—" } + return days >= 1 ? String(format: "%.1fd", days) : String(format: "%.0fh", days * 24) + } + + private func formattedDaysLabel(for days: Double) -> String { + if days >= 1 { + return String(format: "%.1f days", days) + } + let hours = max(0, days * 24) + return String(format: "%.0f hours", hours) + } +} + +struct MomentumStreaksCompactCard: View { + @State private var stats: MonthlyCheckInStats = .empty + + var body: some View { + CompactCard( + title: "Momentum & Streaks", + subtitle: "Streak: \(stats.currentStreak)x • Best: \(stats.bestStreak)x" + ) + .onAppear(perform: refreshStats) + .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in + refreshStats() + } + } + + private func refreshStats() { + stats = MonthlyCheckInStore.stats(referenceDate: Date()) + } +} + +// MARK: - Monthly Summary Card + +struct MonthlySummaryCard: View { + let summary: MonthlySummary + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Cashflow vs Growth") + .font(.headline) + + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Contributions") + .font(.caption) + .foregroundColor(.secondary) + Text(summary.formattedContributions) + .font(.subheadline.weight(.semibold)) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 4) { + Text("Net Performance") + .font(.caption) + .foregroundColor(.secondary) + Text("\(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))") + .font(.subheadline.weight(.semibold)) + .foregroundColor(summary.netPerformance >= 0 ? .positiveGreen : .negativeRed) + } + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } +} + +// MARK: - Dashboard Customization + +struct DashboardCustomizeView: View { + @Environment(\.dismiss) private var dismiss + @Binding var configs: [DashboardSectionConfig] + + var body: some View { + NavigationStack { + List { + ForEach($configs) { $config in + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(DashboardSection(rawValue: config.id)?.title ?? config.id) + .font(.subheadline.weight(.semibold)) + Text(config.isCollapsed ? "Compact" : "Expanded") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Toggle("Show", isOn: $config.isVisible) + .labelsHidden() + } + .swipeActions(edge: .trailing) { + Button { + config.isCollapsed.toggle() + } label: { + Label(config.isCollapsed ? "Expand" : "Compact", systemImage: "arrow.up.left.and.arrow.down.right") + } + .tint(.appSecondary) + } + } + .onMove { indices, newOffset in + configs.move(fromOffsets: indices, toOffset: newOffset) + } + } + .navigationTitle("Customize Dashboard") + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + EditButton() + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + DashboardLayoutStore.save(configs) + dismiss() + } + .fontWeight(.semibold) + } + } + } + .onDisappear { + DashboardLayoutStore.save(configs) + } + } +} + +struct CompactCard: View { + let title: String + let subtitle: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.headline) + Text(subtitle) + .font(.subheadline) + .foregroundColor(.secondary) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } +} + +struct EvolutionCompactCard: View { + let data: [(date: Date, value: Decimal)] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Portfolio Evolution") + .font(.headline) + if let last = data.last { + Text(last.value.compactCurrencyString) + .font(.subheadline.weight(.semibold)) + .foregroundColor(.secondary) + } + SparklineView(data: data, color: .appPrimary) + .frame(height: 40) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } +} + +// MARK: - Period Returns Card + +struct PeriodReturnsCard: View { + let monthChange: String + let yearChange: String + let allTimeChange: String + let isMonthPositive: Bool + let isYearPositive: Bool + let isAllTimePositive: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Returns") + .font(.headline) + + HStack(spacing: 16) { + ReturnPeriodView( + period: "1M", + change: monthChange, + isPositive: isMonthPositive + ) + + Divider() + + ReturnPeriodView( + period: "1Y", + change: yearChange, + isPositive: isYearPositive + ) + + Divider() + + ReturnPeriodView( + period: "All", + change: allTimeChange, + isPositive: isAllTimePositive + ) + } + .frame(maxWidth: .infinity) + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } +} + +struct ReturnPeriodView: View { + let period: String + let change: String + let isPositive: Bool + + var body: some View { + VStack(spacing: 4) { + Text(period) + .font(.caption) + .foregroundColor(.secondary) + + Text(change) + .font(.subheadline.weight(.semibold)) + .foregroundColor(isPositive ? .positiveGreen : .negativeRed) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .frame(maxWidth: .infinity) + } +} + +// MARK: - Empty Dashboard View + +struct EmptyDashboardView: View { + let onAddSource: () -> Void + let onImport: () -> Void + let onLoadSample: () -> Void + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "chart.pie") + .font(.system(size: 60)) + .foregroundColor(.secondary) + + Text("Welcome to Portfolio Journal") + .font(.custom("Avenir Next", size: 24).weight(.semibold)) + + Text("Start by adding your first investment source to track your portfolio.") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + Button { + onAddSource() + } label: { + Label("Add Investment Source", systemImage: "plus") + .font(.headline) + .foregroundColor(.white) + .padding() + .frame(maxWidth: .infinity) + .background(Color.appPrimary) + .cornerRadius(AppConstants.UI.cornerRadius) + } + + HStack(spacing: 12) { + Button { + onImport() + } label: { + Label("Import", systemImage: "square.and.arrow.down") + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding() + .background(Color.appPrimary.opacity(0.1)) + .foregroundColor(.appPrimary) + .cornerRadius(AppConstants.UI.cornerRadius) + } + + Button { + onLoadSample() + } label: { + Label("Load Sample", systemImage: "sparkles") + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding() + .background(Color.appSecondary.opacity(0.1)) + .foregroundColor(.appSecondary) + .cornerRadius(AppConstants.UI.cornerRadius) + } + } + } + .padding() + } +} + +// MARK: - Pending Updates Card + +struct PendingUpdatesCard: View { + @EnvironmentObject var iapService: IAPService + let sources: [InvestmentSource] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "bell.badge.fill") + .foregroundColor(.appWarning) + Text("Pending Updates") + .font(.headline) + Spacer() + Text("\(sources.count)") + .font(.subheadline) + .foregroundColor(.secondary) + } + + ForEach(sources.prefix(3)) { source in + NavigationLink(destination: SourceDetailView(source: source, iapService: iapService)) { + HStack { + Circle() + .fill(source.category?.color ?? .gray) + .frame(width: 8, height: 8) + + Text(source.name) + .font(.subheadline) + + Spacer() + + Text(source.latestSnapshot?.date.relativeDescription ?? "Never") + .font(.caption) + .foregroundColor(.secondary) + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + } + .buttonStyle(.plain) + } + + if sources.count > 3 { + Text("+ \(sources.count - 3) more") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } +} + +// MARK: - Goals Summary Card + +struct GoalsSummaryCard: View { + let goals: [Goal] + let progressProvider: (Goal) -> Double + let currentValueProvider: (Goal) -> Decimal + let paceStatusProvider: (Goal) -> GoalPaceStatus? + let etaProvider: (Goal) -> String? + + var body: some View { + if goals.isEmpty { + EmptyGoalsCard() + } else { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Goals") + .font(.headline) + Spacer() + NavigationLink("View All") { + GoalsView() + } + .font(.caption.weight(.semibold)) + } + + ForEach(goals.prefix(2)) { goal in + let currentValue = currentValueProvider(goal) + let paceStatus = paceStatusProvider(goal) + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(goal.name) + .font(.subheadline.weight(.semibold)) + Spacer() + Button { + GoalShareService.shared.shareGoal( + name: goal.name, + progress: progressProvider(goal), + currentValue: currentValue, + targetValue: goal.targetDecimal + ) + } label: { + Image(systemName: "square.and.arrow.up") + .font(.caption) + .foregroundColor(.appPrimary) + } + } + + GoalProgressBar(progress: progressProvider(goal), tint: .appSecondary, iconColor: .appSecondary) + + HStack { + Text(currentValue.compactCurrencyString) + .font(.caption.weight(.semibold)) + Spacer() + Text("of \(goal.targetDecimal.compactCurrencyString)") + .font(.caption) + .foregroundColor(.secondary) + } + + if let etaText = etaProvider(goal) { + Text(etaText) + .font(.caption2) + .foregroundColor(.secondary) + } + + if let paceStatus { + Text(paceStatus.statusText) + .font(.caption2.weight(.semibold)) + .foregroundColor(paceStatus.isBehind ? .negativeRed : .positiveGreen) + } + } + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } + } +} + +struct EmptyGoalsCard: View { + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Goals") + .font(.headline) + Spacer() + NavigationLink("Set Goal") { + GoalsView() + } + .font(.caption.weight(.semibold)) + } + Text("Add a milestone like 1M and track your progress each month.") + .font(.subheadline) + .foregroundColor(.secondary) + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } +} + +#Preview { + DashboardView() + .environmentObject(IAPService()) + .environmentObject(AccountStore(iapService: IAPService())) +} diff --git a/PortfolioJournal/Views/Dashboard/EvolutionChart.swift b/PortfolioJournal/Views/Dashboard/EvolutionChart.swift new file mode 100644 index 0000000..c869d53 --- /dev/null +++ b/PortfolioJournal/Views/Dashboard/EvolutionChart.swift @@ -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() +} diff --git a/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift b/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift new file mode 100644 index 0000000..5706207 --- /dev/null +++ b/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift @@ -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())) + } +} diff --git a/PortfolioJournal/Views/Goals/GoalEditorView.swift b/PortfolioJournal/Views/Goals/GoalEditorView.swift new file mode 100644 index 0000000..8be528f --- /dev/null +++ b/PortfolioJournal/Views/Goals/GoalEditorView.swift @@ -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) +} diff --git a/PortfolioJournal/Views/Goals/GoalProgressBar.swift b/PortfolioJournal/Views/Goals/GoalProgressBar.swift new file mode 100644 index 0000000..0ac2c0b --- /dev/null +++ b/PortfolioJournal/Views/Goals/GoalProgressBar.swift @@ -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() +} diff --git a/PortfolioJournal/Views/Goals/GoalShareCardView.swift b/PortfolioJournal/Views/Goals/GoalShareCardView.swift new file mode 100644 index 0000000..f858711 --- /dev/null +++ b/PortfolioJournal/Views/Goals/GoalShareCardView.swift @@ -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 + ) +} diff --git a/PortfolioJournal/Views/Goals/GoalsView.swift b/PortfolioJournal/Views/Goals/GoalsView.swift new file mode 100644 index 0000000..ab710ed --- /dev/null +++ b/PortfolioJournal/Views/Goals/GoalsView.swift @@ -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())) +} diff --git a/PortfolioJournal/Views/Journal/JournalView.swift b/PortfolioJournal/Views/Journal/JournalView.swift new file mode 100644 index 0000000..fd77ae5 --- /dev/null +++ b/PortfolioJournal/Views/Journal/JournalView.swift @@ -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() + } +} diff --git a/InvestmentTracker/Views/Onboarding/OnboardingView.swift b/PortfolioJournal/Views/Onboarding/OnboardingView.swift similarity index 51% rename from InvestmentTracker/Views/Onboarding/OnboardingView.swift rename to PortfolioJournal/Views/Onboarding/OnboardingView.swift index d358928..efb8420 100644 --- a/InvestmentTracker/Views/Onboarding/OnboardingView.swift +++ b/PortfolioJournal/Views/Onboarding/OnboardingView.swift @@ -4,30 +4,36 @@ 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: "Track Your Investments", - description: "Monitor all your investment sources in one place. Stocks, bonds, real estate, crypto, and more.", + title: "Long-Term Tracking", + description: "A calm, offline-first portfolio tracker for investors who update monthly.", color: .appPrimary ), OnboardingPage( - icon: "chart.line.uptrend.xyaxis", - title: "Visualize Your Growth", - description: "Beautiful charts show your portfolio evolution, allocation, and performance over time.", + 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: "Never Miss an Update", - description: "Set reminders to track your investments regularly. Monthly, quarterly, or custom schedules.", + title: "Gentle Reminders", + description: "Get a monthly nudge to review your portfolio without realtime noise.", color: .appWarning ), OnboardingPage( - icon: "icloud.fill", - title: "Sync Everywhere", - description: "Your data syncs automatically via iCloud across all your Apple devices.", + icon: "leaf.fill", + title: "Calm Mode", + description: "Hide short-term swings and focus on contributions and long-term growth.", color: .appSecondary ) ] @@ -40,13 +46,23 @@ struct OnboardingView: View { 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.. 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) { @@ -197,7 +330,9 @@ struct WelcomeView: View { .font(.title.weight(.bold)) } - Text("Your investment data has been synced from iCloud.") + Text(cloudSyncEnabled + ? "Your investment data has been synced from iCloud." + : "Your investment data stays on this device.") .font(.subheadline) .foregroundColor(.secondary) .multilineTextAlignment(.center) @@ -227,4 +362,5 @@ struct WelcomeView: View { #Preview { OnboardingView(onboardingCompleted: .constant(false)) + .environmentObject(AccountStore(iapService: IAPService())) } diff --git a/InvestmentTracker/Views/Premium/PaywallView.swift b/PortfolioJournal/Views/Premium/PaywallView.swift similarity index 94% rename from InvestmentTracker/Views/Premium/PaywallView.swift rename to PortfolioJournal/Views/Premium/PaywallView.swift index a4452c7..c2d1e91 100644 --- a/InvestmentTracker/Views/Premium/PaywallView.swift +++ b/PortfolioJournal/Views/Premium/PaywallView.swift @@ -10,27 +10,31 @@ struct PaywallView: View { var body: some View { NavigationStack { - ScrollView { - VStack(spacing: 24) { - // Header - headerSection + ZStack { + AppBackground() - // Features List - featuresSection + ScrollView { + VStack(spacing: 24) { + // Header + headerSection - // Price Card - priceCard + // Features List + featuresSection - // Purchase Button - purchaseButton + // Price Card + priceCard - // Restore Button - restoreButton + // Purchase Button + purchaseButton - // Legal - legalSection + // Restore Button + restoreButton + + // Legal + legalSection + } + .padding() } - .padding() } .navigationTitle("Upgrade to Premium") .navigationBarTitleDisplayMode(.inline) diff --git a/PortfolioJournal/Views/Security/AppLockView.swift b/PortfolioJournal/Views/Security/AppLockView.swift new file mode 100644 index 0000000..0e6650b --- /dev/null +++ b/PortfolioJournal/Views/Security/AppLockView.swift @@ -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 = "" + } + } +} diff --git a/PortfolioJournal/Views/Settings/AllocationTargetsView.swift b/PortfolioJournal/Views/Settings/AllocationTargetsView.swift new file mode 100644 index 0000000..b6ee895 --- /dev/null +++ b/PortfolioJournal/Views/Settings/AllocationTargetsView.swift @@ -0,0 +1,90 @@ +import SwiftUI + +struct AllocationTargetsView: View { + @StateObject private var categoryRepository = CategoryRepository() + @State private var targetValues: [UUID: String] = [:] + + var body: some View { + List { + Section { + HStack { + Text("Total Targets") + Spacer() + Text(String(format: "%.0f%%", totalTargets)) + .foregroundColor(totalTargets == 100 ? .positiveGreen : .secondary) + } + + if totalTargets != 100 { + Text("Targets don't need to be perfect, but aiming for 100% keeps the drift view accurate.") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Section { + ForEach(categoryRepository.categories) { category in + let categoryId = category.id + HStack { + HStack(spacing: 8) { + Circle() + .fill(category.color) + .frame(width: 10, height: 10) + Text(category.name) + } + + Spacer() + + TextField("0", text: targetBinding(for: categoryId)) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(width: 60) + + Text("%") + .foregroundColor(.secondary) + } + } + } header: { + Text("Targets by Category") + } footer: { + Text("Set your desired allocation percentages to track drift over time.") + } + } + .navigationTitle("Allocation Targets") + .navigationBarTitleDisplayMode(.inline) + .onAppear { + preloadTargets() + } + } + + private var totalTargets: Double { + let ids = categoryRepository.categories.compactMap { $0.id } + return AllocationTargetStore.totalTargetPercentage(for: ids) + } + + private func preloadTargets() { + targetValues = categoryRepository.categories.reduce(into: [:]) { result, category in + let id = category.id + if let target = AllocationTargetStore.target(for: id) { + result[id] = String(format: "%.0f", target) + } + } + } + + private func targetBinding(for categoryId: UUID) -> Binding { + Binding( + get: { targetValues[categoryId] ?? "" }, + set: { newValue in + targetValues[categoryId] = newValue + let sanitized = newValue.replacingOccurrences(of: ",", with: ".") + let value = Double(sanitized) ?? 0 + AllocationTargetStore.setTarget(value, for: categoryId) + } + ) + } +} + +#Preview { + NavigationStack { + AllocationTargetsView() + } +} diff --git a/PortfolioJournal/Views/Settings/ImportDataView.swift b/PortfolioJournal/Views/Settings/ImportDataView.swift new file mode 100644 index 0000000..b9cd1da --- /dev/null +++ b/PortfolioJournal/Views/Settings/ImportDataView.swift @@ -0,0 +1,295 @@ +import SwiftUI +import UniformTypeIdentifiers +import UIKit + +struct ImportDataView: View { + @EnvironmentObject private var iapService: IAPService + @EnvironmentObject private var accountStore: AccountStore + @EnvironmentObject private var tabSelection: TabSelectionStore + @Environment(\.dismiss) private var dismiss + + @State private var selectedFormat: ImportService.ImportFormat = .csv + @State private var showingImporter = false + @State private var resultMessage: String? + @State private var errorMessage: String? + @State private var isImporting = false + @State private var importProgress: Double = 0 + @State private var importStatus = "Preparing import" + + var body: some View { + NavigationStack { + List { + Section { + Picker("Format", selection: $selectedFormat) { + Text("CSV").tag(ImportService.ImportFormat.csv) + Text("JSON").tag(ImportService.ImportFormat.json) + } + .pickerStyle(.segmented) + .disabled(isImporting) + + Button { + showingImporter = true + } label: { + Label("Choose File", systemImage: "doc") + } + .disabled(isImporting) + + Button { + importFromClipboard() + } label: { + Label("Paste from Clipboard", systemImage: "doc.on.clipboard") + } + .disabled(isImporting) + + if isImporting { + VStack(alignment: .leading, spacing: 8) { + ProgressView(value: importProgress) + Text(importStatus) + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.top, 8) + } + } header: { + Text("Import") + } footer: { + Text("Your data will be merged with existing categories and sources.") + } + + Section { + if selectedFormat == .csv { + csvDocs + } else { + jsonDocs + } + } header: { + Text("Format Guide") + } footer: { + Text(iapService.isPremium ? "Accounts are imported as provided." : "Free users import into the Personal account.") + } + + Section { + Button { + shareSampleFile() + } label: { + Label("Share Sample \(selectedFormat == .csv ? "CSV" : "JSON")", systemImage: "square.and.arrow.up") + } + } footer: { + Text("Use this sample file to email yourself a template.") + } + } + .navigationTitle("Import Data") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { dismiss() } + .disabled(isImporting) + } + } + .fileImporter( + isPresented: $showingImporter, + allowedContentTypes: selectedFormat == .csv + ? [.commaSeparatedText, .plainText, .text] + : [.json, .plainText, .text], + allowsMultipleSelection: false + ) { result in + handleImport(result) + } + .alert( + "Import Complete", + isPresented: Binding( + get: { resultMessage != nil }, + set: { if !$0 { resultMessage = nil } } + ) + ) { + Button("OK") { resultMessage = nil } + } message: { + Text(resultMessage ?? "") + } + .alert( + "Import Error", + isPresented: Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + ) + ) { + Button("OK") { errorMessage = nil } + } message: { + Text(errorMessage ?? "") + } + } + .presentationDetents([.large]) + } + + private var csvDocs: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Headers") + .font(.headline) + Text("Account,Category,Source,Date,Value,Contribution,Notes") + .font(.caption.monospaced()) + + Text("Example") + .font(.headline) + Text(""" +Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term +,Crypto,BTC,01/15/2024 14:30,3200,,Cold storage +""") + .font(.caption.monospaced()) + Text("Account, Contribution, and Notes are optional. Dates accept / or - and 24h/12h time.") + .font(.caption) + .foregroundColor(.secondary) + } + } + + private var jsonDocs: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Top-level keys") + .font(.headline) + Text("version, currency, accounts") + .font(.caption.monospaced()) + + Text("Example") + .font(.headline) + Text(""" +{ + "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 + }] + }] + }] + }] +} +""") + .font(.caption.monospaced()) + } + } + + private func handleImport(_ result: Result<[URL], Error>) { + do { + let urls = try result.get() + guard let url = urls.first else { return } + let content = try readFileContents(from: url) + handleImportContent(content) + } catch { + errorMessage = "Could not read the selected file. \(error.localizedDescription)" + } + } + + private func readFileContents(from url: URL) throws -> String { + let accessing = url.startAccessingSecurityScopedResource() + defer { + if accessing { + url.stopAccessingSecurityScopedResource() + } + } + + var coordinatorError: NSError? + var contentError: NSError? + var content = "" + let coordinator = NSFileCoordinator() + coordinator.coordinate(readingItemAt: url, options: [], error: &coordinatorError) { fileURL in + do { + if let isUbiquitous = try? fileURL.resourceValues(forKeys: [.isUbiquitousItemKey]).isUbiquitousItem, + isUbiquitous { + try? FileManager.default.startDownloadingUbiquitousItem(at: fileURL) + } + let data = try Data(contentsOf: fileURL) + if let decoded = String(data: data, encoding: .utf8) + ?? String(data: data, encoding: .utf16) + ?? String(data: data, encoding: .isoLatin1) { + content = decoded + } else { + throw NSError( + domain: "ImportDataView", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Unsupported text encoding."] + ) + } + } catch { + contentError = error as NSError + } + } + + if let coordinatorError { + throw coordinatorError + } + if let contentError { + throw contentError + } + return content + } + + private func importFromClipboard() { + guard let content = UIPasteboard.general.string, + !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + errorMessage = "Clipboard is empty." + return + } + handleImportContent(content) + } + + private func handleImportContent(_ content: String) { + let allowMultipleAccounts = iapService.isPremium + let defaultAccountName = accountStore.selectedAccount?.name + ?? accountStore.accounts.first?.name + isImporting = true + importProgress = 0 + importStatus = "Parsing file" + + Task { + let importResult = await ImportService.shared.importDataAsync( + content: content, + format: selectedFormat, + allowMultipleAccounts: allowMultipleAccounts, + defaultAccountName: defaultAccountName + ) { progress in + importProgress = progress.fraction + importStatus = progress.message + } + + isImporting = false + + if importResult.errors.isEmpty { + resultMessage = "Imported \(importResult.sourcesCreated) sources and \(importResult.snapshotsCreated) snapshots." + tabSelection.selectedTab = 0 + dismiss() + } else { + errorMessage = importResult.errors.joined(separator: "\n") + } + } + } + + private func shareSampleFile() { + if selectedFormat == .csv { + ShareService.shared.shareTextFile( + content: ImportService.sampleCSV(), + fileName: "investment_tracker_sample.csv" + ) + } else { + ShareService.shared.shareTextFile( + content: ImportService.sampleJSON(), + fileName: "investment_tracker_sample.json" + ) + } + } +} + +#Preview { + ImportDataView() + .environmentObject(IAPService()) + .environmentObject(AccountStore(iapService: IAPService())) +} diff --git a/InvestmentTracker/Views/Settings/SettingsView.swift b/PortfolioJournal/Views/Settings/SettingsView.swift similarity index 51% rename from InvestmentTracker/Views/Settings/SettingsView.swift rename to PortfolioJournal/Views/Settings/SettingsView.swift index 027da6d..d82117b 100644 --- a/InvestmentTracker/Views/Settings/SettingsView.swift +++ b/PortfolioJournal/Views/Settings/SettingsView.swift @@ -1,8 +1,23 @@ import SwiftUI +import StoreKit struct SettingsView: View { @EnvironmentObject var iapService: IAPService @StateObject private var viewModel: SettingsViewModel + @AppStorage("calmModeEnabled") private var calmModeEnabled = true + @AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = 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 + + @State private var showingPinSetup = false + @State private var showingPinChange = false + @State private var showingBiometricAlert = false + @State private var showingPinRequiredAlert = false + @State private var showingPinDisableAlert = false + @State private var showingRestartAlert = false + @State private var didLoadCloudSync = false init() { _viewModel = StateObject(wrappedValue: SettingsViewModel(iapService: IAPService())) @@ -10,21 +25,39 @@ struct SettingsView: View { var body: some View { NavigationStack { - List { - // Premium Section - premiumSection + ZStack { + AppBackground() - // Notifications Section - notificationsSection + List { + brandSection + // Premium Section + premiumSection - // Data Section - dataSection + // Notifications Section + notificationsSection - // About Section - aboutSection + // Data Section + dataSection - // Danger Zone - dangerZoneSection + // Security Section + securitySection + + // Preferences Section + preferencesSection + + // Long-Term Focus + longTermSection + + // Accounts Section + accountsSection + + // About Section + aboutSection + + // Danger Zone + dangerZoneSection + } + .scrollContentBackground(.hidden) } .navigationTitle("Settings") .sheet(isPresented: $viewModel.showingPaywall) { @@ -33,6 +66,9 @@ struct SettingsView: View { .sheet(isPresented: $viewModel.showingExportOptions) { ExportOptionsSheet(viewModel: viewModel) } + .sheet(isPresented: $viewModel.showingImportSheet) { + ImportDataView() + } .confirmationDialog( "Reset All Data", isPresented: $viewModel.showingResetConfirmation, @@ -58,6 +94,74 @@ struct SettingsView: View { } message: { Text(viewModel.errorMessage ?? "") } + .alert("Restart Required", isPresented: $showingRestartAlert) { + Button("OK") {} + } message: { + Text("Restart the app to apply iCloud sync changes.") + } + .alert("Face ID Unavailable", isPresented: $showingBiometricAlert) { + Button("OK") {} + } message: { + Text("Face ID isn't available on this device.") + } + .alert("PIN Required", isPresented: $showingPinRequiredAlert) { + Button("OK") {} + } message: { + Text("Enable a PIN before turning on Face ID.") + } + .alert("Disable Face ID First", isPresented: $showingPinDisableAlert) { + Button("OK") {} + } message: { + Text("Turn off Face ID before disabling your PIN.") + } + .sheet(isPresented: $showingPinSetup) { + PinSetupView(title: "Set PIN") { pin in + if KeychainService.savePin(pin) { + pinEnabled = true + } else { + pinEnabled = false + } + } + .onDisappear { + if KeychainService.readPin() == nil { + pinEnabled = false + } + } + } + .sheet(isPresented: $showingPinChange) { + PinSetupView(title: "Change PIN") { pin in + _ = KeychainService.savePin(pin) + } + } + .onAppear { + didLoadCloudSync = true + } + } + } + + // MARK: - Brand Section + + private var brandSection: some View { + Section { + HStack(spacing: 12) { + Image("BrandMark") + .resizable() + .scaledToFit() + .frame(width: 36, height: 36) + .padding(6) + .background(Color.appPrimary.opacity(0.08)) + .cornerRadius(10) + + VStack(alignment: .leading, spacing: 2) { + Text(appDisplayName) + .font(.headline) + Text("Long-term portfolio tracker") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + } } } @@ -188,6 +292,13 @@ struct SettingsView: View { private var dataSection: some View { Section { + Toggle("Sync with iCloud", isOn: $cloudSyncEnabled) + .onChange(of: cloudSyncEnabled) { _, _ in + if didLoadCloudSync { + showingRestartAlert = true + } + } + Button { if viewModel.canExport { viewModel.showingExportOptions = true @@ -208,6 +319,15 @@ struct SettingsView: View { } } + Button { + viewModel.showingImportSheet = true + } label: { + HStack { + Label("Import Data", systemImage: "square.and.arrow.down") + Spacer() + } + } + HStack { Text("Total Sources") Spacer() @@ -233,6 +353,133 @@ struct SettingsView: View { } } + // MARK: - Security Section + + private var securitySection: some View { + Section { + Toggle("Require PIN", isOn: $pinEnabled) + .onChange(of: pinEnabled) { _, enabled in + if enabled { + if KeychainService.readPin() == nil { + showingPinSetup = true + } + } else { + if faceIdEnabled { + pinEnabled = true + showingPinDisableAlert = true + } else { + KeychainService.deletePin() + } + } + } + + Toggle("Enable Face ID", isOn: $faceIdEnabled) + .onChange(of: faceIdEnabled) { _, enabled in + if enabled { + guard AppLockService.canUseBiometrics() else { + faceIdEnabled = false + showingBiometricAlert = true + return + } + if !pinEnabled { + faceIdEnabled = false + showingPinRequiredAlert = true + } + } + } + + if pinEnabled { + Button("Change PIN") { + showingPinChange = true + } + } + + if faceIdEnabled || pinEnabled { + Toggle("Lock on App Launch", isOn: $lockOnLaunch) + Toggle("Lock When Backgrounded", isOn: $lockOnBackground) + } + } header: { + Text("App Lock") + } footer: { + Text("Use Face ID or a 4-digit PIN to protect your data.") + } + } + + // MARK: - Preferences Section + + private var preferencesSection: some View { + Section { + Picker("Currency", selection: $viewModel.currencyCode) { + ForEach(CurrencyPicker.commonCodes, id: \.self) { code in + Text(code).tag(code) + } + } + .onChange(of: viewModel.currencyCode) { _, newValue in + viewModel.updateCurrency(newValue) + } + + Picker("Input Mode", selection: $viewModel.inputMode) { + ForEach(InputMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .onChange(of: viewModel.inputMode) { _, newValue in + viewModel.updateInputMode(newValue) + } + } header: { + Text("Preferences") + } footer: { + Text("Currency and input mode apply globally unless overridden per account.") + } + } + + // MARK: - Long-Term Focus Section + + private var longTermSection: some View { + Section { + Toggle("Calm Mode", isOn: $calmModeEnabled) + + NavigationLink { + AllocationTargetsView() + } label: { + HStack { + Label("Allocation Targets", systemImage: "target") + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + } + } + } header: { + Text("Long-Term Focus") + } footer: { + Text("Calm Mode hides short-term noise and advanced charts, keeping the app focused on monthly check-ins.") + } + } + + // MARK: - Accounts Section + + private var accountsSection: some View { + Section { + NavigationLink { + AccountsView() + } label: { + HStack { + Label("Manage Accounts", systemImage: "person.2") + Spacer() + if !viewModel.isPremium { + Text("Premium") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } header: { + Text("Accounts") + } footer: { + Text(viewModel.isPremium ? "Create multiple accounts and switch between them." : "Free users can use one account.") + } + } + // MARK: - About Section private var aboutSection: some View { @@ -312,8 +559,21 @@ struct SettingsView: View { private func requestAppReview() { guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return } - import StoreKit - SKStoreReviewController.requestReview(in: scene) + if #available(iOS 18.0, *) { + AppStore.requestReview(in: scene) + } else { + SKStoreReviewController.requestReview(in: scene) + } + } + + private var appDisplayName: String { + if let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String { + return name + } + if let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String { + return name + } + return "Portfolio Journal" } } @@ -382,7 +642,70 @@ struct ExportOptionsSheet: View { } } +// MARK: - PIN Setup View + +struct PinSetupView: View { + @Environment(\.dismiss) private var dismiss + let title: String + let onSave: (String) -> Void + + @State private var pin = "" + @State private var confirmPin = "" + @State private var errorMessage: String? + + var body: some View { + NavigationStack { + Form { + Section { + SecureField("New PIN", text: $pin) + .keyboardType(.numberPad) + .onChange(of: pin) { _, newValue in + pin = String(newValue.filter(\.isNumber).prefix(4)) + } + + SecureField("Confirm PIN", text: $confirmPin) + .keyboardType(.numberPad) + .onChange(of: confirmPin) { _, newValue in + confirmPin = String(newValue.filter(\.isNumber).prefix(4)) + } + } header: { + Text("4-Digit PIN") + } + + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundColor(.negativeRed) + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { savePin() } + .disabled(pin.count < 4 || confirmPin.count < 4) + } + } + } + .presentationDetents([.medium]) + } + + private func savePin() { + guard pin.count == 4, pin == confirmPin else { + errorMessage = "PINs do not match." + confirmPin = "" + return + } + onSave(pin) + dismiss() + } +} + #Preview { SettingsView() .environmentObject(IAPService()) + .environmentObject(AccountStore(iapService: IAPService())) } diff --git a/InvestmentTracker/Views/Sources/AddSourceView.swift b/PortfolioJournal/Views/Sources/AddSourceView.swift similarity index 73% rename from InvestmentTracker/Views/Sources/AddSourceView.swift rename to PortfolioJournal/Views/Sources/AddSourceView.swift index aa46bfe..713ee47 100644 --- a/InvestmentTracker/Views/Sources/AddSourceView.swift +++ b/PortfolioJournal/Views/Sources/AddSourceView.swift @@ -3,19 +3,31 @@ import SwiftUI struct AddSourceView: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject var iapService: IAPService + @EnvironmentObject var accountStore: AccountStore @State private var name = "" @State private var selectedCategory: Category? - @State private var notificationFrequency: NotificationFrequency = .monthly - @State private var customFrequencyMonths = 1 @State private var initialValue = "" @State private var showingCategoryPicker = false + @State private var selectedAccountId: UUID? @StateObject private var categoryRepository = CategoryRepository() var body: some View { NavigationStack { Form { + if accountStore.accounts.count > 1 { + Section { + Picker("Account", selection: $selectedAccountId) { + ForEach(accountStore.accounts) { account in + Text(account.name).tag(Optional(account.id)) + } + } + } header: { + Text("Account") + } + } + // Source Info Section { TextField("Source Name", text: $name) @@ -54,7 +66,7 @@ struct AddSourceView: View { // Initial Value (Optional) Section { HStack { - Text("€") + Text(currencySymbol) .foregroundColor(.secondary) TextField("0.00", text: $initialValue) @@ -65,27 +77,6 @@ struct AddSourceView: View { } footer: { Text("You can add snapshots later if you prefer.") } - - // Notification Settings - 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("Reminders") - } footer: { - Text("We'll remind you to update this investment based on your selected frequency.") - } } .navigationTitle("Add Source") .navigationBarTitleDisplayMode(.inline) @@ -115,6 +106,9 @@ struct AddSourceView: View { if selectedCategory == nil { selectedCategory = categoryRepository.categories.first } + if selectedAccountId == nil { + selectedAccountId = accountStore.selectedAccount?.id ?? accountStore.accounts.first?.id + } } } } @@ -134,8 +128,7 @@ struct AddSourceView: View { let source = repository.createSource( name: name.trimmingCharacters(in: .whitespaces), category: category, - notificationFrequency: notificationFrequency, - customFrequencyMonths: customFrequencyMonths + account: selectedAccount ) // Add initial snapshot if provided @@ -162,7 +155,7 @@ struct AddSourceView: View { private func parseDecimal(_ string: String) -> Decimal? { let cleaned = string - .replacingOccurrences(of: "€", with: "") + .replacingOccurrences(of: currencySymbol, with: "") .replacingOccurrences(of: ",", with: ".") .trimmingCharacters(in: .whitespaces) @@ -174,6 +167,18 @@ struct AddSourceView: View { return formatter.number(from: cleaned)?.decimalValue } + + private var currencySymbol: String { + if let account = selectedAccount, let code = account.currencyCode { + return CurrencyFormatter.symbol(for: code) + } + return AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol + } + + private var selectedAccount: Account? { + guard let id = selectedAccountId else { return nil } + return accountStore.accounts.first { $0.id == id } + } } // MARK: - Category Picker View @@ -229,13 +234,13 @@ struct CategoryPickerView: View { struct EditSourceView: View { @Environment(\.dismiss) private var dismiss + @EnvironmentObject var accountStore: AccountStore let source: InvestmentSource @State private var name: String @State private var selectedCategory: Category? - @State private var notificationFrequency: NotificationFrequency - @State private var customFrequencyMonths: Int @State private var showingCategoryPicker = false + @State private var selectedAccountId: UUID? @StateObject private var categoryRepository = CategoryRepository() @@ -243,13 +248,24 @@ struct EditSourceView: View { self.source = source _name = State(initialValue: source.name) _selectedCategory = State(initialValue: source.category) - _notificationFrequency = State(initialValue: source.frequency) - _customFrequencyMonths = State(initialValue: Int(source.customFrequencyMonths)) + _selectedAccountId = State(initialValue: source.account?.id) } var body: some View { NavigationStack { Form { + if accountStore.accounts.count > 1 { + Section { + Picker("Account", selection: $selectedAccountId) { + ForEach(accountStore.accounts) { account in + Text(account.name).tag(Optional(account.id)) + } + } + } header: { + Text("Account") + } + } + Section { TextField("Source Name", text: $name) @@ -277,24 +293,6 @@ struct EditSourceView: View { } } } - - 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("Reminders") - } } .navigationTitle("Edit Source") .navigationBarTitleDisplayMode(.inline) @@ -334,8 +332,7 @@ struct EditSourceView: View { source, name: name.trimmingCharacters(in: .whitespaces), category: category, - notificationFrequency: notificationFrequency, - customFrequencyMonths: customFrequencyMonths + account: selectedAccount ) // Reschedule notification @@ -343,6 +340,11 @@ struct EditSourceView: View { dismiss() } + + private var selectedAccount: Account? { + guard let id = selectedAccountId else { return nil } + return accountStore.accounts.first { $0.id == id } + } } // MARK: - Add Snapshot View @@ -350,18 +352,32 @@ struct EditSourceView: View { struct AddSnapshotView: View { @Environment(\.dismiss) private var dismiss let source: InvestmentSource + let snapshot: Snapshot? @StateObject private var viewModel: SnapshotFormViewModel - init(source: InvestmentSource) { + init(source: InvestmentSource, snapshot: Snapshot? = nil) { self.source = source - _viewModel = StateObject(wrappedValue: SnapshotFormViewModel(source: source)) + self.snapshot = snapshot + if let snapshot = snapshot { + _viewModel = StateObject(wrappedValue: SnapshotFormViewModel(source: source, mode: .edit(snapshot))) + } else { + _viewModel = StateObject(wrappedValue: SnapshotFormViewModel(source: source)) + } } var body: some View { NavigationStack { Form { Section { + if snapshot == nil && viewModel.previousValue != nil { + Button { + viewModel.prefillFromPreviousSnapshot() + } label: { + Label("Duplicate Previous Snapshot", systemImage: "doc.on.doc") + } + } + DatePicker( "Date", selection: $viewModel.date, @@ -370,15 +386,15 @@ struct AddSnapshotView: View { ) HStack { - Text("€") + Text(viewModel.currencySymbol) .foregroundColor(.secondary) TextField("Value", text: $viewModel.valueString) .keyboardType(.decimalPad) } - if let previous = viewModel.previousValueString { - Text(previous) + if viewModel.previousValue != nil { + Text(viewModel.previousValueString) .font(.caption) .foregroundColor(.secondary) } @@ -403,22 +419,24 @@ struct AddSnapshotView: View { } } - Section { - Toggle("Include Contribution", isOn: $viewModel.includeContribution) + if viewModel.inputMode == .detailed { + Section { + Toggle("Include Contribution", isOn: $viewModel.includeContribution) - if viewModel.includeContribution { - HStack { - Text("€") - .foregroundColor(.secondary) + if viewModel.includeContribution { + HStack { + Text(viewModel.currencySymbol) + .foregroundColor(.secondary) - TextField("New capital added", text: $viewModel.contributionString) - .keyboardType(.decimalPad) + TextField("New capital added", text: $viewModel.contributionString) + .keyboardType(.decimalPad) + } } + } header: { + Text("Contribution (Optional)") + } footer: { + Text("Track new capital you've added to separate it from investment growth.") } - } header: { - Text("Contribution (Optional)") - } footer: { - Text("Track new capital you've added to separate it from investment growth.") } Section { @@ -452,13 +470,25 @@ struct AddSnapshotView: View { guard let value = viewModel.value else { return } let repository = SnapshotRepository() - repository.createSnapshot( - for: source, - date: viewModel.date, - value: value, - contribution: viewModel.contribution, - notes: viewModel.notes.isEmpty ? nil : viewModel.notes - ) + if let snapshot = snapshot { + repository.updateSnapshot( + snapshot, + date: viewModel.date, + value: value, + contribution: viewModel.contribution, + notes: viewModel.notes.isEmpty ? nil : viewModel.notes, + clearContribution: !viewModel.includeContribution, + clearNotes: viewModel.notes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ) + } else { + repository.createSnapshot( + for: source, + date: viewModel.date, + value: value, + contribution: viewModel.contribution, + notes: viewModel.notes.isEmpty ? nil : viewModel.notes + ) + } // Reschedule notification NotificationService.shared.scheduleReminder(for: source) @@ -473,4 +503,5 @@ struct AddSnapshotView: View { #Preview { AddSourceView() .environmentObject(IAPService()) + .environmentObject(AccountStore(iapService: IAPService())) } diff --git a/PortfolioJournal/Views/Sources/AddTransactionView.swift b/PortfolioJournal/Views/Sources/AddTransactionView.swift new file mode 100644 index 0000000..c216f22 --- /dev/null +++ b/PortfolioJournal/Views/Sources/AddTransactionView.swift @@ -0,0 +1,94 @@ +import SwiftUI + +struct AddTransactionView: View { + @Environment(\.dismiss) private var dismiss + let onSave: (TransactionType, Date, Decimal?, Decimal?, Decimal?, String?) -> Void + + @State private var type: TransactionType = .buy + @State private var date = Date() + @State private var shares = "" + @State private var price = "" + @State private var amount = "" + @State private var notes = "" + + var body: some View { + NavigationStack { + Form { + Section { + Picker("Type", selection: $type) { + ForEach(TransactionType.allCases) { transactionType in + Text(transactionType.displayName).tag(transactionType) + } + } + + DatePicker("Date", selection: $date, displayedComponents: .date) + } header: { + Text("Transaction") + } + + Section { + TextField("Shares", text: $shares) + .keyboardType(.decimalPad) + + TextField("Price per share", text: $price) + .keyboardType(.decimalPad) + + TextField("Total amount", text: $amount) + .keyboardType(.decimalPad) + } header: { + Text("Amounts") + } footer: { + Text("Enter shares and price or just a total amount.") + } + + Section { + TextField("Notes", text: $notes, axis: .vertical) + .lineLimit(2...4) + } header: { + Text("Notes (Optional)") + } + } + .navigationTitle("Add Transaction") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { save() } + .disabled(!isValid) + } + } + } + .presentationDetents([.medium, .large]) + } + + private var isValid: Bool { + parseDecimal(shares) != nil || parseDecimal(amount) != nil + } + + private func save() { + onSave( + type, + date, + parseDecimal(shares), + parseDecimal(price), + parseDecimal(amount), + notes.isEmpty ? nil : notes + ) + dismiss() + } + + 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) + } +} + +#Preview { + AddTransactionView { _, _, _, _, _, _ in } +} + diff --git a/PortfolioJournal/Views/Sources/SourceDetailView.swift b/PortfolioJournal/Views/Sources/SourceDetailView.swift new file mode 100644 index 0000000..d642556 --- /dev/null +++ b/PortfolioJournal/Views/Sources/SourceDetailView.swift @@ -0,0 +1,592 @@ +import SwiftUI +import Charts + +struct SourceDetailView: View { + @StateObject private var viewModel: SourceDetailViewModel + @Environment(\.dismiss) private var dismiss + @AppStorage("calmModeEnabled") private var calmModeEnabled = true + + @State private var showingDeleteConfirmation = false + @State private var editingSnapshot: Snapshot? + + init(source: InvestmentSource, iapService: IAPService) { + _viewModel = StateObject(wrappedValue: SourceDetailViewModel( + source: source, + iapService: iapService + )) + } + + var body: some View { + ZStack { + AppBackground() + + ScrollView { + VStack(spacing: 20) { + // Header Card + headerCard + + // Quick Actions + quickActions + + // Chart + if !viewModel.chartData.isEmpty { + chartSection + } + + // Metrics + if calmModeEnabled { + simpleMetricsSection + } else { + metricsSection + } + + // Transactions + transactionsSection + + // Snapshots List + snapshotsSection + } + .padding() + } + } + .navigationTitle(viewModel.source.name) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Menu { + Button { + viewModel.showingEditSource = true + } label: { + Label("Edit Source", systemImage: "pencil") + } + + Button(role: .destructive) { + showingDeleteConfirmation = true + } label: { + Label("Delete Source", systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + .sheet(isPresented: $viewModel.showingAddSnapshot) { + AddSnapshotView(source: viewModel.source) + } + .sheet(item: $editingSnapshot) { snapshot in + AddSnapshotView(source: viewModel.source, snapshot: snapshot) + } + .sheet(isPresented: $viewModel.showingAddTransaction) { + AddTransactionView { type, date, shares, price, amount, notes in + viewModel.addTransaction( + type: type, + date: date, + shares: shares, + price: price, + amount: amount, + notes: notes + ) + } + } + .sheet(isPresented: $viewModel.showingEditSource) { + EditSourceView(source: viewModel.source) + } + .sheet(isPresented: $viewModel.showingPaywall) { + PaywallView() + } + .confirmationDialog( + "Delete Source", + isPresented: $showingDeleteConfirmation, + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + // Delete and dismiss + let repository = InvestmentSourceRepository() + repository.deleteSource(viewModel.source) + dismiss() + } + } message: { + Text("This will permanently delete \(viewModel.source.name) and all its snapshots.") + } + } + + // MARK: - Header Card + + private var headerCard: some View { + VStack(spacing: 12) { + // Category badge + HStack { + Image(systemName: viewModel.source.category?.icon ?? "questionmark") + Text(viewModel.categoryName) + } + .font(.caption) + .foregroundColor(Color(hex: viewModel.categoryColor) ?? .gray) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background((Color(hex: viewModel.categoryColor) ?? .gray).opacity(0.1)) + .cornerRadius(20) + + // Current value + Text(viewModel.formattedCurrentValue) + .font(.system(size: 36, weight: .bold, design: .rounded)) + + // Return + if calmModeEnabled { + VStack(spacing: 2) { + Text("All-time return") + .font(.caption) + .foregroundColor(.secondary) + Text("\(viewModel.formattedTotalReturn) (\(viewModel.formattedPercentageReturn))") + .font(.subheadline.weight(.medium)) + .foregroundColor(.secondary) + } + } else { + HStack(spacing: 4) { + Image(systemName: viewModel.isPositiveReturn ? "arrow.up.right" : "arrow.down.right") + Text(viewModel.formattedTotalReturn) + Text("(\(viewModel.formattedPercentageReturn))") + } + .font(.subheadline.weight(.medium)) + .foregroundColor(viewModel.isPositiveReturn ? .positiveGreen : .negativeRed) + } + + // Last updated + Text("Last updated: \(viewModel.lastUpdated)") + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } + + // MARK: - Quick Actions + + private var quickActions: some View { + HStack(spacing: 12) { + Button { + viewModel.showingAddSnapshot = true + } label: { + Label("Add Snapshot", systemImage: "plus.circle.fill") + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding() + .background(Color.appPrimary) + .foregroundColor(.white) + .cornerRadius(AppConstants.UI.cornerRadius) + } + + Button { + viewModel.showingAddTransaction = true + } label: { + Label("Add Transaction", systemImage: "arrow.left.arrow.right.circle") + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding() + .background(Color.appSecondary.opacity(0.15)) + .foregroundColor(.appSecondary) + .cornerRadius(AppConstants.UI.cornerRadius) + } + } + } + + // MARK: - Chart Section + + private var chartSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Value History") + .font(.headline) + + Chart { + ForEach(viewModel.chartData, id: \.date) { item in + LineMark( + x: .value("Date", item.date), + y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) + ) + .foregroundStyle(Color.appPrimary) + .interpolationMethod(.catmullRom) + + AreaMark( + x: .value("Date", item.date), + y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) + ) + .foregroundStyle( + LinearGradient( + colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)], + startPoint: .top, + endPoint: .bottom + ) + ) + .interpolationMethod(.catmullRom) + } + + if viewModel.canViewPredictions, !viewModel.predictions.isEmpty { + ForEach(viewModel.predictions, id: \.id) { prediction in + AreaMark( + x: .value("Prediction 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.18)) + + LineMark( + x: .value("Prediction Date", prediction.date), + y: .value("Predicted Value", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue) + ) + .foregroundStyle(Color.appSecondary) + .lineStyle(StrokeStyle(lineWidth: 2, dash: [6, 4])) + } + + if let firstPrediction = viewModel.predictions.first { + PointMark( + x: .value("Forecast Start", firstPrediction.date), + y: .value("Forecast Value", NSDecimalNumber(decimal: firstPrediction.predictedValue).doubleValue) + ) + .symbolSize(30) + .foregroundStyle(Color.appSecondary) + .annotation(position: .topTrailing) { + Text("Forecast") + .font(.caption.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background(Color.appSecondary.opacity(0.15)) + .cornerRadius(8) + } + } + } + } + .chartXAxis { + AxisMarks(values: .stride(by: .month, count: 2)) { value in + AxisValueLabel(format: .dateTime.month(.abbreviated)) + } + } + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisValueLabel { + if let doubleValue = value.as(Double.self) { + Text(Decimal(doubleValue).shortCurrencyString) + .font(.caption) + } + } + } + } + .frame(height: 200) + + if !viewModel.canViewPredictions && viewModel.hasEnoughDataForPredictions { + Button { + viewModel.showingPaywall = true + } label: { + HStack(spacing: 6) { + Image(systemName: "lock.fill") + Text("Unlock predictions on the chart") + } + .font(.caption.weight(.semibold)) + .foregroundColor(.appPrimary) + .padding(.vertical, 6) + .padding(.horizontal, 10) + .background(Color.appPrimary.opacity(0.1)) + .cornerRadius(10) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } + + // MARK: - Metrics Section + + private var metricsSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Performance Metrics") + .font(.headline) + + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()) + ], spacing: 16) { + MetricCard(title: "CAGR", value: viewModel.metrics.formattedCAGR) + MetricCard(title: "Volatility", value: viewModel.metrics.formattedVolatility) + MetricCard(title: "Max Drawdown", value: viewModel.metrics.formattedMaxDrawdown) + MetricCard(title: "Sharpe Ratio", value: viewModel.metrics.formattedSharpeRatio) + MetricCard(title: "Win Rate", value: viewModel.metrics.formattedWinRate) + MetricCard(title: "Avg Monthly", value: viewModel.metrics.formattedAverageMonthlyReturn) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } + + private var simpleMetricsSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Performance Summary") + .font(.headline) + + HStack(spacing: 12) { + MetricChip(title: "CAGR", value: viewModel.metrics.formattedCAGR) + MetricChip(title: "Avg Monthly", value: viewModel.metrics.formattedAverageMonthlyReturn) + MetricChip(title: "Contributions", value: viewModel.source.totalContributions.compactCurrencyString) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } + + // MARK: - Transactions Section + + private var transactionsSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Transactions") + .font(.headline) + + Spacer() + + Text("\(viewModel.transactions.count)") + .font(.subheadline) + .foregroundColor(.secondary) + } + + if viewModel.transactions.isEmpty { + Text("Add buys, sells, dividends, and fees to track cashflows.") + .font(.subheadline) + .foregroundColor(.secondary) + } else { + HStack(spacing: 12) { + MetricChip(title: "Invested", value: viewModel.source.totalInvested.compactCurrencyString) + MetricChip(title: "Dividends", value: viewModel.source.totalDividends.compactCurrencyString) + MetricChip(title: "Fees", value: viewModel.source.totalFees.compactCurrencyString) + } + + ForEach(viewModel.transactions.prefix(5)) { transaction in + TransactionRow(transaction: transaction) + .contextMenu { + Button(role: .destructive) { + viewModel.deleteTransaction(transaction) + } label: { + Label("Delete", systemImage: "trash") + } + } + } + + if viewModel.transactions.count > 5 { + Text("+ \(viewModel.transactions.count - 5) more") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } + + // MARK: - Snapshots Section + + private var snapshotsSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Snapshots") + .font(.headline) + + Spacer() + + Text("\(viewModel.snapshotCount)") + .font(.subheadline) + .foregroundColor(.secondary) + } + + if viewModel.isHistoryLimited { + HStack { + Image(systemName: "info.circle") + .foregroundColor(.appWarning) + Text("\(viewModel.hiddenSnapshotCount) older snapshots hidden. Upgrade for full history.") + .font(.caption) + + Spacer() + + Button("Upgrade") { + viewModel.showingPaywall = true + } + .font(.caption.weight(.semibold)) + } + .padding(8) + .background(Color.appWarning.opacity(0.1)) + .cornerRadius(AppConstants.UI.smallCornerRadius) + } + + ForEach(viewModel.visibleSnapshots) { snapshot in + SnapshotRowView(snapshot: snapshot, onEdit: { + editingSnapshot = snapshot + }) + .contentShape(Rectangle()) + .onTapGesture { + editingSnapshot = snapshot + } + .contextMenu { + Button { + editingSnapshot = snapshot + } label: { + Label("Edit", systemImage: "pencil") + } + } + .swipeActions(edge: .trailing) { + Button { + editingSnapshot = snapshot + } label: { + Label("Edit", systemImage: "pencil") + } + + Button(role: .destructive) { + viewModel.deleteSnapshot(snapshot) + } label: { + Label("Delete", systemImage: "trash") + } + } + + if snapshot.id != viewModel.visibleSnapshots.last?.id { + Divider() + } + } + + if viewModel.snapshots.count > 10 { + Text("+ \(viewModel.snapshots.count - 10) more snapshots") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } +} + +// MARK: - Metric Card + +struct MetricCard: View { + let title: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.caption) + .foregroundColor(.secondary) + + Text(value) + .font(.subheadline.weight(.semibold)) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - Snapshot Row View + +struct SnapshotRowView: View { + let snapshot: Snapshot + let onEdit: (() -> Void)? + + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(snapshot.date.friendlyDescription) + .font(.subheadline) + + if let notes = snapshot.notes, !notes.isEmpty { + Text(notes) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text(snapshot.formattedValue) + .font(.subheadline.weight(.medium)) + + if snapshot.contribution != nil && snapshot.decimalContribution > 0 { + Text("+ \(snapshot.decimalContribution.currencyString)") + .font(.caption) + .foregroundColor(.appPrimary) + } + } + + if let onEdit = onEdit { + Button(action: onEdit) { + Image(systemName: "pencil") + .font(.caption) + .foregroundColor(.secondary) + .padding(6) + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 4) + } +} + +// MARK: - Transaction Row View + +struct TransactionRow: View { + let transaction: Transaction + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(transaction.transactionType.displayName) + .font(.subheadline.weight(.semibold)) + + Text(transaction.date.friendlyDescription) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Text(transaction.decimalAmount.compactCurrencyString) + .font(.subheadline.weight(.semibold)) + .foregroundColor(transaction.transactionType == .fee ? .negativeRed : .primary) + } + .padding(.vertical, 4) + } +} + +struct MetricChip: View { + let title: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.caption) + .foregroundColor(.secondary) + + Text(value) + .font(.caption.weight(.semibold)) + } + .padding(.vertical, 8) + .padding(.horizontal, 10) + .background(Color.gray.opacity(0.1)) + .cornerRadius(10) + } +} + +#Preview { + NavigationStack { + Text("Source Detail Preview") + } +} diff --git a/InvestmentTracker/Views/Sources/SourceListView.swift b/PortfolioJournal/Views/Sources/SourceListView.swift similarity index 70% rename from InvestmentTracker/Views/Sources/SourceListView.swift rename to PortfolioJournal/Views/Sources/SourceListView.swift index 2e7c02d..74ff7cf 100644 --- a/InvestmentTracker/Views/Sources/SourceListView.swift +++ b/PortfolioJournal/Views/Sources/SourceListView.swift @@ -2,20 +2,25 @@ import SwiftUI struct SourceListView: View { @EnvironmentObject var iapService: IAPService + @EnvironmentObject var accountStore: AccountStore @StateObject private var viewModel: SourceListViewModel + @AppStorage("calmModeEnabled") private var calmModeEnabled = true - init() { - // We'll set the iapService in onAppear since we need @EnvironmentObject - _viewModel = StateObject(wrappedValue: SourceListViewModel(iapService: IAPService())) + init(iapService: IAPService) { + _viewModel = StateObject(wrappedValue: SourceListViewModel(iapService: iapService)) } var body: some View { NavigationStack { - Group { - if viewModel.isEmpty { - emptyStateView - } else { - sourcesList + ZStack { + AppBackground() + + Group { + if viewModel.isEmpty { + emptyStateView + } else { + sourcesList + } } } .navigationTitle("Sources") @@ -32,6 +37,10 @@ struct SourceListView: View { ToolbarItem(placement: .navigationBarLeading) { categoryFilterMenu } + + ToolbarItem(placement: .navigationBarLeading) { + accountFilterMenu + } } .sheet(isPresented: $viewModel.showingAddSource) { AddSourceView() @@ -39,6 +48,16 @@ struct SourceListView: View { .sheet(isPresented: $viewModel.showingPaywall) { PaywallView() } + .onAppear { + viewModel.selectedAccount = accountStore.selectedAccount + viewModel.showAllAccounts = accountStore.showAllAccounts + } + .onReceive(accountStore.$selectedAccount) { account in + viewModel.selectedAccount = account + } + .onReceive(accountStore.$showAllAccounts) { showAll in + viewModel.showAllAccounts = showAll + } } } @@ -97,9 +116,9 @@ struct SourceListView: View { Section { ForEach(viewModel.sources) { source in NavigationLink { - SourceDetailView(source: source) + SourceDetailView(source: source, iapService: iapService) } label: { - SourceRowView(source: source) + SourceRowView(source: source, calmModeEnabled: calmModeEnabled) } } .onDelete { indexSet in @@ -119,6 +138,7 @@ struct SourceListView: View { } } .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) .refreshable { viewModel.loadData() } @@ -133,7 +153,7 @@ struct SourceListView: View { .foregroundColor(.secondary) Text("No Investment Sources") - .font(.title2.weight(.semibold)) + .font(.custom("Avenir Next", size: 24).weight(.semibold)) Text("Add your first investment source to start tracking your portfolio.") .font(.subheadline) @@ -196,12 +216,54 @@ struct SourceListView: View { } } } + + // MARK: - Account Filter Menu + + 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: { + HStack(spacing: 4) { + Image(systemName: "person.2") + if !accountStore.showAllAccounts { + Circle() + .fill(Color.appPrimary) + .frame(width: 8, height: 8) + } + } + } + } } // MARK: - Source Row View struct SourceRowView: View { - let source: InvestmentSource + @ObservedObject var source: InvestmentSource + let calmModeEnabled: Bool var body: some View { HStack(spacing: 12) { @@ -241,13 +303,19 @@ struct SourceRowView: View { Text(source.latestValue.compactCurrencyString) .font(.subheadline.weight(.semibold)) - HStack(spacing: 2) { - Image(systemName: source.totalReturn >= 0 ? "arrow.up.right" : "arrow.down.right") - .font(.caption2) + if calmModeEnabled { Text(String(format: "%.1f%%", NSDecimalNumber(decimal: source.totalReturn).doubleValue)) .font(.caption) + .foregroundColor(.secondary) + } else { + HStack(spacing: 2) { + Image(systemName: source.totalReturn >= 0 ? "arrow.up.right" : "arrow.down.right") + .font(.caption2) + Text(String(format: "%.1f%%", NSDecimalNumber(decimal: source.totalReturn).doubleValue)) + .font(.caption) + } + .foregroundColor(source.totalReturn >= 0 ? .positiveGreen : .negativeRed) } - .foregroundColor(source.totalReturn >= 0 ? .positiveGreen : .negativeRed) } } .padding(.vertical, 4) @@ -256,6 +324,7 @@ struct SourceRowView: View { } #Preview { - SourceListView() + SourceListView(iapService: IAPService()) .environmentObject(IAPService()) + .environmentObject(AccountStore(iapService: IAPService())) } diff --git a/InvestmentTrackerWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/PortfolioJournalWidget/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from InvestmentTrackerWidget/Assets.xcassets/AccentColor.colorset/Contents.json rename to PortfolioJournalWidget/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/InvestmentTracker/Assets.xcassets/AppIcon.appiconset/Contents.json b/PortfolioJournalWidget/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from InvestmentTracker/Assets.xcassets/AppIcon.appiconset/Contents.json rename to PortfolioJournalWidget/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/InvestmentTrackerWidget/Assets.xcassets/Contents.json b/PortfolioJournalWidget/Assets.xcassets/Contents.json similarity index 100% rename from InvestmentTrackerWidget/Assets.xcassets/Contents.json rename to PortfolioJournalWidget/Assets.xcassets/Contents.json diff --git a/InvestmentTrackerWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/PortfolioJournalWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json similarity index 100% rename from InvestmentTrackerWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json rename to PortfolioJournalWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json diff --git a/PortfolioJournalWidget/Info.plist b/PortfolioJournalWidget/Info.plist new file mode 100644 index 0000000..0ae0bde --- /dev/null +++ b/PortfolioJournalWidget/Info.plist @@ -0,0 +1,27 @@ + + + + + CFBundleDisplayName + Investment Widget + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/PortfolioJournalWidget/InvestmentWidget.swift b/PortfolioJournalWidget/InvestmentWidget.swift new file mode 100644 index 0000000..bfc38cc --- /dev/null +++ b/PortfolioJournalWidget/InvestmentWidget.swift @@ -0,0 +1,831 @@ +import WidgetKit +import SwiftUI +import CoreData + +private let appGroupIdentifier = "group.com.alexandrevazquez.portfoliojournal" +private let storeFileName = "PortfolioJournal.sqlite" +private let sharedPremiumKey = "premiumUnlocked" + +private func sharedStoreURL() -> URL? { + return FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)? + .appendingPathComponent(storeFileName) +} + +private func createWidgetContainer() -> NSPersistentContainer? { + guard let modelURL = Bundle.main.url(forResource: "PortfolioJournal", withExtension: "momd"), + let model = NSManagedObjectModel(contentsOf: modelURL) else { + return nil + } + + let container = NSPersistentContainer(name: "PortfolioJournal", managedObjectModel: model) + + guard let storeURL = sharedStoreURL(), + FileManager.default.fileExists(atPath: storeURL.path) else { + return nil + } + + let description = NSPersistentStoreDescription(url: storeURL) + description.isReadOnly = true + description.shouldMigrateStoreAutomatically = true + description.shouldInferMappingModelAutomatically = true + + container.persistentStoreDescriptions = [description] + + var loadError: Error? + container.loadPersistentStores { _, error in + loadError = error + } + + if loadError != nil { + return nil + } + + return container +} + +// MARK: - Widget Entry + +struct InvestmentWidgetEntry: TimelineEntry { + let date: Date + let isPremium: Bool + let totalValue: Decimal + let dayChange: Decimal + let dayChangePercentage: Double + let topSources: [(name: String, value: Decimal, color: String)] + let sparklineData: [Decimal] + let categoryEvolution: [CategorySeries] + let categoryTotals: [(name: String, value: Decimal, color: String)] +} + +struct CategorySeries: Identifiable { + let id: String + let name: String + let color: String + let points: [Decimal] + let latestValue: Decimal +} + +// MARK: - Widget Provider + +struct InvestmentWidgetProvider: TimelineProvider { + func placeholder(in context: Context) -> InvestmentWidgetEntry { + InvestmentWidgetEntry( + date: Date(), + isPremium: true, + totalValue: 50000, + dayChange: 250, + dayChangePercentage: 0.5, + topSources: [ + ("Stocks", 30000, "#10B981"), + ("Bonds", 15000, "#3B82F6"), + ("Real Estate", 5000, "#F59E0B") + ], + sparklineData: [45000, 46000, 47000, 48000, 49000, 50000], + categoryEvolution: [ + CategorySeries( + id: "stocks", + name: "Stocks", + color: "#10B981", + points: [20000, 21000, 22000, 23000, 24000, 25000, 26000], + latestValue: 26000 + ), + CategorySeries( + id: "bonds", + name: "Bonds", + color: "#3B82F6", + points: [12000, 12000, 12500, 13000, 13500, 14000, 14500], + latestValue: 14500 + ), + CategorySeries( + id: "realestate", + name: "Real Estate", + color: "#F59E0B", + points: [6000, 6200, 6400, 6500, 6600, 6700, 6800], + latestValue: 6800 + ) + ], + categoryTotals: [ + ("Stocks", 26000, "#10B981"), + ("Bonds", 14500, "#3B82F6"), + ("Real Estate", 6800, "#F59E0B") + ] + ) + } + + func getSnapshot(in context: Context, completion: @escaping (InvestmentWidgetEntry) -> Void) { + let entry = fetchData() + completion(entry) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entry = fetchData() + + // Refresh every hour + let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: Date()) ?? Date() + let timeline = Timeline(entries: [entry], policy: .after(nextUpdate)) + + completion(timeline) + } + + private func fetchData() -> InvestmentWidgetEntry { + let isPremium = UserDefaults(suiteName: appGroupIdentifier)?.bool(forKey: sharedPremiumKey) ?? false + + guard let container = createWidgetContainer() else { + return InvestmentWidgetEntry( + date: Date(), + isPremium: isPremium, + totalValue: 0, + dayChange: 0, + dayChangePercentage: 0, + topSources: [], + sparklineData: [], + categoryEvolution: [], + categoryTotals: [] + ) + } + + let context = container.viewContext + + func decimalValue(from object: NSManagedObject, key: String) -> Decimal { + if let number = object.value(forKey: key) as? NSDecimalNumber { + return number.decimalValue + } + if let dbl = object.value(forKey: key) as? Double { + return Decimal(dbl) + } + if let num = object.value(forKey: key) as? NSNumber { + return Decimal(num.doubleValue) + } + return .zero + } + + // Build daily totals from snapshots for change/sparkline and category evolution. + let snapshotRequest = NSFetchRequest(entityName: "Snapshot") + snapshotRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: true)] + let snapshots = (try? context.fetch(snapshotRequest)) ?? [] + + var dailyTotals: [Date: Decimal] = [:] + var latestBySource: [NSManagedObjectID: (date: Date, value: Decimal, source: NSManagedObject)] = [:] + var categoryDailyTotals: [String: [Date: Decimal]] = [:] + var categoryMeta: [String: (name: String, color: String)] = [:] + let calendar = Calendar.current + for snapshot in snapshots { + guard let rawDate = snapshot.value(forKey: "date") as? Date else { continue } + let day = calendar.startOfDay(for: rawDate) + let value = decimalValue(from: snapshot, key: "value") + dailyTotals[day, default: .zero] += value + + if let source = snapshot.value(forKey: "source") as? NSManagedObject { + let sourceId = source.objectID + if let existing = latestBySource[sourceId] { + if rawDate > existing.date { + latestBySource[sourceId] = (rawDate, value, source) + } + } else { + latestBySource[sourceId] = (rawDate, value, source) + } + + var categoryId = "uncategorized" + var categoryName = "Uncategorized" + var categoryColor = "#94A3B8" + if let category = source.value(forKey: "category") as? NSManagedObject { + categoryId = category.objectID.uriRepresentation().absoluteString + categoryName = (category.value(forKey: "name") as? String) ?? categoryName + if let colorHex = category.value(forKey: "colorHex") as? String, !colorHex.isEmpty { + categoryColor = colorHex + } + } + + categoryMeta[categoryId] = (categoryName, categoryColor) + var dayTotals = categoryDailyTotals[categoryId, default: [:]] + dayTotals[day, default: .zero] += value + categoryDailyTotals[categoryId] = dayTotals + } + } + + let sortedTotals = dailyTotals + .map { ($0.key, $0.value) } + .sorted { $0.0 < $1.0 } + + let sparklineData = Array(sortedTotals.suffix(7).map { $0.1 }) + + let totalValue = latestBySource.values.reduce(Decimal.zero) { $0 + $1.value } + + let mappedSources: [(name: String, value: Decimal, color: String)] = latestBySource.values.map { entry in + let source = entry.source + let name = (source.value(forKey: "name") as? String) ?? "Unknown" + var color = "#3B82F6" + if let category = source.value(forKey: "category") as? NSManagedObject, + let colorHex = category.value(forKey: "colorHex") as? String, + !colorHex.isEmpty { + color = colorHex + } + return (name: name, value: entry.value, color: color) + } + + let topSources = mappedSources + .sorted { $0.value > $1.value } + .prefix(3) + + var categoryTotalsMap: [String: Decimal] = [:] + for entry in latestBySource.values { + let source = entry.source + let categoryId: String = { + if let category = source.value(forKey: "category") as? NSManagedObject { + return category.objectID.uriRepresentation().absoluteString + } + return "uncategorized" + }() + categoryTotalsMap[categoryId, default: .zero] += entry.value + } + + let categoryTotalsData = categoryTotalsMap + .map { key, value in + let meta = categoryMeta[key] ?? ("Uncategorized", "#94A3B8") + return (id: key, name: meta.0, value: value, color: meta.1) + } + .sorted { $0.value > $1.value } + + let evolutionDays = Array(sortedTotals.suffix(7).map { $0.0 }) + let categoryEvolution: [CategorySeries] = categoryTotalsData.prefix(4).map { category in + let dayMap = categoryDailyTotals[category.id] ?? [:] + let points = evolutionDays.map { dayMap[$0] ?? .zero } + return CategorySeries( + id: category.id, + name: category.name, + color: category.color, + points: points, + latestValue: category.value + ) + } + + var dayChange: Decimal = 0 + var dayChangePercentage: Double = 0 + if sortedTotals.count >= 2 { + let last = sortedTotals[sortedTotals.count - 1].1 + let previous = sortedTotals[sortedTotals.count - 2].1 + dayChange = last - previous + if previous != 0 { + dayChangePercentage = NSDecimalNumber(decimal: dayChange / previous).doubleValue * 100 + } + } + + return InvestmentWidgetEntry( + date: Date(), + isPremium: isPremium, + totalValue: totalValue, + dayChange: dayChange, + dayChangePercentage: dayChangePercentage, + topSources: Array(topSources), + sparklineData: sparklineData, + categoryEvolution: categoryEvolution, + categoryTotals: categoryTotalsData.map { (name: $0.name, value: $0.value, color: $0.color) } + ) + } +} + +// MARK: - Small Widget View + +struct SmallWidgetView: View { + let entry: InvestmentWidgetEntry + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Total Value") + .font(.caption) + .foregroundColor(.secondary) + + Text(entry.totalValue.compactCurrencyString) + .font(.title2.weight(.bold)) + .minimumScaleFactor(0.7) + .lineLimit(1) + + VStack(alignment: .leading, spacing: 4) { + Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right") + .font(.caption2) + + Text(entry.dayChange.compactCurrencyString) + .font(.caption.weight(.medium)) + + Text(String(format: "%.1f%% since last", entry.dayChangePercentage)) + .font(.caption2) + .foregroundColor(.secondary) + } + .foregroundColor(entry.dayChange >= 0 ? .green : .red) + + Spacer() + } + .padding() + .containerBackground(.background, for: .widget) + } +} + +// MARK: - Medium Widget View + +struct MediumWidgetView: View { + let entry: InvestmentWidgetEntry + + var body: some View { + HStack(spacing: 16) { + // Left side - Total value + VStack(alignment: .leading, spacing: 8) { + Text("Portfolio") + .font(.caption) + .foregroundColor(.secondary) + + Text(entry.totalValue.compactCurrencyString) + .font(.title.weight(.bold)) + .minimumScaleFactor(0.7) + .lineLimit(1) + + HStack(spacing: 4) { + Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right") + .font(.caption2) + + Text(entry.dayChange.compactCurrencyString) + .font(.caption.weight(.medium)) + + Text("(\(String(format: "%.1f%%", entry.dayChangePercentage)))") + .font(.caption) + .foregroundColor(.secondary) + } + .foregroundColor(entry.dayChange >= 0 ? .green : .red) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 8) { + if entry.isPremium { + if entry.sparklineData.count >= 2 { + SparklineView(data: entry.sparklineData, isPositive: entry.dayChange >= 0) + .frame(height: 48) + } else { + VStack(alignment: .trailing, spacing: 4) { + Text("Add snapshots") + .font(.caption2) + .foregroundColor(.secondary) + Text("to see trend") + .font(.caption.weight(.semibold)) + .foregroundColor(.secondary) + } + } + } else { + VStack(alignment: .trailing, spacing: 4) { + Text("Sparkline") + .font(.caption2) + .foregroundColor(.secondary) + Text("Premium") + .font(.caption.weight(.semibold)) + .foregroundColor(.secondary) + } + } + + // Top sources + VStack(alignment: .trailing, spacing: 6) { + ForEach(entry.topSources, id: \.name) { source in + HStack(spacing: 6) { + Circle() + .fill(Color(hex: source.color) ?? .gray) + .frame(width: 8, height: 8) + + Text(source.name) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + + Text(source.value.shortCurrencyString) + .font(.caption.weight(.medium)) + } + } + } + } + } + .padding() + .containerBackground(.background, for: .widget) + } +} + +// MARK: - Large Widget View + +struct LargeWidgetView: View { + let entry: InvestmentWidgetEntry + private var hasCategoryTrend: Bool { + (entry.categoryEvolution.first?.points.count ?? 0) >= 2 + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 6) { + Text("Portfolio") + .font(.caption) + .foregroundColor(.secondary) + + Text(entry.totalValue.compactCurrencyString) + .font(.title2.weight(.bold)) + .minimumScaleFactor(0.7) + .lineLimit(1) + + HStack(spacing: 4) { + Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right") + .font(.caption2) + + Text(entry.dayChange.compactCurrencyString) + .font(.caption.weight(.medium)) + + Text("(\(String(format: "%.1f%%", entry.dayChangePercentage)))") + .font(.caption2) + .foregroundColor(.secondary) + } + .foregroundColor(entry.dayChange >= 0 ? .green : .red) + } + + Spacer() + + Text("Category Evolution") + .font(.caption.weight(.semibold)) + .foregroundColor(.secondary) + } + + if entry.isPremium { + if hasCategoryTrend { + CategoryEvolutionView(series: entry.categoryEvolution) + .frame(height: 86) + + VStack(alignment: .leading, spacing: 8) { + ForEach(entry.categoryTotals.prefix(4), id: \.name) { category in + HStack { + RoundedRectangle(cornerRadius: 3) + .fill(Color(hex: category.color) ?? .gray) + .frame(width: 10, height: 10) + + Text(category.name) + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + + Text(category.value.shortCurrencyString) + .font(.caption.weight(.medium)) + } + } + } + } else { + VStack(alignment: .leading, spacing: 6) { + Text("Add snapshots") + .font(.caption.weight(.semibold)) + Text("Category evolution appears after updates.") + .font(.caption2) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } else { + VStack(alignment: .leading, spacing: 6) { + Text("Unlock category trends") + .font(.caption.weight(.semibold)) + Text("Premium shows evolution by category.") + .font(.caption2) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + .padding() + .containerBackground(.background, for: .widget) + } +} + +// MARK: - Accessory Circular View (Lock Screen) + +struct AccessoryCircularView: View { + let entry: InvestmentWidgetEntry + + var body: some View { + ZStack { + AccessoryWidgetBackground() + + VStack(spacing: 2) { + Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right") + .font(.caption) + + Text(String(format: "%.1f%%", entry.dayChangePercentage)) + .font(.caption2.weight(.semibold)) + } + } + .containerBackground(.background, for: .widget) + } +} + +// MARK: - Accessory Rectangular View (Lock Screen) + +struct AccessoryRectangularView: View { + let entry: InvestmentWidgetEntry + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text("Portfolio") + .font(.caption2) + .foregroundColor(.secondary) + + Text(entry.totalValue.compactCurrencyString) + .font(.headline) + + HStack(spacing: 4) { + Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right") + .font(.caption2) + + Text(String(format: "%.1f%%", entry.dayChangePercentage)) + .font(.caption2) + } + .foregroundColor(entry.dayChange >= 0 ? .green : .red) + } + .containerBackground(.background, for: .widget) + } +} + +// MARK: - Sparkline + +struct SparklineView: View { + let data: [Decimal] + let isPositive: Bool + + private var points: [CGFloat] { + let doubles = data.map { NSDecimalNumber(decimal: $0).doubleValue } + guard let minV = doubles.min(), let maxV = doubles.max(), minV != maxV else { + return doubles.map { _ in 0.5 } + } + return doubles.map { CGFloat(($0 - minV) / (maxV - minV)) } + } + + var body: some View { + GeometryReader { geo in + let width = geo.size.width + let height = geo.size.height + let step = data.count > 1 ? width / CGFloat(data.count - 1) : width + + Path { path in + guard !points.isEmpty else { return } + for (index, value) in points.enumerated() { + let x = CGFloat(index) * step + let y = height - (CGFloat(value) * height) + if index == 0 { + path.move(to: CGPoint(x: x, y: y)) + } else { + path.addLine(to: CGPoint(x: x, y: y)) + } + } + } + .stroke( + LinearGradient( + colors: [ + (isPositive ? Color.green : Color.red).opacity(0.9), + (isPositive ? Color.green : Color.red).opacity(0.6) + ], + startPoint: .leading, + endPoint: .trailing + ), + style: StrokeStyle(lineWidth: 2, lineJoin: .round) + ) + .shadow(color: Color.black.opacity(0.08), radius: 2, y: 1) + } + } +} + +// MARK: - Category Evolution Chart + +struct CategoryEvolutionView: View { + let series: [CategorySeries] + + private var pointsCount: Int { + series.first?.points.count ?? 0 + } + + var body: some View { + GeometryReader { geo in + let height = geo.size.height + let width = geo.size.width + let columnWidth = pointsCount > 0 ? (width / CGFloat(pointsCount)) : width + + HStack(alignment: .bottom, spacing: 4) { + ForEach(0..> 16) & 0xFF) / 255 + let green = Double((hexNumber >> 8) & 0xFF) / 255 + let blue = Double(hexNumber & 0xFF) / 255 + + self.init(red: red, green: green, blue: blue) + } +} diff --git a/PortfolioJournalWidgetExtension.entitlements b/PortfolioJournalWidgetExtension.entitlements new file mode 100644 index 0000000..72e48d1 --- /dev/null +++ b/PortfolioJournalWidgetExtension.entitlements @@ -0,0 +1,22 @@ + + + + + aps-environment + development + com.apple.developer.icloud-container-identifiers + + iCloud.com.alexandrevazquez.portfoliojournal + + com.apple.developer.icloud-services + + CloudKit + + com.apple.developer.ubiquity-kvstore-identifier + $(TeamIdentifierPrefix)com.alexandrevazquez.portfoliojournal + com.apple.security.application-groups + + group.com.alexandrevazquez.portfoliojournal + + +