Primera build enviada
This commit is contained in:
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -60,15 +60,8 @@ struct ContentView: View {
|
|||||||
isUnlocked = false
|
isUnlocked = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.task {
|
||||||
if coreDataStack.isLoaded {
|
await waitForDataAndResolveOnboarding()
|
||||||
syncOnboardingState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: coreDataStack.isLoaded) { _, loaded in
|
|
||||||
if loaded {
|
|
||||||
syncOnboardingState()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onChange(of: onboardingCompleted) { _, completed in
|
.onChange(of: onboardingCompleted) { _, completed in
|
||||||
resolvedOnboardingCompleted = completed
|
resolvedOnboardingCompleted = completed
|
||||||
@@ -79,12 +72,28 @@ struct ContentView: View {
|
|||||||
coreDataStack.isLoaded && resolvedOnboardingCompleted != nil
|
coreDataStack.isLoaded && resolvedOnboardingCompleted != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func waitForDataAndResolveOnboarding() async {
|
||||||
|
// Wait for Core Data to be loaded
|
||||||
|
while !coreDataStack.isLoaded {
|
||||||
|
try? await Task.sleep(nanoseconds: 50_000_000) // 50ms
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve onboarding state on main thread
|
||||||
|
await MainActor.run {
|
||||||
|
syncOnboardingState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func syncOnboardingState() {
|
private func syncOnboardingState() {
|
||||||
let settings = AppSettings.getOrCreate(in: coreDataStack.viewContext)
|
let settings = AppSettings.getOrCreate(in: coreDataStack.viewContext)
|
||||||
var resolved = settings.onboardingCompleted || onboardingCompleted
|
var resolved = settings.onboardingCompleted || onboardingCompleted
|
||||||
|
|
||||||
|
// If user has existing data, skip onboarding
|
||||||
if !resolved && hasExistingData() {
|
if !resolved && hasExistingData() {
|
||||||
resolved = true
|
resolved = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist the resolved state
|
||||||
if settings.onboardingCompleted != resolved {
|
if settings.onboardingCompleted != resolved {
|
||||||
settings.onboardingCompleted = resolved
|
settings.onboardingCompleted = resolved
|
||||||
CoreDataStack.shared.save()
|
CoreDataStack.shared.save()
|
||||||
@@ -92,6 +101,7 @@ struct ContentView: View {
|
|||||||
if onboardingCompleted != resolved {
|
if onboardingCompleted != resolved {
|
||||||
onboardingCompleted = resolved
|
onboardingCompleted = resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
resolvedOnboardingCompleted = resolved
|
resolvedOnboardingCompleted = resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,39 +131,41 @@ struct ContentView: View {
|
|||||||
private var mainContent: some View {
|
private var mainContent: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
TabView(selection: $tabSelection.selectedTab) {
|
TabView(selection: $tabSelection.selectedTab) {
|
||||||
DashboardView()
|
bannerInsetView(DashboardView())
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Home", systemImage: "house.fill")
|
Label("Home", systemImage: "house.fill")
|
||||||
}
|
}
|
||||||
.tag(0)
|
.tag(0)
|
||||||
|
|
||||||
SourceListView(iapService: iapService)
|
bannerInsetView(SourceListView(iapService: iapService))
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Sources", systemImage: "list.bullet")
|
Label("Sources", systemImage: "list.bullet")
|
||||||
}
|
}
|
||||||
.tag(1)
|
.tag(1)
|
||||||
|
|
||||||
ChartsContainerView(iapService: iapService)
|
bannerInsetView(ChartsContainerView(iapService: iapService))
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Charts", systemImage: "chart.xyaxis.line")
|
Label("Charts", systemImage: "chart.xyaxis.line")
|
||||||
}
|
}
|
||||||
.tag(2)
|
.tag(2)
|
||||||
|
|
||||||
JournalView()
|
bannerInsetView(JournalView())
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Journal", systemImage: "book.closed")
|
Label("Journal", systemImage: "book.closed")
|
||||||
}
|
}
|
||||||
.tag(3)
|
.tag(3)
|
||||||
|
|
||||||
SettingsView(iapService: iapService)
|
bannerInsetView(SettingsView(iapService: iapService))
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Settings", systemImage: "gearshape.fill")
|
Label("Settings", systemImage: "gearshape.fill")
|
||||||
}
|
}
|
||||||
.tag(4)
|
.tag(4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Banner ad at bottom for free users
|
}
|
||||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
|
||||||
|
private func bannerInsetView<Content: View>(_ content: Content) -> some View {
|
||||||
|
content.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
if !iapService.isPremium {
|
if !iapService.isPremium {
|
||||||
BannerAdView()
|
BannerAdView()
|
||||||
.frame(height: AppConstants.UI.bannerAdHeight)
|
.frame(height: AppConstants.UI.bannerAdHeight)
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ struct PortfolioJournalApp: App {
|
|||||||
let coreDataStack = CoreDataStack.shared
|
let coreDataStack = CoreDataStack.shared
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
// Clean up any duplicate objects from previous bugs before initializing stores
|
||||||
|
CoreDataStack.shared.cleanupDuplicateObjects()
|
||||||
|
|
||||||
let iap = IAPService()
|
let iap = IAPService()
|
||||||
_iapService = StateObject(wrappedValue: iap)
|
_iapService = StateObject(wrappedValue: iap)
|
||||||
_adMobService = StateObject(wrappedValue: AdMobService())
|
_adMobService = StateObject(wrappedValue: AdMobService())
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ public class Account: NSManagedObject, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Constants
|
||||||
|
|
||||||
|
extension Account {
|
||||||
|
static let defaultAccountName = "Default"
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
extension Account {
|
extension Account {
|
||||||
@@ -50,5 +56,9 @@ extension Account {
|
|||||||
var currencyCode: String? {
|
var currencyCode: String? {
|
||||||
currency?.isEmpty == false ? currency : nil
|
currency?.isEmpty == false ? currency : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isDefaultAccount: Bool {
|
||||||
|
name == Account.defaultAccountName
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,13 +69,13 @@ extension Category {
|
|||||||
extension Category {
|
extension Category {
|
||||||
static let defaultCategories: [(name: String, colorHex: String, icon: String)] = [
|
static let defaultCategories: [(name: String, colorHex: String, icon: String)] = [
|
||||||
("Stocks", "#10B981", "chart.line.uptrend.xyaxis"),
|
("Stocks", "#10B981", "chart.line.uptrend.xyaxis"),
|
||||||
("Bonds", "#3B82F6", "building.columns.fill"),
|
|
||||||
("Real Estate", "#F59E0B", "house.fill"),
|
|
||||||
("Crypto", "#8B5CF6", "bitcoinsign.circle.fill"),
|
|
||||||
("Cash", "#6B7280", "banknote.fill"),
|
("Cash", "#6B7280", "banknote.fill"),
|
||||||
("ETFs", "#EC4899", "chart.bar.fill"),
|
("Crypto", "#8B5CF6", "bitcoinsign.circle.fill"),
|
||||||
|
("Real Estate", "#F59E0B", "house.fill"),
|
||||||
|
("Bonds", "#3B82F6", "building.columns.fill"),
|
||||||
("Retirement", "#14B8A6", "person.fill"),
|
("Retirement", "#14B8A6", "person.fill"),
|
||||||
("Other", "#64748B", "ellipsis.circle.fill")
|
("Other", "#64748B", "ellipsis.circle.fill"),
|
||||||
|
("ETFs", "#EC4899", "chart.bar.fill")
|
||||||
]
|
]
|
||||||
|
|
||||||
static func createDefaultCategories(in context: NSManagedObjectContext) {
|
static func createDefaultCategories(in context: NSManagedObjectContext) {
|
||||||
|
|||||||
@@ -179,6 +179,55 @@ class CoreDataStack: ObservableObject {
|
|||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Cleanup Duplicates
|
||||||
|
|
||||||
|
/// Removes duplicate objects that have the same UUID, keeping only the oldest one.
|
||||||
|
/// This fixes data corruption from race conditions during object creation.
|
||||||
|
func cleanupDuplicateObjects() {
|
||||||
|
let context = viewContext
|
||||||
|
context.performAndWait {
|
||||||
|
// Clean up duplicate Goals
|
||||||
|
cleanupDuplicates(entityName: "Goal", idKey: "id", context: context)
|
||||||
|
// Clean up duplicate Accounts (already handled in AccountRepository but added here for safety)
|
||||||
|
cleanupDuplicates(entityName: "Account", idKey: "id", context: context)
|
||||||
|
// Clean up duplicate InvestmentSources
|
||||||
|
cleanupDuplicates(entityName: "InvestmentSource", idKey: "id", context: context)
|
||||||
|
// Clean up duplicate Categories
|
||||||
|
cleanupDuplicates(entityName: "Category", idKey: "id", context: context)
|
||||||
|
|
||||||
|
if context.hasChanges {
|
||||||
|
try? context.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanupDuplicates(entityName: String, idKey: String, context: NSManagedObjectContext) {
|
||||||
|
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
|
||||||
|
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
|
||||||
|
|
||||||
|
guard let objects = try? context.fetch(request) else { return }
|
||||||
|
|
||||||
|
var seenIds = Set<UUID>()
|
||||||
|
var objectsToDelete: [NSManagedObject] = []
|
||||||
|
|
||||||
|
for object in objects {
|
||||||
|
guard let objectId = object.value(forKey: idKey) as? UUID else { continue }
|
||||||
|
|
||||||
|
if seenIds.contains(objectId) {
|
||||||
|
objectsToDelete.append(object)
|
||||||
|
} else {
|
||||||
|
seenIds.insert(objectId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !objectsToDelete.isEmpty {
|
||||||
|
print("Cleaning up \(objectsToDelete.count) duplicate \(entityName) objects")
|
||||||
|
for object in objectsToDelete {
|
||||||
|
context.delete(object)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Save Context
|
// MARK: - Save Context
|
||||||
|
|
||||||
func save() {
|
func save() {
|
||||||
@@ -232,12 +281,10 @@ class CoreDataStack: ObservableObject {
|
|||||||
/// Forces SQLite to checkpoint the WAL file, ensuring all changes are written to the main database file.
|
/// 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.
|
/// This is necessary for the widget to see changes, as it opens the database read-only.
|
||||||
private func checkpointWAL() {
|
private func checkpointWAL() {
|
||||||
|
viewContext.performAndWait {
|
||||||
if viewContext.hasChanges {
|
if viewContext.hasChanges {
|
||||||
try? viewContext.save()
|
try? viewContext.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
let coordinator = persistentContainer.persistentStoreCoordinator
|
|
||||||
coordinator.performAndWait {
|
|
||||||
viewContext.refreshAllObjects()
|
viewContext.refreshAllObjects()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,3 +201,49 @@ struct PortfolioSummary {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Portfolio Forecast
|
||||||
|
|
||||||
|
struct PortfolioForecast {
|
||||||
|
let currentValue: Decimal
|
||||||
|
let forecastValue: Decimal
|
||||||
|
let forecastDate: Date
|
||||||
|
let confidenceLower: Decimal
|
||||||
|
let confidenceUpper: Decimal
|
||||||
|
let monthlyGrowthRate: Decimal
|
||||||
|
let annualizedGrowthRate: Decimal
|
||||||
|
|
||||||
|
var formattedForecastValue: String {
|
||||||
|
CurrencyFormatter.format(forecastValue, style: .currency, maximumFractionDigits: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedCurrentValue: String {
|
||||||
|
CurrencyFormatter.format(currentValue, style: .currency, maximumFractionDigits: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedConfidenceRange: String {
|
||||||
|
let lower = CurrencyFormatter.format(confidenceLower, style: .currency, maximumFractionDigits: 0)
|
||||||
|
let upper = CurrencyFormatter.format(confidenceUpper, style: .currency, maximumFractionDigits: 0)
|
||||||
|
return "\(lower) – \(upper)"
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedForecastDate: String {
|
||||||
|
forecastDate.monthYearString
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedGrowthPercentage: String {
|
||||||
|
guard currentValue > 0 else { return "+0%" }
|
||||||
|
let growthPct = ((forecastValue - currentValue) / currentValue) * 100
|
||||||
|
let prefix = growthPct >= 0 ? "+" : ""
|
||||||
|
return String(format: "\(prefix)%.1f%%", NSDecimalNumber(decimal: growthPct).doubleValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedAnnualizedGrowthRate: String {
|
||||||
|
let prefix = annualizedGrowthRate >= 0 ? "+" : ""
|
||||||
|
return String(format: "\(prefix)%.1f%%", NSDecimalNumber(decimal: annualizedGrowthRate * 100).doubleValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPositiveGrowth: Bool {
|
||||||
|
forecastValue >= currentValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import Foundation
|
|||||||
import CoreData
|
import CoreData
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
class AccountRepository: ObservableObject {
|
class AccountRepository: ObservableObject {
|
||||||
private let context: NSManagedObjectContext
|
private let context: NSManagedObjectContext
|
||||||
|
|
||||||
@Published private(set) var accounts: [Account] = []
|
@Published private(set) var accounts: [Account] = []
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
fetchAccounts()
|
fetchAccounts()
|
||||||
@@ -14,40 +17,30 @@ class AccountRepository: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func setupNotificationObserver() {
|
private func setupNotificationObserver() {
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: context)
|
||||||
self,
|
.receive(on: DispatchQueue.main)
|
||||||
selector: #selector(contextDidChange),
|
.sink { [weak self] _ in
|
||||||
name: .NSManagedObjectContextObjectsDidChange,
|
self?.fetchAccounts()
|
||||||
object: context
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
@objc private func contextDidChange(_ notification: Notification) {
|
|
||||||
fetchAccounts()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Fetch
|
// MARK: - Fetch
|
||||||
|
|
||||||
func fetchAccounts() {
|
func fetchAccounts() {
|
||||||
context.perform { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
let request: NSFetchRequest<Account> = Account.fetchRequest()
|
let request: NSFetchRequest<Account> = Account.fetchRequest()
|
||||||
request.sortDescriptors = [
|
request.sortDescriptors = [
|
||||||
NSSortDescriptor(keyPath: \Account.sortOrder, ascending: true),
|
NSSortDescriptor(keyPath: \Account.sortOrder, ascending: true),
|
||||||
NSSortDescriptor(keyPath: \Account.createdAt, ascending: true)
|
NSSortDescriptor(keyPath: \Account.createdAt, ascending: true)
|
||||||
]
|
]
|
||||||
|
// Performance: Add batch size for fetches
|
||||||
|
request.fetchBatchSize = 50
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let fetched = try self.context.fetch(request)
|
accounts = try context.fetch(request)
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.accounts = fetched
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to fetch accounts: \(error)")
|
print("Failed to fetch accounts: \(error)")
|
||||||
DispatchQueue.main.async {
|
accounts = []
|
||||||
self.accounts = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,28 +73,83 @@ class AccountRepository: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func createDefaultAccountIfNeeded() -> Account {
|
func createDefaultAccountIfNeeded() -> Account {
|
||||||
if let existing = accounts.first {
|
// Fetch accounts directly from database to avoid race condition with async fetch
|
||||||
|
let request: NSFetchRequest<Account> = Account.fetchRequest()
|
||||||
|
request.sortDescriptors = [
|
||||||
|
NSSortDescriptor(keyPath: \Account.sortOrder, ascending: true),
|
||||||
|
NSSortDescriptor(keyPath: \Account.createdAt, ascending: true)
|
||||||
|
]
|
||||||
|
let existingAccounts = (try? context.fetch(request)) ?? []
|
||||||
|
|
||||||
|
// Check if Default account already exists
|
||||||
|
if let defaultAccount = existingAccounts.first(where: { $0.isDefaultAccount }) {
|
||||||
|
return defaultAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no Default account but other accounts exist, return first one
|
||||||
|
if let existing = existingAccounts.first {
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No accounts exist, create Default account
|
||||||
let defaultCurrency = AppSettings.getOrCreate(in: context).currency
|
let defaultCurrency = AppSettings.getOrCreate(in: context).currency
|
||||||
let account = createAccount(
|
let account = Account(context: context)
|
||||||
name: "Personal",
|
account.name = Account.defaultAccountName
|
||||||
currency: defaultCurrency,
|
account.currency = defaultCurrency
|
||||||
inputMode: .simple,
|
account.inputMode = InputMode.simple.rawValue
|
||||||
notificationFrequency: .monthly
|
account.notificationFrequency = NotificationFrequency.monthly.rawValue
|
||||||
)
|
account.customFrequencyMonths = 1
|
||||||
|
account.sortOrder = 0
|
||||||
|
|
||||||
// Attach existing sources to the default account
|
// Attach existing sources to the default account
|
||||||
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
let sourceRequest: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||||
if let sources = try? context.fetch(request) {
|
if let sources = try? context.fetch(sourceRequest) {
|
||||||
for source in sources where source.account == nil {
|
for source in sources where source.account == nil {
|
||||||
source.account = account
|
source.account = account
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
save()
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes duplicate Default accounts, keeping only the oldest one.
|
||||||
|
/// Call this once to clean up any duplicates created by the race condition bug.
|
||||||
|
func cleanupDuplicateDefaultAccounts() {
|
||||||
|
let request: NSFetchRequest<Account> = Account.fetchRequest()
|
||||||
|
request.predicate = NSPredicate(format: "name == %@", Account.defaultAccountName)
|
||||||
|
request.sortDescriptors = [NSSortDescriptor(keyPath: \Account.createdAt, ascending: true)]
|
||||||
|
|
||||||
|
guard let defaultAccounts = try? context.fetch(request), defaultAccounts.count > 1 else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the first (oldest) Default account, delete the rest
|
||||||
|
let accountsToDelete = defaultAccounts.dropFirst()
|
||||||
|
for account in accountsToDelete {
|
||||||
|
// Move sources to the kept Default account before deleting
|
||||||
|
if let keptAccount = defaultAccounts.first {
|
||||||
|
for source in account.sourcesArray {
|
||||||
|
source.account = keptAccount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.delete(account)
|
||||||
|
}
|
||||||
|
|
||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
|
|
||||||
return account
|
// MARK: - Validation
|
||||||
|
|
||||||
|
func isNameAvailable(_ name: String, excludingAccountId: UUID? = nil) -> Bool {
|
||||||
|
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let normalized = trimmed.lowercased()
|
||||||
|
return !accounts.contains { account in
|
||||||
|
if let excludeId = excludingAccountId, account.safeId == excludeId {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return account.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Update
|
// MARK: - Update
|
||||||
@@ -134,7 +182,15 @@ class AccountRepository: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Delete
|
// MARK: - Delete
|
||||||
|
|
||||||
|
func canDeleteAccount(_ account: Account) -> Bool {
|
||||||
|
// Cannot delete the Default account
|
||||||
|
guard !account.isDefaultAccount else { return false }
|
||||||
|
// Must keep at least one account
|
||||||
|
return accounts.count > 1
|
||||||
|
}
|
||||||
|
|
||||||
func deleteAccount(_ account: Account) {
|
func deleteAccount(_ account: Account) {
|
||||||
|
guard canDeleteAccount(account) else { return }
|
||||||
context.delete(account)
|
context.delete(account)
|
||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
@@ -145,7 +201,8 @@ class AccountRepository: ObservableObject {
|
|||||||
guard context.hasChanges else { return }
|
guard context.hasChanges else { return }
|
||||||
do {
|
do {
|
||||||
try context.save()
|
try context.save()
|
||||||
fetchAccounts()
|
// Note: Removed redundant fetchAccounts() call - the NotificationCenter observer
|
||||||
|
// in setupNotificationObserver() already handles refetching on context changes
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to save accounts: \(error)")
|
print("Failed to save accounts: \(error)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,53 +2,47 @@ import Foundation
|
|||||||
import CoreData
|
import CoreData
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
class CategoryRepository: ObservableObject {
|
class CategoryRepository: ObservableObject {
|
||||||
private let context: NSManagedObjectContext
|
private let context: NSManagedObjectContext
|
||||||
|
|
||||||
@Published private(set) var categories: [Category] = []
|
@Published private(set) var categories: [Category] = []
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
fetchCategories()
|
fetchCategories()
|
||||||
|
ensureDefaultCategoriesExist()
|
||||||
setupNotificationObserver()
|
setupNotificationObserver()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupNotificationObserver() {
|
private func setupNotificationObserver() {
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: context)
|
||||||
self,
|
.receive(on: DispatchQueue.main)
|
||||||
selector: #selector(contextDidChange),
|
.sink { [weak self] notification in
|
||||||
name: .NSManagedObjectContextObjectsDidChange,
|
guard let self, self.isRelevantChange(notification) else { return }
|
||||||
object: context
|
self.fetchCategories()
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
@objc private func contextDidChange(_ notification: Notification) {
|
|
||||||
guard isRelevantChange(notification) else { return }
|
|
||||||
fetchCategories()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Fetch
|
// MARK: - Fetch
|
||||||
|
|
||||||
func fetchCategories() {
|
func fetchCategories() {
|
||||||
context.perform { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
let request: NSFetchRequest<Category> = Category.fetchRequest()
|
let request: NSFetchRequest<Category> = Category.fetchRequest()
|
||||||
request.sortDescriptors = [
|
request.sortDescriptors = [
|
||||||
NSSortDescriptor(keyPath: \Category.sortOrder, ascending: true),
|
NSSortDescriptor(keyPath: \Category.sortOrder, ascending: true),
|
||||||
NSSortDescriptor(keyPath: \Category.name, ascending: true)
|
NSSortDescriptor(keyPath: \Category.name, ascending: true)
|
||||||
]
|
]
|
||||||
|
// Performance: Add batch size for fetches
|
||||||
|
request.fetchBatchSize = 50
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let fetched = try self.context.fetch(request)
|
categories = try context.fetch(request)
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.categories = fetched
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to fetch categories: \(error)")
|
print("Failed to fetch categories: \(error)")
|
||||||
DispatchQueue.main.async {
|
categories = []
|
||||||
self.categories = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,14 +55,33 @@ class CategoryRepository: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Create
|
// MARK: - Create
|
||||||
|
|
||||||
|
/// Check if a category with the given name already exists (case-insensitive)
|
||||||
|
func categoryExists(name: String) -> Bool {
|
||||||
|
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let normalized = trimmed.lowercased()
|
||||||
|
return categories.contains { $0.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find an existing category by name (case-insensitive)
|
||||||
|
func findCategory(byName name: String) -> Category? {
|
||||||
|
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let normalized = trimmed.lowercased()
|
||||||
|
return categories.first { $0.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized }
|
||||||
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func createCategory(
|
func createCategory(
|
||||||
name: String,
|
name: String,
|
||||||
colorHex: String,
|
colorHex: String,
|
||||||
icon: String
|
icon: String
|
||||||
) -> Category {
|
) -> Category {
|
||||||
|
// Check if category with same name already exists
|
||||||
|
if let existing = findCategory(byName: name) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
let category = Category(context: context)
|
let category = Category(context: context)
|
||||||
category.name = name
|
category.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
category.colorHex = colorHex
|
category.colorHex = colorHex
|
||||||
category.icon = icon
|
category.icon = icon
|
||||||
category.sortOrder = Int16(categories.count)
|
category.sortOrder = Int16(categories.count)
|
||||||
@@ -78,8 +91,29 @@ class CategoryRepository: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func createDefaultCategoriesIfNeeded() {
|
func createDefaultCategoriesIfNeeded() {
|
||||||
guard categories.isEmpty else { return }
|
ensureDefaultCategoriesExist()
|
||||||
Category.createDefaultCategories(in: context)
|
}
|
||||||
|
|
||||||
|
func ensureDefaultCategoriesExist() {
|
||||||
|
var addedCount = 0
|
||||||
|
var nextSortOrder = categories.count
|
||||||
|
|
||||||
|
for categoryData in Category.defaultCategories {
|
||||||
|
if categoryExists(name: categoryData.name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let category = Category(context: context)
|
||||||
|
category.name = categoryData.name
|
||||||
|
category.colorHex = categoryData.colorHex
|
||||||
|
category.icon = categoryData.icon
|
||||||
|
category.sortOrder = Int16(nextSortOrder)
|
||||||
|
nextSortOrder += 1
|
||||||
|
addedCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
guard addedCount > 0 else { return }
|
||||||
|
save()
|
||||||
fetchCategories()
|
fetchCategories()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +182,8 @@ class CategoryRepository: ObservableObject {
|
|||||||
guard context.hasChanges else { return }
|
guard context.hasChanges else { return }
|
||||||
do {
|
do {
|
||||||
try context.save()
|
try context.save()
|
||||||
fetchCategories()
|
// Note: Removed redundant fetchCategories() call - the NotificationCenter observer
|
||||||
|
// in setupNotificationObserver() already handles refetching on context changes
|
||||||
CoreDataStack.shared.refreshWidgetData()
|
CoreDataStack.shared.refreshWidgetData()
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to save context: \(error)")
|
print("Failed to save context: \(error)")
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import Foundation
|
|||||||
import CoreData
|
import CoreData
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
class GoalRepository: ObservableObject {
|
class GoalRepository: ObservableObject {
|
||||||
private let context: NSManagedObjectContext
|
private let context: NSManagedObjectContext
|
||||||
|
|
||||||
@Published private(set) var goals: [Goal] = []
|
@Published private(set) var goals: [Goal] = []
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
fetchGoals()
|
fetchGoals()
|
||||||
@@ -14,23 +17,17 @@ class GoalRepository: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func setupNotificationObserver() {
|
private func setupNotificationObserver() {
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: context)
|
||||||
self,
|
.receive(on: DispatchQueue.main)
|
||||||
selector: #selector(contextDidChange),
|
.sink { [weak self] _ in
|
||||||
name: .NSManagedObjectContextObjectsDidChange,
|
self?.fetchGoals()
|
||||||
object: context
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
@objc private func contextDidChange(_ notification: Notification) {
|
|
||||||
fetchGoals()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Fetch
|
// MARK: - Fetch
|
||||||
|
|
||||||
func fetchGoals(for account: Account? = nil) {
|
func fetchGoals(for account: Account? = nil) {
|
||||||
context.perform { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
let request: NSFetchRequest<Goal> = Goal.fetchRequest()
|
let request: NSFetchRequest<Goal> = Goal.fetchRequest()
|
||||||
if let account = account {
|
if let account = account {
|
||||||
request.predicate = NSPredicate(format: "account == %@", account)
|
request.predicate = NSPredicate(format: "account == %@", account)
|
||||||
@@ -38,23 +35,41 @@ class GoalRepository: ObservableObject {
|
|||||||
request.sortDescriptors = [
|
request.sortDescriptors = [
|
||||||
NSSortDescriptor(keyPath: \Goal.createdAt, ascending: true)
|
NSSortDescriptor(keyPath: \Goal.createdAt, ascending: true)
|
||||||
]
|
]
|
||||||
|
// Performance: Add batch size for fetches
|
||||||
|
request.fetchBatchSize = 50
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let fetched = try self.context.fetch(request)
|
goals = try context.fetch(request)
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.goals = fetched
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to fetch goals: \(error)")
|
print("Failed to fetch goals: \(error)")
|
||||||
DispatchQueue.main.async {
|
goals = []
|
||||||
self.goals = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Create
|
// MARK: - Create
|
||||||
|
|
||||||
|
/// Check if a goal with the given name already exists in the account (case-insensitive)
|
||||||
|
func goalExists(name: String, in account: Account?) -> Bool {
|
||||||
|
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let normalized = trimmed.lowercased()
|
||||||
|
return goals.contains { goal in
|
||||||
|
let nameMatches = goal.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
|
||||||
|
let accountMatches = goal.account?.id == account?.id
|
||||||
|
return nameMatches && accountMatches
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find an existing goal by name (case-insensitive) within the same account
|
||||||
|
func findGoal(byName name: String, in account: Account?) -> Goal? {
|
||||||
|
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let normalized = trimmed.lowercased()
|
||||||
|
return goals.first { goal in
|
||||||
|
let nameMatches = goal.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
|
||||||
|
let accountMatches = goal.account?.id == account?.id
|
||||||
|
return nameMatches && accountMatches
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func createGoal(
|
func createGoal(
|
||||||
name: String,
|
name: String,
|
||||||
@@ -62,8 +77,13 @@ class GoalRepository: ObservableObject {
|
|||||||
targetDate: Date? = nil,
|
targetDate: Date? = nil,
|
||||||
account: Account?
|
account: Account?
|
||||||
) -> Goal {
|
) -> Goal {
|
||||||
|
// Check if goal with same name already exists in this account
|
||||||
|
if let existing = findGoal(byName: name, in: account) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
let goal = Goal(context: context)
|
let goal = Goal(context: context)
|
||||||
goal.name = name
|
goal.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
goal.targetAmount = NSDecimalNumber(decimal: targetAmount)
|
goal.targetAmount = NSDecimalNumber(decimal: targetAmount)
|
||||||
goal.targetDate = targetDate
|
goal.targetDate = targetDate
|
||||||
goal.account = account
|
goal.account = account
|
||||||
@@ -109,7 +129,8 @@ class GoalRepository: ObservableObject {
|
|||||||
guard context.hasChanges else { return }
|
guard context.hasChanges else { return }
|
||||||
do {
|
do {
|
||||||
try context.save()
|
try context.save()
|
||||||
fetchGoals()
|
// Note: Removed redundant fetchGoals() call - the NotificationCenter observer
|
||||||
|
// in setupNotificationObserver() already handles refetching on context changes
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to save goals: \(error)")
|
print("Failed to save goals: \(error)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import Foundation
|
|||||||
import CoreData
|
import CoreData
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
class InvestmentSourceRepository: ObservableObject {
|
class InvestmentSourceRepository: ObservableObject {
|
||||||
private let context: NSManagedObjectContext
|
private let context: NSManagedObjectContext
|
||||||
|
|
||||||
@Published private(set) var sources: [InvestmentSource] = []
|
@Published private(set) var sources: [InvestmentSource] = []
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
fetchSources()
|
fetchSources()
|
||||||
@@ -14,24 +17,18 @@ class InvestmentSourceRepository: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func setupNotificationObserver() {
|
private func setupNotificationObserver() {
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: context)
|
||||||
self,
|
.receive(on: DispatchQueue.main)
|
||||||
selector: #selector(contextDidChange),
|
.sink { [weak self] notification in
|
||||||
name: .NSManagedObjectContextObjectsDidChange,
|
guard let self, self.isRelevantChange(notification) else { return }
|
||||||
object: context
|
self.fetchSources()
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
@objc private func contextDidChange(_ notification: Notification) {
|
|
||||||
guard isRelevantChange(notification) else { return }
|
|
||||||
fetchSources()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Fetch
|
// MARK: - Fetch
|
||||||
|
|
||||||
func fetchSources(account: Account? = nil) {
|
func fetchSources(account: Account? = nil) {
|
||||||
context.perform { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||||
if let account = account {
|
if let account = account {
|
||||||
request.predicate = NSPredicate(format: "account == %@", account)
|
request.predicate = NSPredicate(format: "account == %@", account)
|
||||||
@@ -39,18 +36,14 @@ class InvestmentSourceRepository: ObservableObject {
|
|||||||
request.sortDescriptors = [
|
request.sortDescriptors = [
|
||||||
NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)
|
NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)
|
||||||
]
|
]
|
||||||
|
// Performance: Add batch size for large datasets
|
||||||
|
request.fetchBatchSize = 100
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let fetched = try self.context.fetch(request)
|
sources = try context.fetch(request)
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.sources = fetched
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to fetch sources: \(error)")
|
print("Failed to fetch sources: \(error)")
|
||||||
DispatchQueue.main.async {
|
sources = []
|
||||||
self.sources = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +75,28 @@ class InvestmentSourceRepository: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Create
|
// MARK: - Create
|
||||||
|
|
||||||
|
/// Check if a source with the given name already exists in the account (case-insensitive)
|
||||||
|
func sourceExists(name: String, in account: Account?) -> Bool {
|
||||||
|
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let normalized = trimmed.lowercased()
|
||||||
|
return sources.contains { source in
|
||||||
|
let nameMatches = source.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
|
||||||
|
let accountMatches = source.account?.id == account?.id
|
||||||
|
return nameMatches && accountMatches
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find an existing source by name (case-insensitive) within the same account
|
||||||
|
func findSource(byName name: String, in account: Account?) -> InvestmentSource? {
|
||||||
|
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let normalized = trimmed.lowercased()
|
||||||
|
return sources.first { source in
|
||||||
|
let nameMatches = source.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
|
||||||
|
let accountMatches = source.account?.id == account?.id
|
||||||
|
return nameMatches && accountMatches
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func createSource(
|
func createSource(
|
||||||
name: String,
|
name: String,
|
||||||
@@ -90,8 +105,13 @@ class InvestmentSourceRepository: ObservableObject {
|
|||||||
customFrequencyMonths: Int = 1,
|
customFrequencyMonths: Int = 1,
|
||||||
account: Account? = nil
|
account: Account? = nil
|
||||||
) -> InvestmentSource {
|
) -> InvestmentSource {
|
||||||
|
// Check if source with same name already exists in this account
|
||||||
|
if let existing = findSource(byName: name, in: account) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
let source = InvestmentSource(context: context)
|
let source = InvestmentSource(context: context)
|
||||||
source.name = name
|
source.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
source.category = category
|
source.category = category
|
||||||
source.notificationFrequency = notificationFrequency.rawValue
|
source.notificationFrequency = notificationFrequency.rawValue
|
||||||
source.customFrequencyMonths = Int16(customFrequencyMonths)
|
source.customFrequencyMonths = Int16(customFrequencyMonths)
|
||||||
@@ -188,7 +208,8 @@ class InvestmentSourceRepository: ObservableObject {
|
|||||||
guard context.hasChanges else { return }
|
guard context.hasChanges else { return }
|
||||||
do {
|
do {
|
||||||
try context.save()
|
try context.save()
|
||||||
fetchSources()
|
// Note: Removed redundant fetchSources() call - the NotificationCenter observer
|
||||||
|
// in setupNotificationObserver() already handles refetching on context changes
|
||||||
CoreDataStack.shared.refreshWidgetData()
|
CoreDataStack.shared.refreshWidgetData()
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to save context: \(error)")
|
print("Failed to save context: \(error)")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Foundation
|
|||||||
import CoreData
|
import CoreData
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
class SnapshotRepository: ObservableObject {
|
class SnapshotRepository: ObservableObject {
|
||||||
private let context: NSManagedObjectContext
|
private let context: NSManagedObjectContext
|
||||||
private let cache = NSCache<NSString, NSArray>()
|
private let cache = NSCache<NSString, NSArray>()
|
||||||
@@ -9,6 +10,8 @@ class SnapshotRepository: ObservableObject {
|
|||||||
|
|
||||||
@Published private(set) var snapshots: [Snapshot] = []
|
@Published private(set) var snapshots: [Snapshot] = []
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
// MARK: - Performance: Shared DateFormatter
|
// MARK: - Performance: Shared DateFormatter
|
||||||
private static let monthYearFormatter: DateFormatter = {
|
private static let monthYearFormatter: DateFormatter = {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
@@ -301,16 +304,17 @@ class SnapshotRepository: ObservableObject {
|
|||||||
cacheVersion &+= 1
|
cacheVersion &+= 1
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupNotificationObserver() {
|
/// Clear cache on memory pressure - call from AppDelegate's didReceiveMemoryWarning
|
||||||
NotificationCenter.default.addObserver(
|
func clearCacheOnMemoryPressure() {
|
||||||
self,
|
cache.removeAllObjects()
|
||||||
selector: #selector(contextDidChange),
|
|
||||||
name: .NSManagedObjectContextObjectsDidChange,
|
|
||||||
object: context
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func contextDidChange(_ notification: Notification) {
|
private func setupNotificationObserver() {
|
||||||
invalidateCache()
|
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: context)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.invalidateCache()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,14 @@ class AccountStore: ObservableObject {
|
|||||||
self.accountRepository = accountRepository ?? AccountRepository()
|
self.accountRepository = accountRepository ?? AccountRepository()
|
||||||
self.iapService = iapService
|
self.iapService = iapService
|
||||||
|
|
||||||
self.accountRepository.fetchAccounts()
|
// Clean up any duplicate Default accounts from previous bug
|
||||||
|
self.accountRepository.cleanupDuplicateDefaultAccounts()
|
||||||
|
|
||||||
|
// Create default account if needed (now fetches directly from DB)
|
||||||
let defaultAccount = self.accountRepository.createDefaultAccountIfNeeded()
|
let defaultAccount = self.accountRepository.createDefaultAccountIfNeeded()
|
||||||
|
|
||||||
|
// Trigger async fetch to populate the published accounts array
|
||||||
|
self.accountRepository.fetchAccounts()
|
||||||
accounts = self.accountRepository.accounts
|
accounts = self.accountRepository.accounts
|
||||||
selectedAccount = defaultAccount
|
selectedAccount = defaultAccount
|
||||||
|
|
||||||
@@ -33,9 +39,23 @@ class AccountStore: ObservableObject {
|
|||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] accounts in
|
.sink { [weak self] accounts in
|
||||||
self?.accounts = accounts
|
self?.accounts = accounts
|
||||||
|
if let selected = self?.selectedAccount, selected.isDeleted {
|
||||||
|
self?.selectedAccount = nil
|
||||||
|
}
|
||||||
self?.syncSelectedAccount()
|
self?.syncSelectedAccount()
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: .didResetData)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.accountRepository.fetchAccounts()
|
||||||
|
self.selectedAccount = nil
|
||||||
|
self.showAllAccounts = true
|
||||||
|
self.persistSelection()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadSelection() {
|
private func loadSelection() {
|
||||||
@@ -53,8 +73,8 @@ class AccountStore: ObservableObject {
|
|||||||
|
|
||||||
private func syncSelectedAccount() {
|
private func syncSelectedAccount() {
|
||||||
if showAllAccounts { return }
|
if showAllAccounts { return }
|
||||||
if let selected = selectedAccount,
|
if let selectedId = selectedAccount?.safeId,
|
||||||
accounts.contains(where: { $0.id == selected.id }) {
|
accounts.contains(where: { $0.safeId == selectedId }) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
selectedAccount = accounts.first
|
selectedAccount = accounts.first
|
||||||
@@ -62,6 +82,7 @@ class AccountStore: ObservableObject {
|
|||||||
|
|
||||||
func selectAllAccounts() {
|
func selectAllAccounts() {
|
||||||
showAllAccounts = true
|
showAllAccounts = true
|
||||||
|
selectedAccount = nil
|
||||||
persistSelection()
|
persistSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +96,7 @@ class AccountStore: ObservableObject {
|
|||||||
let context = CoreDataStack.shared.viewContext
|
let context = CoreDataStack.shared.viewContext
|
||||||
let settings = AppSettings.getOrCreate(in: context)
|
let settings = AppSettings.getOrCreate(in: context)
|
||||||
settings.showAllAccounts = showAllAccounts
|
settings.showAllAccounts = showAllAccounts
|
||||||
settings.selectedAccountId = showAllAccounts ? nil : selectedAccount?.id
|
settings.selectedAccountId = showAllAccounts ? nil : selectedAccount?.safeId
|
||||||
CoreDataStack.shared.save()
|
CoreDataStack.shared.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,3 +104,10 @@ class AccountStore: ObservableObject {
|
|||||||
iapService.isPremium || accounts.count < 1
|
iapService.isPremium || accounts.count < 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Account {
|
||||||
|
var safeId: UUID? {
|
||||||
|
guard !isDeleted else { return nil }
|
||||||
|
return value(forKey: "id") as? UUID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,9 +43,15 @@ class CalculationService {
|
|||||||
let monthChange = calculatePeriodChange(sources: sources, from: monthAgo)
|
let monthChange = calculatePeriodChange(sources: sources, from: monthAgo)
|
||||||
let yearChange = calculatePeriodChange(sources: sources, from: yearAgo)
|
let yearChange = calculatePeriodChange(sources: sources, from: yearAgo)
|
||||||
|
|
||||||
let allTimeReturn = totalValue - totalContributions
|
let baselineTotal = sources.reduce(Decimal.zero) { partial, source in
|
||||||
let allTimeReturnPercentage = totalContributions > 0
|
if let firstSnapshot = source.sortedSnapshotsByDateAscending.first {
|
||||||
? NSDecimalNumber(decimal: allTimeReturn / totalContributions).doubleValue * 100
|
return partial + firstSnapshot.decimalValue
|
||||||
|
}
|
||||||
|
return partial + source.latestValue
|
||||||
|
}
|
||||||
|
let allTimeReturn = totalValue - baselineTotal
|
||||||
|
let allTimeReturnPercentage = baselineTotal > 0
|
||||||
|
? NSDecimalNumber(decimal: allTimeReturn / baselineTotal).doubleValue * 100
|
||||||
: 0
|
: 0
|
||||||
|
|
||||||
let lastUpdated = snapshots.map { $0.date }.max()
|
let lastUpdated = snapshots.map { $0.date }.max()
|
||||||
@@ -458,57 +464,36 @@ class CalculationService {
|
|||||||
|
|
||||||
private func buildCategorySeries(from snapshots: [Snapshot]) -> [SeriesPoint] {
|
private func buildCategorySeries(from snapshots: [Snapshot]) -> [SeriesPoint] {
|
||||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||||
let uniqueDates = Array(Set(sortedSnapshots.map { Calendar.current.startOfDay(for: $0.date) }))
|
let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in
|
||||||
.sorted()
|
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
|
||||||
guard !uniqueDates.isEmpty else { return [] }
|
return DateComponents(year: components.year, month: components.month)
|
||||||
|
|
||||||
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] = []
|
var series: [SeriesPoint] = []
|
||||||
|
series.reserveCapacity(groupedByMonth.count)
|
||||||
|
|
||||||
for (index, date) in uniqueDates.enumerated() {
|
for (key, monthSnapshots) in groupedByMonth {
|
||||||
let nextDate = index + 1 < uniqueDates.count
|
var latestBySource: [UUID: Snapshot] = [:]
|
||||||
? uniqueDates[index + 1]
|
var contributions: Decimal = 0
|
||||||
: Date.distantFuture
|
|
||||||
var total: Decimal = 0
|
|
||||||
|
|
||||||
for (sourceId, sourceSnapshots) in snapshotsBySource {
|
for snapshot in monthSnapshots {
|
||||||
var currentIndex = indices[sourceId] ?? 0
|
contributions += snapshot.decimalContribution
|
||||||
var latest: (date: Date, value: Decimal)?
|
guard let sourceId = snapshot.source?.id else { continue }
|
||||||
|
if let existing = latestBySource[sourceId] {
|
||||||
while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextDate {
|
if snapshot.date > existing.date {
|
||||||
latest = sourceSnapshots[currentIndex]
|
latestBySource[sourceId] = snapshot
|
||||||
currentIndex += 1
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
indices[sourceId] = currentIndex
|
latestBySource[sourceId] = snapshot
|
||||||
|
|
||||||
if let latest {
|
|
||||||
total += latest.value
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
series.append(
|
let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue }
|
||||||
SeriesPoint(
|
let date = Calendar.current.date(from: key) ?? Date()
|
||||||
date: date,
|
series.append(SeriesPoint(date: date, value: total, contribution: contributions))
|
||||||
value: total,
|
|
||||||
contribution: contributionsByDate[date] ?? 0
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return series
|
return series.sorted { $0.date < $1.date }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func calculateMonthlyReturns(from series: [SeriesPoint]) -> [InvestmentMetrics.MonthlyReturn] {
|
private func calculateMonthlyReturns(from series: [SeriesPoint]) -> [InvestmentMetrics.MonthlyReturn] {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class ImportService {
|
|||||||
let accountsCreated: Int
|
let accountsCreated: Int
|
||||||
let sourcesCreated: Int
|
let sourcesCreated: Int
|
||||||
let snapshotsCreated: Int
|
let snapshotsCreated: Int
|
||||||
|
let snapshotsUpdated: Int
|
||||||
let errors: [String]
|
let errors: [String]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,11 +386,13 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
|||||||
var accountsCreated = 0
|
var accountsCreated = 0
|
||||||
var sourcesCreated = 0
|
var sourcesCreated = 0
|
||||||
var snapshotsCreated = 0
|
var snapshotsCreated = 0
|
||||||
|
var snapshotsUpdated = 0
|
||||||
var errors: [String] = []
|
var errors: [String] = []
|
||||||
|
|
||||||
var categoryLookup = buildCategoryLookup(from: categoryRepository.categories)
|
let existingCategories = fetchCategories(in: context)
|
||||||
let otherCategory = resolveExistingCategory(named: "Other", lookup: categoryLookup) ??
|
var categoryLookup = buildCategoryLookup(from: existingCategories)
|
||||||
categoryRepository.createCategory(
|
let otherCategory = resolveExistingCategory(named: "Other", lookup: categoryLookup)
|
||||||
|
?? categoryRepository.createCategory(
|
||||||
name: "Other",
|
name: "Other",
|
||||||
colorHex: "#64748B",
|
colorHex: "#64748B",
|
||||||
icon: "ellipsis.circle.fill"
|
icon: "ellipsis.circle.fill"
|
||||||
@@ -398,17 +401,22 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
|||||||
|
|
||||||
var completionDatesByMonth: [String: Date] = [:]
|
var completionDatesByMonth: [String: Date] = [:]
|
||||||
|
|
||||||
|
// Build lookup of existing accounts by fetching directly from database
|
||||||
|
let existingAccountsLookup = fetchAccountsLookup(in: context)
|
||||||
|
|
||||||
for importedAccount in accounts {
|
for importedAccount in accounts {
|
||||||
let existingAccount = accountRepository.accounts.first(where: { $0.name == importedAccount.name })
|
let existingAccount = existingAccountsLookup[importedAccount.name]
|
||||||
let account = existingAccount ?? accountRepository.createAccount(
|
let account: Account
|
||||||
|
if let existing = existingAccount {
|
||||||
|
account = existing
|
||||||
|
} else {
|
||||||
|
account = accountRepository.createAccount(
|
||||||
name: importedAccount.name,
|
name: importedAccount.name,
|
||||||
currency: importedAccount.currency,
|
currency: importedAccount.currency,
|
||||||
inputMode: importedAccount.inputMode,
|
inputMode: importedAccount.inputMode,
|
||||||
notificationFrequency: importedAccount.notificationFrequency,
|
notificationFrequency: importedAccount.notificationFrequency,
|
||||||
customFrequencyMonths: importedAccount.customFrequencyMonths
|
customFrequencyMonths: importedAccount.customFrequencyMonths
|
||||||
)
|
)
|
||||||
|
|
||||||
if existingAccount == nil {
|
|
||||||
accountsCreated += 1
|
accountsCreated += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,8 +439,9 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
|||||||
categoryLookup[normalizedCategoryName(category.name)] = category
|
categoryLookup[normalizedCategoryName(category.name)] = category
|
||||||
|
|
||||||
for importedSource in importedCategory.sources {
|
for importedSource in importedCategory.sources {
|
||||||
|
let accountId = account.safeId
|
||||||
let existingSource = sourceRepository.sources.first(where: {
|
let existingSource = sourceRepository.sources.first(where: {
|
||||||
$0.name == importedSource.name && $0.account?.id == account.id
|
$0.name == importedSource.name && $0.account?.id == accountId
|
||||||
})
|
})
|
||||||
let source = existingSource ?? sourceRepository.createSource(
|
let source = existingSource ?? sourceRepository.createSource(
|
||||||
name: importedSource.name,
|
name: importedSource.name,
|
||||||
@@ -446,6 +455,21 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
|||||||
}
|
}
|
||||||
|
|
||||||
for snapshot in importedSource.snapshots {
|
for snapshot in importedSource.snapshots {
|
||||||
|
// Check if a snapshot with the same date already exists for this source
|
||||||
|
let existingSnapshot = fetchSnapshot(for: source, date: snapshot.date, in: context)
|
||||||
|
|
||||||
|
if let existing = existingSnapshot {
|
||||||
|
// Update existing snapshot instead of creating a duplicate
|
||||||
|
existing.value = NSDecimalNumber(decimal: snapshot.value)
|
||||||
|
if let contribution = snapshot.contribution {
|
||||||
|
existing.contribution = NSDecimalNumber(decimal: contribution)
|
||||||
|
}
|
||||||
|
if let notes = snapshot.notes, !notes.isEmpty {
|
||||||
|
existing.notes = notes
|
||||||
|
}
|
||||||
|
snapshotsUpdated += 1
|
||||||
|
} else {
|
||||||
|
// Create new snapshot
|
||||||
snapshotRepository.createSnapshot(
|
snapshotRepository.createSnapshot(
|
||||||
for: source,
|
for: source,
|
||||||
date: snapshot.date,
|
date: snapshot.date,
|
||||||
@@ -454,6 +478,7 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
|||||||
notes: snapshot.notes
|
notes: snapshot.notes
|
||||||
)
|
)
|
||||||
snapshotsCreated += 1
|
snapshotsCreated += 1
|
||||||
|
}
|
||||||
snapshotProgress?(snapshotsCreated)
|
snapshotProgress?(snapshotsCreated)
|
||||||
|
|
||||||
let monthKey = MonthlyCheckInStore.monthKey(for: snapshot.date)
|
let monthKey = MonthlyCheckInStore.monthKey(for: snapshot.date)
|
||||||
@@ -491,6 +516,7 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
|||||||
accountsCreated: accountsCreated,
|
accountsCreated: accountsCreated,
|
||||||
sourcesCreated: sourcesCreated,
|
sourcesCreated: sourcesCreated,
|
||||||
snapshotsCreated: snapshotsCreated,
|
snapshotsCreated: snapshotsCreated,
|
||||||
|
snapshotsUpdated: snapshotsUpdated,
|
||||||
errors: errors
|
errors: errors
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -516,6 +542,50 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
|||||||
return lookup
|
return lookup
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func fetchCategories(in context: NSManagedObjectContext) -> [Category] {
|
||||||
|
let request: NSFetchRequest<Category> = Category.fetchRequest()
|
||||||
|
request.sortDescriptors = [
|
||||||
|
NSSortDescriptor(keyPath: \Category.sortOrder, ascending: true),
|
||||||
|
NSSortDescriptor(keyPath: \Category.name, ascending: true)
|
||||||
|
]
|
||||||
|
return (try? context.fetch(request)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchAccountsLookup(in context: NSManagedObjectContext) -> [String: Account] {
|
||||||
|
let request: NSFetchRequest<Account> = Account.fetchRequest()
|
||||||
|
request.sortDescriptors = [NSSortDescriptor(keyPath: \Account.createdAt, ascending: true)]
|
||||||
|
let accounts = (try? context.fetch(request)) ?? []
|
||||||
|
var lookup: [String: Account] = [:]
|
||||||
|
for account in accounts {
|
||||||
|
lookup[account.name] = account
|
||||||
|
}
|
||||||
|
return lookup
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch a snapshot for a given source and date (same day)
|
||||||
|
private func fetchSnapshot(
|
||||||
|
for source: InvestmentSource,
|
||||||
|
date: Date,
|
||||||
|
in context: NSManagedObjectContext
|
||||||
|
) -> Snapshot? {
|
||||||
|
let request: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
|
||||||
|
|
||||||
|
// Match snapshots on the same day (ignore time component)
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let startOfDay = calendar.startOfDay(for: date)
|
||||||
|
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
|
||||||
|
|
||||||
|
request.predicate = NSPredicate(
|
||||||
|
format: "source == %@ AND date >= %@ AND date < %@",
|
||||||
|
source,
|
||||||
|
startOfDay as NSDate,
|
||||||
|
endOfDay as NSDate
|
||||||
|
)
|
||||||
|
request.fetchLimit = 1
|
||||||
|
|
||||||
|
return try? context.fetch(request).first
|
||||||
|
}
|
||||||
|
|
||||||
private func canonicalCategoryName(for rawName: String) -> String? {
|
private func canonicalCategoryName(for rawName: String) -> String? {
|
||||||
let normalized = normalizedCategoryName(rawName)
|
let normalized = normalizedCategoryName(rawName)
|
||||||
for mapping in categoryAliasMappings {
|
for mapping in categoryAliasMappings {
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ extension NotificationService {
|
|||||||
|
|
||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
static let openSourceDetail = Notification.Name("openSourceDetail")
|
static let openSourceDetail = Notification.Name("openSourceDetail")
|
||||||
|
static let didResetData = Notification.Name("didResetData")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Background Refresh
|
// MARK: - Background Refresh
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
|
||||||
class SampleDataService {
|
class SampleDataService {
|
||||||
static let shared = SampleDataService()
|
static let shared = SampleDataService()
|
||||||
@@ -20,10 +21,32 @@ class SampleDataService {
|
|||||||
let goalRepository = GoalRepository(context: context)
|
let goalRepository = GoalRepository(context: context)
|
||||||
let transactionRepository = TransactionRepository(context: context)
|
let transactionRepository = TransactionRepository(context: context)
|
||||||
|
|
||||||
let categories = categoryRepository.categories
|
let categories = fetchCategories(in: context)
|
||||||
let stocksCategory = categories.first { $0.name == "Stocks" } ?? categories.first!
|
guard let fallbackCategory = categories.first else { return }
|
||||||
let cryptoCategory = categories.first { $0.name == "Crypto" } ?? categories.first!
|
let stocksCategory = resolveCategory(
|
||||||
let realEstateCategory = categories.first { $0.name == "Real Estate" } ?? categories.first!
|
named: "Stocks",
|
||||||
|
fallback: fallbackCategory,
|
||||||
|
colorHex: "#3B82F6",
|
||||||
|
icon: "chart.line.uptrend.xyaxis",
|
||||||
|
repository: categoryRepository,
|
||||||
|
context: context
|
||||||
|
)
|
||||||
|
let cryptoCategory = resolveCategory(
|
||||||
|
named: "Crypto",
|
||||||
|
fallback: fallbackCategory,
|
||||||
|
colorHex: "#10B981",
|
||||||
|
icon: "bitcoinsign.circle.fill",
|
||||||
|
repository: categoryRepository,
|
||||||
|
context: context
|
||||||
|
)
|
||||||
|
let realEstateCategory = resolveCategory(
|
||||||
|
named: "Real Estate",
|
||||||
|
fallback: fallbackCategory,
|
||||||
|
colorHex: "#F59E0B",
|
||||||
|
icon: "house.fill",
|
||||||
|
repository: categoryRepository,
|
||||||
|
context: context
|
||||||
|
)
|
||||||
|
|
||||||
let stocks = sourceRepository.createSource(
|
let stocks = sourceRepository.createSource(
|
||||||
name: "Index Fund",
|
name: "Index Fund",
|
||||||
@@ -98,4 +121,28 @@ class SampleDataService {
|
|||||||
MonthlyCheckInStore.setNote(note, for: date)
|
MonthlyCheckInStore.setNote(note, for: date)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func fetchCategories(in context: NSManagedObjectContext) -> [Category] {
|
||||||
|
let request: NSFetchRequest<Category> = Category.fetchRequest()
|
||||||
|
request.sortDescriptors = [
|
||||||
|
NSSortDescriptor(keyPath: \Category.sortOrder, ascending: true),
|
||||||
|
NSSortDescriptor(keyPath: \Category.name, ascending: true)
|
||||||
|
]
|
||||||
|
return (try? context.fetch(request)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveCategory(
|
||||||
|
named name: String,
|
||||||
|
fallback: Category,
|
||||||
|
colorHex: String,
|
||||||
|
icon: String,
|
||||||
|
repository: CategoryRepository,
|
||||||
|
context: NSManagedObjectContext
|
||||||
|
) -> Category {
|
||||||
|
if let existing = fetchCategories(in: context)
|
||||||
|
.first(where: { $0.name == name }) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
return repository.createCategory(name: name, colorHex: colorHex, icon: icon)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,6 +174,14 @@ enum MonthlyCheckInStore {
|
|||||||
return achievementStatuses(for: stats)
|
return achievementStatuses(for: stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func clearAll() {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
defaults.removeObject(forKey: notesKey)
|
||||||
|
defaults.removeObject(forKey: completionsKey)
|
||||||
|
defaults.removeObject(forKey: entriesKey)
|
||||||
|
defaults.removeObject(forKey: legacyLastCheckInKey)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
private static func updateEntry(for date: Date, mutate: (inout MonthlyCheckInEntry) -> Void) {
|
private static func updateEntry(for date: Date, mutate: (inout MonthlyCheckInEntry) -> Void) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class ChartsViewModel: ObservableObject {
|
class ChartsViewModel: ObservableObject {
|
||||||
@@ -149,7 +150,6 @@ class ChartsViewModel: ObservableObject {
|
|||||||
private var lastAccountId: UUID?
|
private var lastAccountId: UUID?
|
||||||
private var lastShowAllAccounts: Bool = true
|
private var lastShowAllAccounts: Bool = true
|
||||||
private var cachedSnapshots: [Snapshot]?
|
private var cachedSnapshots: [Snapshot]?
|
||||||
private var cachedSnapshotsBySource: [UUID: [Snapshot]]?
|
|
||||||
private var isUpdateInProgress = false
|
private var isUpdateInProgress = false
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
@@ -186,20 +186,20 @@ class ChartsViewModel: ObservableObject {
|
|||||||
let (chartType, category, timeRange, _) = combined
|
let (chartType, category, timeRange, _) = combined
|
||||||
|
|
||||||
// Performance: Skip update if nothing meaningful changed
|
// Performance: Skip update if nothing meaningful changed
|
||||||
|
let safeSelectedAccountId = self.safeSelectedAccountId
|
||||||
let hasChanges = self.lastChartType != chartType ||
|
let hasChanges = self.lastChartType != chartType ||
|
||||||
self.lastTimeRange != timeRange ||
|
self.lastTimeRange != timeRange ||
|
||||||
self.lastCategoryId != category?.id ||
|
self.lastCategoryId != category?.id ||
|
||||||
self.lastAccountId != self.selectedAccount?.id ||
|
self.lastAccountId != safeSelectedAccountId ||
|
||||||
self.lastShowAllAccounts != showAll
|
self.lastShowAllAccounts != showAll
|
||||||
|
|
||||||
if hasChanges {
|
if hasChanges {
|
||||||
self.lastChartType = chartType
|
self.lastChartType = chartType
|
||||||
self.lastTimeRange = timeRange
|
self.lastTimeRange = timeRange
|
||||||
self.lastCategoryId = category?.id
|
self.lastCategoryId = category?.id
|
||||||
self.lastAccountId = self.selectedAccount?.id
|
self.lastAccountId = safeSelectedAccountId
|
||||||
self.lastShowAllAccounts = showAll
|
self.lastShowAllAccounts = showAll
|
||||||
self.cachedSnapshots = nil // Invalidate cache on meaningful changes
|
self.cachedSnapshots = nil // Invalidate cache on meaningful changes
|
||||||
self.cachedSnapshotsBySource = nil
|
|
||||||
self.updateChartData(chartType: chartType, category: category, timeRange: timeRange)
|
self.updateChartData(chartType: chartType, category: category, timeRange: timeRange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,48 +266,40 @@ class ChartsViewModel: ObservableObject {
|
|||||||
cachedSnapshots = snapshots
|
cachedSnapshots = snapshots
|
||||||
}
|
}
|
||||||
|
|
||||||
// Performance: Cache snapshotsBySource
|
let completedSnapshots = filterSnapshotsForCharts(
|
||||||
let snapshotsBySource: [UUID: [Snapshot]]
|
sources: sources,
|
||||||
if let cached = cachedSnapshotsBySource {
|
snapshots: snapshots
|
||||||
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
|
// Performance: Only calculate data for the selected chart type
|
||||||
switch chartType {
|
switch chartType {
|
||||||
case .evolution:
|
case .evolution:
|
||||||
calculateEvolutionData(from: snapshots)
|
calculateEvolutionData(from: completedSnapshots)
|
||||||
let categoriesForChart = categoriesForStackedChart(
|
let categoriesForChart = categoriesForStackedChart(
|
||||||
sources: sources,
|
sources: sources,
|
||||||
selectedCategory: selectedCategory
|
selectedCategory: selectedCategory
|
||||||
)
|
)
|
||||||
calculateCategoryEvolutionData(from: snapshots, categories: categoriesForChart)
|
calculateCategoryEvolutionData(from: completedSnapshots, categories: categoriesForChart)
|
||||||
case .allocation:
|
case .allocation:
|
||||||
calculateAllocationData(for: sources)
|
calculateAllocationData(for: sources)
|
||||||
case .performance:
|
case .performance:
|
||||||
calculatePerformanceData(for: sources, snapshotsBySource: snapshotsBySource)
|
let completedSnapshotsBySource = groupSnapshotsBySource(completedSnapshots)
|
||||||
|
calculatePerformanceData(for: sources, snapshotsBySource: completedSnapshotsBySource)
|
||||||
case .contributions:
|
case .contributions:
|
||||||
calculateContributionsData(from: snapshots)
|
calculateContributionsData(from: completedSnapshots)
|
||||||
case .rollingReturn:
|
case .rollingReturn:
|
||||||
calculateRollingReturnData(from: snapshots)
|
calculateRollingReturnData(from: completedSnapshots)
|
||||||
case .riskReturn:
|
case .riskReturn:
|
||||||
calculateRiskReturnData(for: sources, snapshotsBySource: snapshotsBySource)
|
let completedSnapshotsBySource = groupSnapshotsBySource(completedSnapshots)
|
||||||
|
calculateRiskReturnData(for: sources, snapshotsBySource: completedSnapshotsBySource)
|
||||||
case .cashflow:
|
case .cashflow:
|
||||||
calculateCashflowData(from: snapshots)
|
calculateCashflowData(from: completedSnapshots)
|
||||||
case .drawdown:
|
case .drawdown:
|
||||||
calculateDrawdownData(from: snapshots)
|
calculateDrawdownData(from: completedSnapshots)
|
||||||
case .volatility:
|
case .volatility:
|
||||||
calculateVolatilityData(from: snapshots)
|
calculateVolatilityData(from: completedSnapshots)
|
||||||
case .prediction:
|
case .prediction:
|
||||||
calculatePredictionData(from: snapshots)
|
calculatePredictionData(from: completedSnapshots)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let selected = selectedCategory,
|
if let selected = selectedCategory,
|
||||||
@@ -336,7 +328,17 @@ class ChartsViewModel: ObservableObject {
|
|||||||
if showAllAccounts || selectedAccount == nil {
|
if showAllAccounts || selectedAccount == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return source.account?.id == selectedAccount?.id
|
guard let selectedId = safeSelectedAccountId else { return true }
|
||||||
|
return source.account?.id == selectedId
|
||||||
|
}
|
||||||
|
|
||||||
|
private var safeSelectedAccountId: UUID? {
|
||||||
|
guard !showAllAccounts,
|
||||||
|
let selected = selectedAccount,
|
||||||
|
!selected.isDeleted else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return selected.id
|
||||||
}
|
}
|
||||||
|
|
||||||
private func categoriesForStackedChart(
|
private func categoriesForStackedChart(
|
||||||
@@ -406,17 +408,34 @@ class ChartsViewModel: ObservableObject {
|
|||||||
|
|
||||||
private func calculateEvolutionData(from snapshots: [Snapshot]) {
|
private func calculateEvolutionData(from snapshots: [Snapshot]) {
|
||||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||||
|
let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in
|
||||||
var dateValues: [Date: Decimal] = [:]
|
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
|
||||||
|
return DateComponents(year: components.year, month: components.month)
|
||||||
for snapshot in sortedSnapshots {
|
|
||||||
let day = Calendar.current.startOfDay(for: snapshot.date)
|
|
||||||
dateValues[day, default: 0] += snapshot.decimalValue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let series = dateValues
|
var series: [(date: Date, value: Decimal)] = []
|
||||||
.map { (date: $0.key, value: $0.value) }
|
series.reserveCapacity(groupedByMonth.count)
|
||||||
.sorted { $0.date < $1.date }
|
|
||||||
|
for (key, monthSnapshots) in groupedByMonth {
|
||||||
|
var latestBySource: [UUID: Snapshot] = [:]
|
||||||
|
for snapshot in monthSnapshots {
|
||||||
|
guard let sourceId = snapshot.source?.id else { continue }
|
||||||
|
if let existing = latestBySource[sourceId] {
|
||||||
|
if snapshot.date > existing.date {
|
||||||
|
latestBySource[sourceId] = snapshot
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
latestBySource[sourceId] = snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue }
|
||||||
|
let date = Calendar.current.date(from: key) ?? Date()
|
||||||
|
series.append((date: date, value: total))
|
||||||
|
}
|
||||||
|
|
||||||
|
series.sort { $0.date < $1.date }
|
||||||
|
|
||||||
evolutionData = downsampleSeries(series, maxPoints: maxChartPoints)
|
evolutionData = downsampleSeries(series, maxPoints: maxChartPoints)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,6 +496,69 @@ class ChartsViewModel: ObservableObject {
|
|||||||
}.sorted { $0.value > $1.value }
|
}.sorted { $0.value > $1.value }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func completedMonthKeys(
|
||||||
|
sources: [InvestmentSource],
|
||||||
|
snapshots: [Snapshot],
|
||||||
|
after cutoff: Date
|
||||||
|
) -> Set<DateComponents> {
|
||||||
|
let sourceIds = Set(sources.compactMap { $0.id })
|
||||||
|
guard !sourceIds.isEmpty else { return [] }
|
||||||
|
|
||||||
|
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> DateComponents in
|
||||||
|
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
|
||||||
|
return DateComponents(year: components.year, month: components.month)
|
||||||
|
}
|
||||||
|
|
||||||
|
var completed: Set<DateComponents> = []
|
||||||
|
completed.reserveCapacity(groupedByMonth.count)
|
||||||
|
|
||||||
|
for (key, monthSnapshots) in groupedByMonth {
|
||||||
|
guard let monthDate = Calendar.current.date(from: key) else { continue }
|
||||||
|
guard monthDate > cutoff else { continue }
|
||||||
|
guard MonthlyCheckInStore.completionDate(for: monthDate) != nil else { continue }
|
||||||
|
let monthSourceIds = Set(monthSnapshots.compactMap { $0.source?.id })
|
||||||
|
if sourceIds.isSubset(of: monthSourceIds) {
|
||||||
|
completed.insert(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return completed
|
||||||
|
}
|
||||||
|
|
||||||
|
private func filterSnapshotsForCharts(
|
||||||
|
sources: [InvestmentSource],
|
||||||
|
snapshots: [Snapshot]
|
||||||
|
) -> [Snapshot] {
|
||||||
|
guard let lastCompleted = MonthlyCheckInStore.latestCompletionDate()?.startOfMonth else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let completedMonthsAfter = completedMonthKeys(
|
||||||
|
sources: sources,
|
||||||
|
snapshots: snapshots,
|
||||||
|
after: lastCompleted
|
||||||
|
)
|
||||||
|
|
||||||
|
return snapshots.filter { snapshot in
|
||||||
|
let monthDate = snapshot.date.startOfMonth
|
||||||
|
if monthDate <= lastCompleted {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
|
||||||
|
let key = DateComponents(year: components.year, month: components.month)
|
||||||
|
return completedMonthsAfter.contains(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func groupSnapshotsBySource(_ snapshots: [Snapshot]) -> [UUID: [Snapshot]] {
|
||||||
|
var grouped: [UUID: [Snapshot]] = [:]
|
||||||
|
for snapshot in snapshots {
|
||||||
|
guard let id = snapshot.source?.id else { continue }
|
||||||
|
grouped[id, default: []].append(snapshot)
|
||||||
|
}
|
||||||
|
return grouped
|
||||||
|
}
|
||||||
|
|
||||||
private func calculatePerformanceData(
|
private func calculatePerformanceData(
|
||||||
for sources: [InvestmentSource],
|
for sources: [InvestmentSource],
|
||||||
snapshotsBySource: [UUID: [Snapshot]]
|
snapshotsBySource: [UUID: [Snapshot]]
|
||||||
@@ -492,11 +574,20 @@ class ChartsViewModel: ObservableObject {
|
|||||||
}.flatMap { $0 }
|
}.flatMap { $0 }
|
||||||
guard snapshots.count >= 2 else { return nil }
|
guard snapshots.count >= 2 else { return nil }
|
||||||
|
|
||||||
let metrics = calculationService.calculateMetrics(for: snapshots)
|
let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots)
|
||||||
|
guard let first = monthlyTotals.first,
|
||||||
|
let last = monthlyTotals.last,
|
||||||
|
first.totalValue > 0 else { return nil }
|
||||||
|
let cagr = calculationService.calculateCAGR(
|
||||||
|
startValue: first.totalValue,
|
||||||
|
endValue: last.totalValue,
|
||||||
|
startDate: first.date,
|
||||||
|
endDate: last.date
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
category: category.name,
|
category: category.name,
|
||||||
cagr: metrics.cagr,
|
cagr: cagr,
|
||||||
color: category.colorHex
|
color: category.colorHex
|
||||||
)
|
)
|
||||||
}.sorted { $0.cagr > $1.cagr }
|
}.sorted { $0.cagr > $1.cagr }
|
||||||
@@ -543,12 +634,23 @@ class ChartsViewModel: ObservableObject {
|
|||||||
let id = source.id
|
let id = source.id
|
||||||
return snapshotsBySource[id]
|
return snapshotsBySource[id]
|
||||||
}.flatMap { $0 }
|
}.flatMap { $0 }
|
||||||
guard snapshots.count >= 3 else { return nil }
|
let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots)
|
||||||
let metrics = calculationService.calculateMetrics(for: snapshots)
|
guard monthlyTotals.count >= 3,
|
||||||
|
let first = monthlyTotals.first,
|
||||||
|
let last = monthlyTotals.last,
|
||||||
|
first.totalValue > 0 else { return nil }
|
||||||
|
let cagr = calculationService.calculateCAGR(
|
||||||
|
startValue: first.totalValue,
|
||||||
|
endValue: last.totalValue,
|
||||||
|
startDate: first.date,
|
||||||
|
endDate: last.date
|
||||||
|
)
|
||||||
|
let monthlyReturns = monthlyReturnSeries(from: monthlyTotals)
|
||||||
|
let volatility = calculationService.calculateVolatility(monthlyReturns: monthlyReturns)
|
||||||
return (
|
return (
|
||||||
category: category.name,
|
category: category.name,
|
||||||
cagr: metrics.cagr,
|
cagr: cagr,
|
||||||
volatility: metrics.volatility,
|
volatility: volatility,
|
||||||
color: category.colorHex
|
color: category.colorHex
|
||||||
)
|
)
|
||||||
}.sorted { $0.cagr > $1.cagr }
|
}.sorted { $0.cagr > $1.cagr }
|
||||||
@@ -670,6 +772,61 @@ class ChartsViewModel: ObservableObject {
|
|||||||
return totals
|
return totals
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func monthlyTotalsByMonthYear(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
|
||||||
|
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||||
|
let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in
|
||||||
|
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
|
||||||
|
return DateComponents(year: components.year, month: components.month)
|
||||||
|
}
|
||||||
|
|
||||||
|
var totals: [(date: Date, totalValue: Decimal)] = []
|
||||||
|
totals.reserveCapacity(groupedByMonth.count)
|
||||||
|
|
||||||
|
for (key, monthSnapshots) in groupedByMonth {
|
||||||
|
var latestBySource: [UUID: Snapshot] = [:]
|
||||||
|
for snapshot in monthSnapshots {
|
||||||
|
guard let sourceId = snapshot.source?.id else { continue }
|
||||||
|
if let existing = latestBySource[sourceId] {
|
||||||
|
if snapshot.date > existing.date {
|
||||||
|
latestBySource[sourceId] = snapshot
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
latestBySource[sourceId] = snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue }
|
||||||
|
let date = Calendar.current.date(from: key) ?? Date()
|
||||||
|
totals.append((date: date, totalValue: total))
|
||||||
|
}
|
||||||
|
|
||||||
|
return totals.sorted { $0.date < $1.date }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func monthlyReturnSeries(
|
||||||
|
from monthlyTotals: [(date: Date, totalValue: Decimal)]
|
||||||
|
) -> [InvestmentMetrics.MonthlyReturn] {
|
||||||
|
guard monthlyTotals.count >= 2 else { return [] }
|
||||||
|
|
||||||
|
var returns: [InvestmentMetrics.MonthlyReturn] = []
|
||||||
|
returns.reserveCapacity(monthlyTotals.count - 1)
|
||||||
|
|
||||||
|
for index in 1..<monthlyTotals.count {
|
||||||
|
let previous = monthlyTotals[index - 1]
|
||||||
|
let current = monthlyTotals[index]
|
||||||
|
guard previous.totalValue > 0 else { continue }
|
||||||
|
let returnPercentage = NSDecimalNumber(
|
||||||
|
decimal: (current.totalValue - previous.totalValue) / previous.totalValue
|
||||||
|
).doubleValue * 100
|
||||||
|
returns.append(InvestmentMetrics.MonthlyReturn(
|
||||||
|
date: current.date,
|
||||||
|
returnPercentage: returnPercentage
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return returns
|
||||||
|
}
|
||||||
|
|
||||||
func updatePredictionTargetDate(_ goals: [Goal]) {
|
func updatePredictionTargetDate(_ goals: [Goal]) {
|
||||||
let futureGoalDates = goals.compactMap { $0.targetDate }.filter { $0 > Date() }
|
let futureGoalDates = goals.compactMap { $0.targetDate }.filter { $0 > Date() }
|
||||||
guard let latestGoalDate = futureGoalDates.max(),
|
guard let latestGoalDate = futureGoalDates.max(),
|
||||||
|
|||||||
@@ -17,20 +17,27 @@ class DashboardViewModel: ObservableObject {
|
|||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
@Published var selectedAccount: Account?
|
@Published var selectedAccount: Account?
|
||||||
@Published var showAllAccounts = true
|
@Published var showAllAccounts = true
|
||||||
|
@Published var showingPaywall = false
|
||||||
|
|
||||||
// MARK: - Chart Data
|
// MARK: - Chart Data
|
||||||
|
|
||||||
@Published var evolutionData: [(date: Date, value: Decimal)] = []
|
@Published var evolutionData: [(date: Date, value: Decimal)] = []
|
||||||
|
|
||||||
|
// MARK: - Portfolio Forecast
|
||||||
|
|
||||||
|
@Published var portfolioForecast: PortfolioForecast?
|
||||||
|
|
||||||
// MARK: - Dependencies
|
// MARK: - Dependencies
|
||||||
|
|
||||||
private let categoryRepository: CategoryRepository
|
private let categoryRepository: CategoryRepository
|
||||||
private let sourceRepository: InvestmentSourceRepository
|
private let sourceRepository: InvestmentSourceRepository
|
||||||
private let snapshotRepository: SnapshotRepository
|
private let snapshotRepository: SnapshotRepository
|
||||||
private let calculationService: CalculationService
|
private let calculationService: CalculationService
|
||||||
|
private let predictionEngine: PredictionEngine
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var isRefreshing = false
|
private var isRefreshing = false
|
||||||
private var refreshQueued = false
|
private var refreshQueued = false
|
||||||
|
private var refreshTask: Task<Void, Never>?
|
||||||
private let maxHistoryMonths = 60
|
private let maxHistoryMonths = 60
|
||||||
|
|
||||||
// MARK: - Performance: Caching
|
// MARK: - Performance: Caching
|
||||||
@@ -51,6 +58,7 @@ class DashboardViewModel: ObservableObject {
|
|||||||
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
|
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
|
||||||
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
|
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
|
||||||
self.calculationService = calculationService ?? .shared
|
self.calculationService = calculationService ?? .shared
|
||||||
|
self.predictionEngine = .shared
|
||||||
|
|
||||||
setupObservers()
|
setupObservers()
|
||||||
loadData()
|
loadData()
|
||||||
@@ -128,9 +136,13 @@ class DashboardViewModel: ObservableObject {
|
|||||||
guard !isRefreshing else { return }
|
guard !isRefreshing else { return }
|
||||||
|
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
Task { [weak self] in
|
|
||||||
|
// Cancel any previous refresh task to avoid stale updates
|
||||||
|
refreshTask?.cancel()
|
||||||
|
|
||||||
|
refreshTask = Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
while self.refreshQueued {
|
while self.refreshQueued && !Task.isCancelled {
|
||||||
self.refreshQueued = false
|
self.refreshQueued = false
|
||||||
await self.refreshAllData()
|
await self.refreshAllData()
|
||||||
}
|
}
|
||||||
@@ -141,6 +153,12 @@ class DashboardViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cancel any ongoing background tasks - call when view disappears
|
||||||
|
func cancelPendingTasks() {
|
||||||
|
refreshTask?.cancel()
|
||||||
|
refreshTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
private func refreshAllData() async {
|
private func refreshAllData() async {
|
||||||
let categories = categoryRepository.categories
|
let categories = categoryRepository.categories
|
||||||
let sources = filteredSources()
|
let sources = filteredSources()
|
||||||
@@ -171,31 +189,40 @@ class DashboardViewModel: ObservableObject {
|
|||||||
let accountFilter = showAllAccounts ? nil : selectedAccount
|
let accountFilter = showAllAccounts ? nil : selectedAccount
|
||||||
sourcesNeedingUpdate = sourceRepository.fetchSourcesNeedingUpdate(for: accountFilter)
|
sourcesNeedingUpdate = sourceRepository.fetchSourcesNeedingUpdate(for: accountFilter)
|
||||||
|
|
||||||
// Calculate evolution data for chart
|
// Calculate evolution data for chart (only completed monthly check-ins)
|
||||||
updateEvolutionData(from: allSnapshots, categories: categories)
|
let completedSnapshots = filterSnapshotsForCharts(
|
||||||
|
sources: sources,
|
||||||
|
snapshots: allSnapshots
|
||||||
|
)
|
||||||
|
updateEvolutionData(from: completedSnapshots, categories: categories)
|
||||||
latestPortfolioChange = calculateLatestChange(from: evolutionData)
|
latestPortfolioChange = calculateLatestChange(from: evolutionData)
|
||||||
|
|
||||||
|
// Calculate portfolio forecast
|
||||||
|
updatePortfolioForecast()
|
||||||
|
|
||||||
// Log screen view
|
// Log screen view
|
||||||
FirebaseService.shared.logScreenView(screenName: "Dashboard")
|
FirebaseService.shared.logScreenView(screenName: "Dashboard")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func filteredSources() -> [InvestmentSource] {
|
private func filteredSources() -> [InvestmentSource] {
|
||||||
|
let safeSelectedAccountId = selectedAccount?.safeId
|
||||||
|
|
||||||
// Performance: Cache filtered sources to avoid repeated filtering
|
// Performance: Cache filtered sources to avoid repeated filtering
|
||||||
let currentHash = sourceRepository.sources.count
|
let currentHash = sourceRepository.sources.count
|
||||||
let accountChanged = lastAccountId != selectedAccount?.id || lastShowAllAccounts != showAllAccounts
|
let accountChanged = lastAccountId != safeSelectedAccountId || lastShowAllAccounts != showAllAccounts
|
||||||
|
|
||||||
if !accountChanged && cachedFilteredSources != nil && cachedSourcesHash == currentHash {
|
if !accountChanged && cachedFilteredSources != nil && cachedSourcesHash == currentHash {
|
||||||
return cachedFilteredSources!
|
return cachedFilteredSources!
|
||||||
}
|
}
|
||||||
|
|
||||||
lastAccountId = selectedAccount?.id
|
lastAccountId = safeSelectedAccountId
|
||||||
lastShowAllAccounts = showAllAccounts
|
lastShowAllAccounts = showAllAccounts
|
||||||
cachedSourcesHash = currentHash
|
cachedSourcesHash = currentHash
|
||||||
|
|
||||||
if showAllAccounts || selectedAccount == nil {
|
if showAllAccounts || safeSelectedAccountId == nil {
|
||||||
cachedFilteredSources = sourceRepository.sources
|
cachedFilteredSources = sourceRepository.sources
|
||||||
} else {
|
} else {
|
||||||
cachedFilteredSources = sourceRepository.sources.filter { $0.account?.id == selectedAccount?.id }
|
cachedFilteredSources = sourceRepository.sources.filter { $0.account?.id == safeSelectedAccountId }
|
||||||
}
|
}
|
||||||
return cachedFilteredSources!
|
return cachedFilteredSources!
|
||||||
}
|
}
|
||||||
@@ -247,80 +274,51 @@ class DashboardViewModel: ObservableObject {
|
|||||||
return EvolutionSummary(evolutionData: [], categorySeries: [], categoryTotals: [:])
|
return EvolutionSummary(evolutionData: [], categorySeries: [], categoryTotals: [:])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Performance: Pre-allocate capacity and use more efficient data structures
|
|
||||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||||
|
let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in
|
||||||
// Use dictionary to deduplicate dates more efficiently
|
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
|
||||||
var uniqueDateSet = Set<Date>()
|
return DateComponents(year: components.year, month: components.month)
|
||||||
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 evolution: [(date: Date, value: Decimal)] = []
|
||||||
var snapshotsBySource: [UUID: [(date: Date, value: Decimal, categoryId: UUID?)]] = [:]
|
evolution.reserveCapacity(groupedByMonth.count)
|
||||||
snapshotsBySource.reserveCapacity(Set(sortedSnapshots.compactMap { $0.source?.id }).count)
|
var series: [(date: Date, valuesByCategory: [UUID: Decimal])] = []
|
||||||
|
series.reserveCapacity(groupedByMonth.count)
|
||||||
var categoryTotals: [UUID: Decimal] = [:]
|
var categoryTotals: [UUID: Decimal] = [:]
|
||||||
|
|
||||||
for snapshot in sortedSnapshots {
|
for (key, monthSnapshots) in groupedByMonth {
|
||||||
|
var latestBySource: [UUID: Snapshot] = [:]
|
||||||
|
for snapshot in monthSnapshots {
|
||||||
guard let sourceId = snapshot.source?.id else { continue }
|
guard let sourceId = snapshot.source?.id else { continue }
|
||||||
let categoryId = snapshot.source?.category?.id
|
if let existing = latestBySource[sourceId] {
|
||||||
snapshotsBySource[sourceId, default: []].append(
|
if snapshot.date > existing.date {
|
||||||
(date: snapshot.date, value: snapshot.decimalValue, categoryId: categoryId)
|
latestBySource[sourceId] = snapshot
|
||||||
)
|
}
|
||||||
if let categoryId {
|
} else {
|
||||||
categoryTotals[categoryId, default: 0] += snapshot.decimalValue
|
latestBySource[sourceId] = snapshot
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 total: Decimal = 0
|
||||||
var valuesByCategory: [UUID: Decimal] = [:]
|
var valuesByCategory: [UUID: Decimal] = [:]
|
||||||
|
|
||||||
for (sourceId, sourceSnapshots) in snapshotsBySource {
|
for snapshot in latestBySource.values {
|
||||||
var currentIndex = indices[sourceId] ?? 0
|
let value = snapshot.decimalValue
|
||||||
|
total += value
|
||||||
while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextDate {
|
if let categoryId = snapshot.source?.category?.id {
|
||||||
let snap = sourceSnapshots[currentIndex]
|
valuesByCategory[categoryId, default: 0] += value
|
||||||
lastValues[sourceId] = (value: snap.value, categoryId: snap.categoryId)
|
categoryTotals[categoryId, default: 0] += value
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let date = Calendar.current.date(from: key) ?? Date()
|
||||||
evolution.append((date: date, value: total))
|
evolution.append((date: date, value: total))
|
||||||
series.append((date: date, valuesByCategory: valuesByCategory))
|
series.append((date: date, valuesByCategory: valuesByCategory))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
evolution.sort { $0.date < $1.date }
|
||||||
|
series.sort { $0.date < $1.date }
|
||||||
|
|
||||||
return EvolutionSummary(
|
return EvolutionSummary(
|
||||||
evolutionData: evolution,
|
evolutionData: evolution,
|
||||||
categorySeries: series,
|
categorySeries: series,
|
||||||
@@ -328,6 +326,60 @@ class DashboardViewModel: ObservableObject {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func completedMonthKeys(
|
||||||
|
sources: [InvestmentSource],
|
||||||
|
snapshots: [Snapshot],
|
||||||
|
after cutoff: Date
|
||||||
|
) -> Set<DateComponents> {
|
||||||
|
let sourceIds = Set(sources.compactMap { $0.id })
|
||||||
|
guard !sourceIds.isEmpty else { return [] }
|
||||||
|
|
||||||
|
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> DateComponents in
|
||||||
|
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
|
||||||
|
return DateComponents(year: components.year, month: components.month)
|
||||||
|
}
|
||||||
|
|
||||||
|
var completed: Set<DateComponents> = []
|
||||||
|
completed.reserveCapacity(groupedByMonth.count)
|
||||||
|
|
||||||
|
for (key, monthSnapshots) in groupedByMonth {
|
||||||
|
guard let monthDate = Calendar.current.date(from: key) else { continue }
|
||||||
|
guard monthDate > cutoff else { continue }
|
||||||
|
guard MonthlyCheckInStore.completionDate(for: monthDate) != nil else { continue }
|
||||||
|
let monthSourceIds = Set(monthSnapshots.compactMap { $0.source?.id })
|
||||||
|
if sourceIds.isSubset(of: monthSourceIds) {
|
||||||
|
completed.insert(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return completed
|
||||||
|
}
|
||||||
|
|
||||||
|
private func filterSnapshotsForCharts(
|
||||||
|
sources: [InvestmentSource],
|
||||||
|
snapshots: [Snapshot]
|
||||||
|
) -> [Snapshot] {
|
||||||
|
guard let lastCompleted = MonthlyCheckInStore.latestCompletionDate()?.startOfMonth else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let completedMonthsAfter = completedMonthKeys(
|
||||||
|
sources: sources,
|
||||||
|
snapshots: snapshots,
|
||||||
|
after: lastCompleted
|
||||||
|
)
|
||||||
|
|
||||||
|
return snapshots.filter { snapshot in
|
||||||
|
let monthDate = snapshot.date.startOfMonth
|
||||||
|
if monthDate <= lastCompleted {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
|
||||||
|
let key = DateComponents(year: components.year, month: components.month)
|
||||||
|
return completedMonthsAfter.contains(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func calculateLatestChange(from data: [(date: Date, value: Decimal)]) -> PortfolioChange {
|
private func calculateLatestChange(from data: [(date: Date, value: Decimal)]) -> PortfolioChange {
|
||||||
guard data.count >= 2 else {
|
guard data.count >= 2 else {
|
||||||
return PortfolioChange(absolute: 0, percentage: 0, label: "since last update")
|
return PortfolioChange(absolute: 0, percentage: 0, label: "since last update")
|
||||||
@@ -341,6 +393,95 @@ class DashboardViewModel: ObservableObject {
|
|||||||
return PortfolioChange(absolute: absolute, percentage: percentage, label: "since last update")
|
return PortfolioChange(absolute: absolute, percentage: percentage, label: "since last update")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func updatePortfolioForecast() {
|
||||||
|
// Need at least 3 data points for meaningful forecast
|
||||||
|
guard evolutionData.count >= 3 else {
|
||||||
|
portfolioForecast = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentValue = evolutionData.last?.value ?? 0
|
||||||
|
|
||||||
|
// Use the evolution data to predict 12 months ahead
|
||||||
|
// Create "virtual snapshots" from evolution data for the prediction engine
|
||||||
|
let virtualSnapshots = evolutionData.map { dataPoint -> VirtualSnapshot in
|
||||||
|
VirtualSnapshot(date: dataPoint.date, value: dataPoint.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate using Holt-Winters or linear trend based on data volatility
|
||||||
|
let values = virtualSnapshots.map { NSDecimalNumber(decimal: $0.value).doubleValue }
|
||||||
|
guard values.count >= 3 else {
|
||||||
|
portfolioForecast = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate monthly growth rate from recent data
|
||||||
|
let recentData = Array(virtualSnapshots.suffix(min(12, virtualSnapshots.count)))
|
||||||
|
guard let firstPoint = recentData.first, let lastPoint = recentData.last,
|
||||||
|
firstPoint.value > 0 else {
|
||||||
|
portfolioForecast = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let monthsBetween = max(1, firstPoint.date.monthsBetween(lastPoint.date))
|
||||||
|
let totalGrowth = lastPoint.value - firstPoint.value
|
||||||
|
let monthlyGrowth = totalGrowth / Decimal(monthsBetween)
|
||||||
|
|
||||||
|
// Project 12 months ahead
|
||||||
|
let forecastValue = currentValue + (monthlyGrowth * 12)
|
||||||
|
|
||||||
|
// Calculate confidence interval based on volatility
|
||||||
|
let volatility = calculatePortfolioVolatility(from: values)
|
||||||
|
let confidenceWidth = NSDecimalNumber(decimal: currentValue).doubleValue * volatility * 0.5
|
||||||
|
|
||||||
|
portfolioForecast = PortfolioForecast(
|
||||||
|
currentValue: currentValue,
|
||||||
|
forecastValue: forecastValue,
|
||||||
|
forecastDate: Calendar.current.date(byAdding: .month, value: 12, to: Date()) ?? Date(),
|
||||||
|
confidenceLower: Decimal(max(0, NSDecimalNumber(decimal: forecastValue).doubleValue - confidenceWidth)),
|
||||||
|
confidenceUpper: forecastValue + Decimal(confidenceWidth),
|
||||||
|
monthlyGrowthRate: monthlyGrowth,
|
||||||
|
annualizedGrowthRate: calculateAnnualizedGrowth(from: virtualSnapshots)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculatePortfolioVolatility(from values: [Double]) -> Double {
|
||||||
|
guard values.count >= 2 else { return 0 }
|
||||||
|
|
||||||
|
var returns: [Double] = []
|
||||||
|
for i in 1..<values.count {
|
||||||
|
guard values[i - 1] != 0 else { continue }
|
||||||
|
let periodReturn = (values[i] - values[i - 1]) / values[i - 1]
|
||||||
|
returns.append(periodReturn)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !returns.isEmpty else { return 0 }
|
||||||
|
|
||||||
|
let mean = returns.reduce(0, +) / Double(returns.count)
|
||||||
|
let squaredDiffs = returns.map { pow($0 - mean, 2) }
|
||||||
|
let variance = squaredDiffs.reduce(0, +) / Double(max(1, returns.count - 1))
|
||||||
|
return sqrt(variance)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateAnnualizedGrowth(from snapshots: [VirtualSnapshot]) -> Decimal {
|
||||||
|
guard let first = snapshots.first, let last = snapshots.last,
|
||||||
|
first.value > 0 else { return 0 }
|
||||||
|
|
||||||
|
let monthsBetween = max(1, first.date.monthsBetween(last.date))
|
||||||
|
let totalReturn = (last.value - first.value) / first.value
|
||||||
|
|
||||||
|
// Annualize: (1 + total_return)^(12/months) - 1
|
||||||
|
let monthlyReturn = totalReturn / Decimal(monthsBetween)
|
||||||
|
let annualized = monthlyReturn * 12
|
||||||
|
return annualized
|
||||||
|
}
|
||||||
|
|
||||||
|
// Virtual snapshot for forecast calculations
|
||||||
|
private struct VirtualSnapshot {
|
||||||
|
let date: Date
|
||||||
|
let value: Decimal
|
||||||
|
}
|
||||||
|
|
||||||
func goalEtaText(for goal: Goal, currentValue: Decimal) -> String? {
|
func goalEtaText(for goal: Goal, currentValue: Decimal) -> String? {
|
||||||
let target = goal.targetDecimal
|
let target = goal.targetDecimal
|
||||||
let lastValue = evolutionData.last?.value ?? currentValue
|
let lastValue = evolutionData.last?.value ?? currentValue
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ class GoalsViewModel: ObservableObject {
|
|||||||
private var lastSourcesHash: Int = 0
|
private var lastSourcesHash: Int = 0
|
||||||
|
|
||||||
init(
|
init(
|
||||||
goalRepository: GoalRepository = GoalRepository(),
|
goalRepository: GoalRepository? = nil,
|
||||||
sourceRepository: InvestmentSourceRepository = InvestmentSourceRepository(),
|
sourceRepository: InvestmentSourceRepository? = nil,
|
||||||
snapshotRepository: SnapshotRepository = SnapshotRepository()
|
snapshotRepository: SnapshotRepository? = nil
|
||||||
) {
|
) {
|
||||||
self.goalRepository = goalRepository
|
self.goalRepository = goalRepository ?? GoalRepository()
|
||||||
self.sourceRepository = sourceRepository
|
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
|
||||||
self.snapshotRepository = snapshotRepository
|
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
|
||||||
|
|
||||||
setupObservers()
|
setupObservers()
|
||||||
refresh()
|
refresh()
|
||||||
@@ -69,9 +69,9 @@ class GoalsViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func totalValue(for goal: Goal) -> Decimal {
|
func totalValue(for goal: Goal) -> Decimal {
|
||||||
if let account = goal.account {
|
if let accountId = goal.account?.safeId {
|
||||||
return sourceRepository.sources
|
return sourceRepository.sources
|
||||||
.filter { $0.account?.id == account.id }
|
.filter { $0.account?.id == accountId }
|
||||||
.reduce(Decimal.zero) { $0 + $1.latestValue }
|
.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||||
}
|
}
|
||||||
return totalValue
|
return totalValue
|
||||||
@@ -143,8 +143,8 @@ class GoalsViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let sources: [InvestmentSource]
|
let sources: [InvestmentSource]
|
||||||
if let account = goal.account {
|
if let accountId = goal.account?.safeId {
|
||||||
sources = sourceRepository.sources.filter { $0.account?.id == account.id }
|
sources = sourceRepository.sources.filter { $0.account?.id == accountId }
|
||||||
} else {
|
} else {
|
||||||
sources = sourceRepository.sources
|
sources = sourceRepository.sources
|
||||||
}
|
}
|
||||||
@@ -155,7 +155,7 @@ class GoalsViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Performance: Use cached evolution data if available
|
// Performance: Use cached evolution data if available
|
||||||
let cacheKey = goal.account?.id ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
|
let cacheKey = goal.account?.safeId ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
|
||||||
let evolutionData: [(date: Date, value: Decimal)]
|
let evolutionData: [(date: Date, value: Decimal)]
|
||||||
if let cached = cachedEvolutionData[cacheKey] {
|
if let cached = cachedEvolutionData[cacheKey] {
|
||||||
evolutionData = cached
|
evolutionData = cached
|
||||||
@@ -251,7 +251,8 @@ class GoalsViewModel: ObservableObject {
|
|||||||
// MARK: - Private helpers
|
// MARK: - Private helpers
|
||||||
|
|
||||||
private func loadGoals() {
|
private func loadGoals() {
|
||||||
if showAllAccounts || selectedAccount == nil {
|
let selectedAccountId = selectedAccount?.safeId
|
||||||
|
if showAllAccounts || selectedAccountId == nil {
|
||||||
goalRepository.fetchGoals()
|
goalRepository.fetchGoals()
|
||||||
} else if let account = selectedAccount {
|
} else if let account = selectedAccount {
|
||||||
goalRepository.fetchGoals(for: account)
|
goalRepository.fetchGoals(for: account)
|
||||||
@@ -259,28 +260,26 @@ class GoalsViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateGoals(using repositoryGoals: [Goal]) {
|
private func updateGoals(using repositoryGoals: [Goal]) {
|
||||||
if showAllAccounts || selectedAccount == nil {
|
let selectedAccountId = selectedAccount?.safeId
|
||||||
|
if showAllAccounts || selectedAccountId == nil {
|
||||||
goals = repositoryGoals
|
goals = repositoryGoals
|
||||||
} else if let account = selectedAccount {
|
|
||||||
goals = repositoryGoals.filter { $0.account?.id == account.id }
|
|
||||||
} else {
|
} else {
|
||||||
goals = repositoryGoals
|
goals = repositoryGoals.filter { $0.account?.id == selectedAccountId }
|
||||||
}
|
}
|
||||||
updateTotalValue()
|
updateTotalValue()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateTotalValue() {
|
private func updateTotalValue() {
|
||||||
if showAllAccounts || selectedAccount == nil {
|
let selectedAccountId = selectedAccount?.safeId
|
||||||
|
if showAllAccounts || selectedAccountId == nil {
|
||||||
totalValue = sourceRepository.sources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
totalValue = sourceRepository.sources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if let account = selectedAccount {
|
|
||||||
totalValue = sourceRepository.sources
|
totalValue = sourceRepository.sources
|
||||||
.filter { $0.account?.id == account.id }
|
.filter { $0.account?.id == selectedAccountId }
|
||||||
.reduce(Decimal.zero) { $0 + $1.latestValue }
|
.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GoalPaceStatus {
|
struct GoalPaceStatus {
|
||||||
|
|||||||
@@ -112,10 +112,11 @@ class MonthlyCheckInViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func filteredSources() -> [InvestmentSource] {
|
private func filteredSources() -> [InvestmentSource] {
|
||||||
if showAllAccounts || selectedAccount == nil {
|
let selectedAccountId = selectedAccount?.safeId
|
||||||
|
if showAllAccounts || selectedAccountId == nil {
|
||||||
return sourceRepository.sources
|
return sourceRepository.sources
|
||||||
}
|
}
|
||||||
return sourceRepository.sources.filter { $0.account?.id == selectedAccount?.id }
|
return sourceRepository.sources.filter { $0.account?.id == selectedAccountId }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func filteredSnapshots(for sources: [InvestmentSource]) -> [Snapshot] {
|
private func filteredSnapshots(for sources: [InvestmentSource]) -> [Snapshot] {
|
||||||
|
|||||||
@@ -83,14 +83,28 @@ class SettingsViewModel: ObservableObject {
|
|||||||
currencyCode = settings.currency
|
currencyCode = settings.currency
|
||||||
inputMode = InputMode(rawValue: settings.inputMode) ?? .simple
|
inputMode = InputMode(rawValue: settings.inputMode) ?? .simple
|
||||||
|
|
||||||
// Load statistics
|
// Load statistics directly from database to avoid async race conditions
|
||||||
totalSources = sourceRepository.sourceCount
|
loadStatistics()
|
||||||
totalCategories = categoryRepository.categories.count
|
|
||||||
totalSnapshots = sourceRepository.sources.reduce(0) { $0 + $1.snapshotCount }
|
|
||||||
|
|
||||||
FirebaseService.shared.logScreenView(screenName: "Settings")
|
FirebaseService.shared.logScreenView(screenName: "Settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func loadStatistics() {
|
||||||
|
let context = CoreDataStack.shared.viewContext
|
||||||
|
|
||||||
|
// Fetch source count directly
|
||||||
|
let sourceRequest: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||||
|
totalSources = (try? context.count(for: sourceRequest)) ?? 0
|
||||||
|
|
||||||
|
// Fetch category count directly
|
||||||
|
let categoryRequest: NSFetchRequest<Category> = Category.fetchRequest()
|
||||||
|
totalCategories = (try? context.count(for: categoryRequest)) ?? 0
|
||||||
|
|
||||||
|
// Fetch snapshot count directly
|
||||||
|
let snapshotRequest: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
|
||||||
|
totalSnapshots = (try? context.count(for: snapshotRequest)) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Premium Actions
|
// MARK: - Premium Actions
|
||||||
|
|
||||||
func upgradeToPremium() {
|
func upgradeToPremium() {
|
||||||
@@ -142,6 +156,16 @@ class SettingsViewModel: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Export
|
// MARK: - Export
|
||||||
|
|
||||||
|
@Published var isExporting = false
|
||||||
|
@Published var exportProgress: Double = 0
|
||||||
|
@Published var exportStatus = ""
|
||||||
|
@Published var shareItem: ShareItem?
|
||||||
|
|
||||||
|
struct ShareItem: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let url: URL
|
||||||
|
}
|
||||||
|
|
||||||
func exportData(format: ExportService.ExportFormat) {
|
func exportData(format: ExportService.ExportFormat) {
|
||||||
guard freemiumValidator.canExport() else {
|
guard freemiumValidator.canExport() else {
|
||||||
showingPaywall = true
|
showingPaywall = true
|
||||||
@@ -149,19 +173,79 @@ class SettingsViewModel: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
isExporting = true
|
||||||
let viewController = windowScene.windows.first?.rootViewController else {
|
exportProgress = 0
|
||||||
return
|
exportStatus = "Preparing export..."
|
||||||
|
|
||||||
|
Task {
|
||||||
|
// Fetch data directly from database
|
||||||
|
let context = CoreDataStack.shared.viewContext
|
||||||
|
let sources = fetchAllSources(in: context)
|
||||||
|
let categories = fetchAllCategories(in: context)
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
exportProgress = 0.3
|
||||||
|
exportStatus = "Generating \(format.rawValue) file..."
|
||||||
}
|
}
|
||||||
|
|
||||||
ExportService.shared.share(
|
let content: String
|
||||||
format: format,
|
let fileName: String
|
||||||
sources: sourceRepository.sources,
|
|
||||||
categories: categoryRepository.categories,
|
switch format {
|
||||||
from: viewController
|
case .csv:
|
||||||
)
|
content = ExportService.shared.exportToCSV(sources: sources, categories: categories)
|
||||||
|
fileName = "investment_tracker_export.csv"
|
||||||
|
case .json:
|
||||||
|
content = ExportService.shared.exportToJSON(sources: sources, categories: categories)
|
||||||
|
fileName = "investment_tracker_export.json"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
exportProgress = 0.7
|
||||||
|
exportStatus = "Saving file..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create temporary file
|
||||||
|
let tempURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(fileName)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try content.write(to: tempURL, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
exportProgress = 1.0
|
||||||
|
exportStatus = "Export complete"
|
||||||
|
isExporting = false
|
||||||
|
showingExportOptions = false
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
||||||
|
self?.shareItem = ShareItem(url: tempURL)
|
||||||
|
}
|
||||||
|
FirebaseService.shared.logExportAttempt(format: format.rawValue, success: true)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
isExporting = false
|
||||||
|
errorMessage = "Export failed: \(error.localizedDescription)"
|
||||||
|
FirebaseService.shared.logExportAttempt(format: format.rawValue, success: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchAllSources(in context: NSManagedObjectContext) -> [InvestmentSource] {
|
||||||
|
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||||
|
request.sortDescriptors = [NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)]
|
||||||
|
return (try? context.fetch(request)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchAllCategories(in context: NSManagedObjectContext) -> [Category] {
|
||||||
|
let request: NSFetchRequest<Category> = Category.fetchRequest()
|
||||||
|
request.sortDescriptors = [NSSortDescriptor(keyPath: \Category.name, ascending: true)]
|
||||||
|
return (try? context.fetch(request)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share sheet is presented in SettingsView using shareItem.
|
||||||
|
|
||||||
var canExport: Bool {
|
var canExport: Bool {
|
||||||
freemiumValidator.canExport()
|
freemiumValidator.canExport()
|
||||||
}
|
}
|
||||||
@@ -202,41 +286,79 @@ class SettingsViewModel: ObservableObject {
|
|||||||
// MARK: - Data Management
|
// MARK: - Data Management
|
||||||
|
|
||||||
func resetAllData() {
|
func resetAllData() {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
let context = CoreDataStack.shared.viewContext
|
let context = CoreDataStack.shared.viewContext
|
||||||
|
let entityNames = [
|
||||||
|
"Snapshot",
|
||||||
|
"InvestmentSource",
|
||||||
|
"Category",
|
||||||
|
"Asset",
|
||||||
|
"Account",
|
||||||
|
"Goal",
|
||||||
|
"Transaction",
|
||||||
|
"PredictionCache"
|
||||||
|
]
|
||||||
|
|
||||||
// Delete all snapshots, sources, and categories
|
context.performAndWait {
|
||||||
for source in sourceRepository.sources {
|
var deletedObjectIDs: [NSManagedObjectID] = []
|
||||||
context.delete(source)
|
for name in entityNames {
|
||||||
|
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: name)
|
||||||
|
let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
|
||||||
|
deleteRequest.resultType = .resultTypeObjectIDs
|
||||||
|
if let result = try? context.execute(deleteRequest) as? NSBatchDeleteResult,
|
||||||
|
let objectIDs = result.result as? [NSManagedObjectID] {
|
||||||
|
deletedObjectIDs.append(contentsOf: objectIDs)
|
||||||
}
|
}
|
||||||
for category in categoryRepository.categories {
|
|
||||||
context.delete(category)
|
|
||||||
}
|
|
||||||
let assetRequest: NSFetchRequest<Asset> = Asset.fetchRequest()
|
|
||||||
let accountRequest: NSFetchRequest<Account> = Account.fetchRequest()
|
|
||||||
let goalRequest: NSFetchRequest<Goal> = Goal.fetchRequest()
|
|
||||||
if let assets = try? context.fetch(assetRequest) {
|
|
||||||
assets.forEach { context.delete($0) }
|
|
||||||
}
|
|
||||||
if let accounts = try? context.fetch(accountRequest) {
|
|
||||||
accounts.forEach { context.delete($0) }
|
|
||||||
}
|
|
||||||
if let goals = try? context.fetch(goalRequest) {
|
|
||||||
goals.forEach { context.delete($0) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CoreDataStack.shared.save()
|
if !deletedObjectIDs.isEmpty {
|
||||||
|
NSManagedObjectContext.mergeChanges(
|
||||||
|
fromRemoteContextSave: [NSDeletedObjectsKey: deletedObjectIDs],
|
||||||
|
into: [context]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Clear notifications
|
context.reset()
|
||||||
notificationService.cancelAllReminders()
|
|
||||||
|
|
||||||
|
context.performAndWait {
|
||||||
// Recreate default categories
|
// Recreate default categories
|
||||||
categoryRepository.createDefaultCategoriesIfNeeded()
|
let categoryCount = (try? context.count(for: Category.fetchRequest())) ?? 0
|
||||||
_ = AccountRepository().createDefaultAccountIfNeeded()
|
if categoryCount == 0 {
|
||||||
|
Category.createDefaultCategories(in: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recreate default account
|
||||||
|
let accountCount = (try? context.count(for: Account.fetchRequest())) ?? 0
|
||||||
|
if accountCount == 0 {
|
||||||
|
let defaultCurrency = AppSettings.getOrCreate(in: context).currency
|
||||||
|
let account = Account(context: context)
|
||||||
|
account.name = Account.defaultAccountName
|
||||||
|
account.currency = defaultCurrency
|
||||||
|
account.inputMode = InputMode.simple.rawValue
|
||||||
|
account.notificationFrequency = NotificationFrequency.monthly.rawValue
|
||||||
|
account.customFrequencyMonths = 1
|
||||||
|
account.sortOrder = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = AppSettings.getOrCreate(in: context)
|
||||||
|
settings.selectedAccountId = nil
|
||||||
|
settings.showAllAccounts = true
|
||||||
|
|
||||||
|
try? context.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear notifications + monthly check-ins
|
||||||
|
notificationService.cancelAllReminders()
|
||||||
|
MonthlyCheckInStore.clearAll()
|
||||||
|
CoreDataStack.shared.refreshWidgetData()
|
||||||
|
|
||||||
// Reload data
|
|
||||||
loadSettings()
|
loadSettings()
|
||||||
|
isLoading = false
|
||||||
successMessage = "All data has been reset"
|
successMessage = "All data has been reset"
|
||||||
|
NotificationCenter.default.post(name: .didResetData, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class SourceDetailViewModel: ObservableObject {
|
|||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var isRefreshing = false
|
private var isRefreshing = false
|
||||||
private var refreshQueued = false
|
private var refreshQueued = false
|
||||||
|
private var refreshTask: Task<Void, Never>?
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
@@ -97,15 +98,21 @@ class SourceDetailViewModel: ObservableObject {
|
|||||||
guard !isRefreshing else { return }
|
guard !isRefreshing else { return }
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
|
|
||||||
Task { [weak self] in
|
// Cancel any previous refresh task to avoid stale updates
|
||||||
|
refreshTask?.cancel()
|
||||||
|
|
||||||
|
refreshTask = Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
while self.refreshQueued {
|
while self.refreshQueued && !Task.isCancelled {
|
||||||
self.refreshQueued = false
|
self.refreshQueued = false
|
||||||
|
|
||||||
// Fetch snapshots (filtered by freemium limits)
|
// Fetch snapshots (filtered by freemium limits)
|
||||||
let allSnapshots = snapshotRepository.fetchSnapshots(for: source)
|
let allSnapshots = snapshotRepository.fetchSnapshots(for: source)
|
||||||
let filteredSnapshots = freemiumValidator.filterSnapshots(allSnapshots)
|
let filteredSnapshots = freemiumValidator.filterSnapshots(allSnapshots)
|
||||||
|
|
||||||
|
// Check for cancellation before updating UI
|
||||||
|
guard !Task.isCancelled else { break }
|
||||||
|
|
||||||
// Performance: Only update if data actually changed
|
// Performance: Only update if data actually changed
|
||||||
let snapshotsChanged = filteredSnapshots.count != self.snapshots.count ||
|
let snapshotsChanged = filteredSnapshots.count != self.snapshots.count ||
|
||||||
filteredSnapshots.first?.date != self.snapshots.first?.date
|
filteredSnapshots.first?.date != self.snapshots.first?.date
|
||||||
@@ -139,6 +146,12 @@ class SourceDetailViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cancel any ongoing background tasks - call when view disappears
|
||||||
|
func cancelPendingTasks() {
|
||||||
|
refreshTask?.cancel()
|
||||||
|
refreshTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Snapshot Actions
|
// MARK: - Snapshot Actions
|
||||||
|
|
||||||
func addSnapshot(date: Date, value: Decimal, contribution: Decimal?, notes: String?) {
|
func addSnapshot(date: Date, value: Decimal, contribution: Decimal?, notes: String?) {
|
||||||
@@ -288,21 +301,22 @@ class SourceDetailViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var isHistoryLimited: Bool {
|
var isHistoryLimited: Bool {
|
||||||
!freemiumValidator.isPremium && hiddenSnapshotCount > 0
|
!freemiumValidator.isPremium
|
||||||
}
|
}
|
||||||
|
|
||||||
var hiddenSnapshotCount: Int {
|
var hiddenSnapshotCount: Int {
|
||||||
guard let limit = snapshotDisplayLimit else { return 0 }
|
// For free users, count how many snapshots are hidden due to the 12-month limit
|
||||||
return max(0, snapshots.count - min(limit, snapshots.count))
|
guard !freemiumValidator.isPremium else { return 0 }
|
||||||
}
|
|
||||||
|
|
||||||
var snapshotDisplayLimit: Int? {
|
// Get all snapshots without the date filter
|
||||||
freemiumValidator.isPremium ? nil : 10
|
let allSnapshots = snapshotRepository.fetchSnapshots(for: source)
|
||||||
|
return max(0, allSnapshots.count - snapshots.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
var visibleSnapshots: [Snapshot] {
|
var visibleSnapshots: [Snapshot] {
|
||||||
guard let limit = snapshotDisplayLimit else { return snapshots }
|
// Premium users see all snapshots (already filtered by freemiumValidator in refreshData)
|
||||||
return Array(snapshots.prefix(limit))
|
// Free users also see all their filtered snapshots (limited to last 12 months)
|
||||||
|
snapshots
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isRelevantChange(_ notification: Notification) -> Bool {
|
private func isRelevantChange(_ notification: Notification) -> Bool {
|
||||||
|
|||||||
@@ -83,8 +83,9 @@ class SourceListViewModel: ObservableObject {
|
|||||||
private func filterAndSortSources(_ allSources: [InvestmentSource]) {
|
private func filterAndSortSources(_ allSources: [InvestmentSource]) {
|
||||||
var filtered = allSources
|
var filtered = allSources
|
||||||
|
|
||||||
if !showAllAccounts, let account = selectedAccount {
|
let selectedAccountId = selectedAccount?.safeId
|
||||||
filtered = filtered.filter { $0.account?.id == account.id }
|
if !showAllAccounts, let selectedAccountId {
|
||||||
|
filtered = filtered.filter { $0.account?.id == selectedAccountId }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by category
|
// Filter by category
|
||||||
|
|||||||
@@ -18,7 +18,18 @@ struct AccountEditorView: View {
|
|||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
Section {
|
Section {
|
||||||
|
if isEditingDefaultAccount {
|
||||||
|
HStack {
|
||||||
|
Text(Account.defaultAccountName)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "lock.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
TextField("Account name", text: $name)
|
TextField("Account name", text: $name)
|
||||||
|
}
|
||||||
Picker("Currency", selection: $currencyCode) {
|
Picker("Currency", selection: $currencyCode) {
|
||||||
ForEach(CurrencyPicker.commonCodes, id: \.self) { code in
|
ForEach(CurrencyPicker.commonCodes, id: \.self) { code in
|
||||||
Text(code).tag(code)
|
Text(code).tag(code)
|
||||||
@@ -30,6 +41,8 @@ struct AccountEditorView: View {
|
|||||||
if let errorMessage {
|
if let errorMessage {
|
||||||
Text(errorMessage)
|
Text(errorMessage)
|
||||||
.foregroundColor(.negativeRed)
|
.foregroundColor(.negativeRed)
|
||||||
|
} else if isEditingDefaultAccount {
|
||||||
|
Text("The Default account name cannot be changed.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,9 +80,16 @@ struct AccountEditorView: View {
|
|||||||
.presentationDetents([.large])
|
.presentationDetents([.large])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isEditingDefaultAccount: Bool {
|
||||||
|
account?.isDefaultAccount == true
|
||||||
|
}
|
||||||
|
|
||||||
private var isValid: Bool {
|
private var isValid: Bool {
|
||||||
|
if isEditingDefaultAccount {
|
||||||
|
return true // Can only edit currency/inputMode for Default account
|
||||||
|
}
|
||||||
let trimmed = name.trimmingCharacters(in: .whitespaces)
|
let trimmed = name.trimmingCharacters(in: .whitespaces)
|
||||||
return !trimmed.isEmpty && !isDuplicateName(trimmed)
|
return !trimmed.isEmpty && errorMessage == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func validateName() {
|
private func validateName() {
|
||||||
@@ -78,17 +98,24 @@ struct AccountEditorView: View {
|
|||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
errorMessage = isDuplicateName(trimmed) ? "An account with this name already exists." : nil
|
|
||||||
|
// Check if trying to use reserved "Default" name for a non-default account
|
||||||
|
if trimmed.lowercased() == Account.defaultAccountName.lowercased() {
|
||||||
|
let isCreatingNew = account == nil
|
||||||
|
let isEditingNonDefault = account != nil && !account!.isDefaultAccount
|
||||||
|
if isCreatingNew || isEditingNonDefault {
|
||||||
|
errorMessage = "The name '\(Account.defaultAccountName)' is reserved."
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isDuplicateName(_ trimmed: String) -> Bool {
|
// Check for duplicate names
|
||||||
let normalized = trimmed.lowercased()
|
if !accountRepository.isNameAvailable(trimmed, excludingAccountId: account?.safeId) {
|
||||||
return accountRepository.accounts.contains { existing in
|
errorMessage = "An account with this name already exists."
|
||||||
if let account, existing.id == account.id {
|
return
|
||||||
return false
|
|
||||||
}
|
|
||||||
return (existing.name).trimmingCharacters(in: .whitespaces).lowercased() == normalized
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
errorMessage = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadAccount() {
|
private func loadAccount() {
|
||||||
@@ -102,15 +129,17 @@ struct AccountEditorView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func saveAccount() {
|
private func saveAccount() {
|
||||||
|
if !isEditingDefaultAccount {
|
||||||
validateName()
|
validateName()
|
||||||
guard errorMessage == nil else { return }
|
guard errorMessage == nil else { return }
|
||||||
let trimmedName = name.trimmingCharacters(in: .whitespaces)
|
}
|
||||||
|
let trimmedName = isEditingDefaultAccount ? Account.defaultAccountName : name.trimmingCharacters(in: .whitespaces)
|
||||||
let enforcedFrequency: NotificationFrequency = .monthly
|
let enforcedFrequency: NotificationFrequency = .monthly
|
||||||
let enforcedCustomMonths = 1
|
let enforcedCustomMonths = 1
|
||||||
if let account {
|
if let account {
|
||||||
accountRepository.updateAccount(
|
accountRepository.updateAccount(
|
||||||
account,
|
account,
|
||||||
name: trimmedName,
|
name: isEditingDefaultAccount ? nil : trimmedName, // Don't update name for Default account
|
||||||
currency: currencyCode,
|
currency: currencyCode,
|
||||||
inputMode: inputMode,
|
inputMode: inputMode,
|
||||||
notificationFrequency: enforcedFrequency,
|
notificationFrequency: enforcedFrequency,
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ struct AccountsView: View {
|
|||||||
@State private var selectedAccount: Account?
|
@State private var selectedAccount: Account?
|
||||||
@State private var showingPaywall = false
|
@State private var showingPaywall = false
|
||||||
@State private var accountToDelete: Account?
|
@State private var accountToDelete: Account?
|
||||||
|
@State private var showingDeleteBlocked = false
|
||||||
|
|
||||||
|
private var availableAccounts: [Account] {
|
||||||
|
accountRepository.accounts.filter { $0.safeId != nil }
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -15,26 +20,35 @@ struct AccountsView: View {
|
|||||||
|
|
||||||
List {
|
List {
|
||||||
Section {
|
Section {
|
||||||
ForEach(accountRepository.accounts) { account in
|
ForEach(availableAccounts, id: \.objectID) { account in
|
||||||
|
let canDelete = accountRepository.canDeleteAccount(account)
|
||||||
Button {
|
Button {
|
||||||
selectedAccount = account
|
selectedAccount = account
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
Text(account.name)
|
Text(account.name)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
if account.isDefaultAccount {
|
||||||
|
Image(systemName: "star.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.appWarning)
|
||||||
|
}
|
||||||
|
}
|
||||||
Text(account.currencyCode ?? AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency)
|
Text(account.currencyCode ?? AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if accountStore.selectedAccount?.id == account.id && !accountStore.showAllAccounts {
|
if accountStore.selectedAccount?.safeId == account.safeId && !accountStore.showAllAccounts {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.foregroundColor(.appPrimary)
|
.foregroundColor(.appPrimary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.swipeActions(edge: .trailing) {
|
.swipeActions(edge: .trailing) {
|
||||||
|
if canDelete {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
accountToDelete = account
|
accountToDelete = account
|
||||||
} label: {
|
} label: {
|
||||||
@@ -42,10 +56,11 @@ struct AccountsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Accounts")
|
Text("Accounts")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(iapService.isPremium ? "Create multiple accounts for business, family, or goals." : "Free users can create one account.")
|
Text(iapService.isPremium ? "Create multiple accounts for business, family, or goals. The Default account cannot be deleted." : "Free users can create one account.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
@@ -61,14 +76,20 @@ struct AccountsView: View {
|
|||||||
) {
|
) {
|
||||||
Button("Delete", role: .destructive) {
|
Button("Delete", role: .destructive) {
|
||||||
guard let accountToDelete else { return }
|
guard let accountToDelete else { return }
|
||||||
if accountStore.selectedAccount?.id == accountToDelete.id {
|
if accountRepository.canDeleteAccount(accountToDelete) {
|
||||||
accountStore.selectAllAccounts()
|
|
||||||
}
|
|
||||||
accountRepository.deleteAccount(accountToDelete)
|
accountRepository.deleteAccount(accountToDelete)
|
||||||
|
} else {
|
||||||
|
showingDeleteBlocked = true
|
||||||
|
}
|
||||||
self.accountToDelete = nil
|
self.accountToDelete = nil
|
||||||
}
|
}
|
||||||
} message: {
|
} message: {
|
||||||
Text("This will remove the account and unlink its sources.")
|
Text("This will remove the account and all its sources and snapshots.")
|
||||||
|
}
|
||||||
|
.alert("Cannot Delete Account", isPresented: $showingDeleteBlocked) {
|
||||||
|
Button("OK") {}
|
||||||
|
} message: {
|
||||||
|
Text("The Default account cannot be deleted. You must keep at least one account.")
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
|||||||
@@ -260,13 +260,13 @@ struct ChartsContainerView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
ForEach(accountStore.accounts) { account in
|
ForEach(availableAccounts, id: \.objectID) { account in
|
||||||
Button {
|
Button {
|
||||||
accountStore.selectAccount(account)
|
accountStore.selectAccount(account)
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Text(account.name)
|
Text(account.name)
|
||||||
if accountStore.selectedAccount?.id == account.id && !accountStore.showAllAccounts {
|
if accountStore.selectedAccount?.safeId == account.safeId && !accountStore.showAllAccounts {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,6 +276,10 @@ struct ChartsContainerView: View {
|
|||||||
Image(systemName: "person.2.circle")
|
Image(systemName: "person.2.circle")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var availableAccounts: [Account] {
|
||||||
|
accountStore.accounts.filter { $0.safeId != nil }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Chart Type Button
|
// MARK: - Chart Type Button
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ struct DashboardView: View {
|
|||||||
.sheet(isPresented: $showingCustomize) {
|
.sheet(isPresented: $showingCustomize) {
|
||||||
DashboardCustomizeView(configs: $sectionConfigs)
|
DashboardCustomizeView(configs: $sectionConfigs)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $viewModel.showingPaywall) {
|
||||||
|
PaywallView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,13 +110,13 @@ struct DashboardView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
ForEach(accountStore.accounts) { account in
|
ForEach(availableAccounts, id: \.objectID) { account in
|
||||||
Button {
|
Button {
|
||||||
accountStore.selectAccount(account)
|
accountStore.selectAccount(account)
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Text(account.name)
|
Text(account.name)
|
||||||
if accountStore.selectedAccount?.id == account.id && !accountStore.showAllAccounts {
|
if accountStore.selectedAccount?.safeId == account.safeId && !accountStore.showAllAccounts {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,6 +127,10 @@ struct DashboardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var availableAccounts: [Account] {
|
||||||
|
accountStore.accounts.filter { $0.safeId != nil }
|
||||||
|
}
|
||||||
|
|
||||||
private var visibleSections: [DashboardSectionConfig] {
|
private var visibleSections: [DashboardSectionConfig] {
|
||||||
sectionConfigs.filter { $0.isVisible }
|
sectionConfigs.filter { $0.isVisible }
|
||||||
}
|
}
|
||||||
@@ -144,7 +151,16 @@ struct DashboardView: View {
|
|||||||
changeLabel: calmModeEnabled ? "since last update" : "today",
|
changeLabel: calmModeEnabled ? "since last update" : "today",
|
||||||
isPositive: calmModeEnabled
|
isPositive: calmModeEnabled
|
||||||
? viewModel.latestPortfolioChange.absolute >= 0
|
? viewModel.latestPortfolioChange.absolute >= 0
|
||||||
: viewModel.isDayChangePositive
|
: viewModel.isDayChangePositive,
|
||||||
|
forecast: viewModel.portfolioForecast,
|
||||||
|
isPremium: iapService.isPremium,
|
||||||
|
onUnlockTap: {
|
||||||
|
viewModel.showingPaywall = true
|
||||||
|
},
|
||||||
|
yearChange: viewModel.portfolioSummary.formattedYearChange,
|
||||||
|
sinceInceptionChange: viewModel.portfolioSummary.formattedAllTimeReturn,
|
||||||
|
isYearPositive: viewModel.isYearChangePositive,
|
||||||
|
isSinceInceptionPositive: viewModel.portfolioSummary.allTimeReturn >= 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case .monthlyCheckIn:
|
case .monthlyCheckIn:
|
||||||
@@ -243,6 +259,13 @@ struct TotalValueCard: View {
|
|||||||
let changeText: String
|
let changeText: String
|
||||||
let changeLabel: String
|
let changeLabel: String
|
||||||
let isPositive: Bool
|
let isPositive: Bool
|
||||||
|
var forecast: PortfolioForecast?
|
||||||
|
var isPremium: Bool = false
|
||||||
|
var onUnlockTap: (() -> Void)?
|
||||||
|
var yearChange: String?
|
||||||
|
var sinceInceptionChange: String?
|
||||||
|
var isYearPositive: Bool = true
|
||||||
|
var isSinceInceptionPositive: Bool = true
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
@@ -264,6 +287,77 @@ struct TotalValueCard: View {
|
|||||||
.foregroundColor(.white.opacity(0.8))
|
.foregroundColor(.white.opacity(0.8))
|
||||||
}
|
}
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
|
|
||||||
|
// YoY and Since Inception returns
|
||||||
|
if yearChange != nil || sinceInceptionChange != nil {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
if let yearChange = yearChange {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text("YoY")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
|
Text(yearChange)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let sinceInceptionChange = sinceInceptionChange {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text("Since inception")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
|
Text(sinceInceptionChange)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forecast section
|
||||||
|
if let forecast = forecast {
|
||||||
|
Divider()
|
||||||
|
.background(Color.white.opacity(0.3))
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
if isPremium {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "wand.and.stars")
|
||||||
|
.font(.caption)
|
||||||
|
Text("12-month forecast")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.foregroundColor(.white.opacity(0.85))
|
||||||
|
|
||||||
|
Text(forecast.formattedForecastValue)
|
||||||
|
.font(.title2.weight(.bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
|
||||||
|
Text(forecast.formattedGrowthPercentage)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundColor(forecast.isPositiveGrowth ? .white : .white.opacity(0.8))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
onUnlockTap?()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "lock.fill")
|
||||||
|
.font(.caption)
|
||||||
|
Text("Unlock 12-month forecast")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color.white.opacity(0.2))
|
||||||
|
.cornerRadius(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 24)
|
.padding(.vertical, 24)
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ struct ImportDataView: View {
|
|||||||
|
|
||||||
private let accountRepository = AccountRepository()
|
private let accountRepository = AccountRepository()
|
||||||
|
|
||||||
|
private var availableAccounts: [Account] {
|
||||||
|
accountStore.accounts.filter { $0.safeId != nil }
|
||||||
|
}
|
||||||
|
|
||||||
init(importContext: ImportContext = .settings) {
|
init(importContext: ImportContext = .settings) {
|
||||||
self.importContext = importContext
|
self.importContext = importContext
|
||||||
}
|
}
|
||||||
@@ -146,8 +150,17 @@ struct ImportDataView: View {
|
|||||||
Text(errorMessage ?? "")
|
Text(errorMessage ?? "")
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if selectedAccountId == nil {
|
// Ensure selectedAccountId is valid and exists in availableAccounts
|
||||||
selectedAccountId = accountStore.selectedAccount?.id ?? accountStore.accounts.first?.id
|
let validIds = Set(availableAccounts.compactMap { $0.safeId })
|
||||||
|
if selectedAccountId == nil || !validIds.contains(selectedAccountId!) {
|
||||||
|
selectedAccountId = accountStore.selectedAccount?.safeId ?? availableAccounts.first?.safeId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: availableAccounts) { _, newAccounts in
|
||||||
|
// Re-validate selectedAccountId when accounts change
|
||||||
|
let validIds = Set(newAccounts.compactMap { $0.safeId })
|
||||||
|
if let currentId = selectedAccountId, !validIds.contains(currentId) {
|
||||||
|
selectedAccountId = newAccounts.first?.safeId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: accountSelection) { _, _ in
|
.onChange(of: accountSelection) { _, _ in
|
||||||
@@ -219,18 +232,17 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var shouldShowAccountSelection: Bool {
|
private var shouldShowAccountSelection: Bool {
|
||||||
importContext == .onboarding
|
// Show account selection when there are multiple accounts (Premium) or during onboarding
|
||||||
|
iapService.isPremium && availableAccounts.count > 1 || importContext == .onboarding
|
||||||
}
|
}
|
||||||
|
|
||||||
private var importFooterText: String {
|
private var importFooterText: String {
|
||||||
if importContext == .onboarding {
|
if shouldShowAccountSelection {
|
||||||
return iapService.isPremium
|
return iapService.isPremium
|
||||||
? "Import will be added to the selected account."
|
? "Import will be added to the selected account."
|
||||||
: "Free users import into the existing account."
|
: "Free users import into the Default account."
|
||||||
}
|
}
|
||||||
return iapService.isPremium
|
return "Data will be imported into your Default account."
|
||||||
? "Accounts are imported as provided."
|
|
||||||
: "Free users import into the selected account."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var accountSection: some View {
|
private var accountSection: some View {
|
||||||
@@ -245,8 +257,8 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
|
|||||||
|
|
||||||
if accountSelection == .existing {
|
if accountSelection == .existing {
|
||||||
Picker("Import into", selection: $selectedAccountId) {
|
Picker("Import into", selection: $selectedAccountId) {
|
||||||
ForEach(accountStore.accounts) { account in
|
ForEach(availableAccounts, id: \.objectID) { account in
|
||||||
Text(account.name).tag(Optional(account.id))
|
Text(account.name).tag(Optional(account.safeId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(isImporting)
|
.disabled(isImporting)
|
||||||
@@ -273,9 +285,9 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var selectedAccountName: String? {
|
private var selectedAccountName: String? {
|
||||||
accountStore.accounts.first { $0.id == selectedAccountId }?.name
|
availableAccounts.first { $0.safeId == selectedAccountId }?.name
|
||||||
?? accountStore.selectedAccount?.name
|
?? accountStore.selectedAccount?.name
|
||||||
?? accountStore.accounts.first?.name
|
?? availableAccounts.first?.name
|
||||||
}
|
}
|
||||||
|
|
||||||
private func validateNewAccountName() -> String? {
|
private func validateNewAccountName() -> String? {
|
||||||
@@ -355,12 +367,17 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleImportContent(_ content: String) {
|
private func handleImportContent(_ content: String) {
|
||||||
let shouldForceSingleAccount = importContext == .onboarding
|
// For non-Premium users or when only one account exists, use the Default account
|
||||||
let allowMultipleAccounts = iapService.isPremium && !shouldForceSingleAccount
|
// For Premium users with multiple accounts, respect their selection
|
||||||
var defaultAccountName = accountStore.selectedAccount?.name
|
let useAccountSelection = shouldShowAccountSelection && iapService.isPremium
|
||||||
?? accountStore.accounts.first?.name
|
let allowMultipleAccounts = false // Always import into a single account
|
||||||
|
|
||||||
if shouldForceSingleAccount && iapService.isPremium {
|
var defaultAccountName = accountStore.accounts.first(where: { $0.isDefaultAccount })?.name
|
||||||
|
?? accountStore.selectedAccount?.name
|
||||||
|
?? accountStore.accounts.first?.name
|
||||||
|
?? Account.defaultAccountName
|
||||||
|
|
||||||
|
if useAccountSelection {
|
||||||
if accountSelection == .new {
|
if accountSelection == .new {
|
||||||
accountErrorMessage = validateNewAccountName()
|
accountErrorMessage = validateNewAccountName()
|
||||||
guard accountErrorMessage == nil else { return }
|
guard accountErrorMessage == nil else { return }
|
||||||
@@ -375,7 +392,7 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
|
|||||||
)
|
)
|
||||||
defaultAccountName = account.name
|
defaultAccountName = account.name
|
||||||
} else {
|
} else {
|
||||||
defaultAccountName = selectedAccountName
|
defaultAccountName = selectedAccountName ?? defaultAccountName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +414,11 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
|
|||||||
isImporting = false
|
isImporting = false
|
||||||
|
|
||||||
if importResult.errors.isEmpty {
|
if importResult.errors.isEmpty {
|
||||||
resultMessage = "Imported \(importResult.sourcesCreated) sources and \(importResult.snapshotsCreated) snapshots."
|
var message = "Imported \(importResult.sourcesCreated) sources and \(importResult.snapshotsCreated) snapshots."
|
||||||
|
if importResult.snapshotsUpdated > 0 {
|
||||||
|
message += " Updated \(importResult.snapshotsUpdated) existing snapshots."
|
||||||
|
}
|
||||||
|
resultMessage = message
|
||||||
tabSelection.selectedTab = 0
|
tabSelection.selectedTab = 0
|
||||||
dismiss()
|
dismiss()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
|
||||||
import StoreKit
|
import StoreKit
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
private let iapService: IAPService
|
private let iapService: IAPService
|
||||||
@@ -67,6 +68,9 @@ struct SettingsView: View {
|
|||||||
.sheet(isPresented: $viewModel.showingExportOptions) {
|
.sheet(isPresented: $viewModel.showingExportOptions) {
|
||||||
ExportOptionsSheet(viewModel: viewModel)
|
ExportOptionsSheet(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
|
.sheet(item: $viewModel.shareItem) { shareItem in
|
||||||
|
ActivityView(activityItems: [shareItem.url])
|
||||||
|
}
|
||||||
.sheet(isPresented: $viewModel.showingImportSheet) {
|
.sheet(isPresented: $viewModel.showingImportSheet) {
|
||||||
ImportDataView()
|
ImportDataView()
|
||||||
}
|
}
|
||||||
@@ -578,6 +582,22 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Activity View
|
||||||
|
|
||||||
|
struct ActivityView: UIViewControllerRepresentable {
|
||||||
|
let activityItems: [Any]
|
||||||
|
let applicationActivities: [UIActivity]? = nil
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||||
|
UIActivityViewController(
|
||||||
|
activityItems: activityItems,
|
||||||
|
applicationActivities: applicationActivities
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Export Options Sheet
|
// MARK: - Export Options Sheet
|
||||||
|
|
||||||
struct ExportOptionsSheet: View {
|
struct ExportOptionsSheet: View {
|
||||||
@@ -587,10 +607,24 @@ struct ExportOptionsSheet: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
|
if viewModel.isExporting {
|
||||||
|
Section {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
ProgressView(value: viewModel.exportProgress)
|
||||||
|
.progressViewStyle(.linear)
|
||||||
|
|
||||||
|
Text(viewModel.exportStatus)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
} header: {
|
||||||
|
Text("Exporting...")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
Section {
|
Section {
|
||||||
Button {
|
Button {
|
||||||
viewModel.exportData(format: .csv)
|
viewModel.exportData(format: .csv)
|
||||||
dismiss()
|
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "tablecells")
|
Image(systemName: "tablecells")
|
||||||
@@ -609,7 +643,6 @@ struct ExportOptionsSheet: View {
|
|||||||
|
|
||||||
Button {
|
Button {
|
||||||
viewModel.exportData(format: .json)
|
viewModel.exportData(format: .json)
|
||||||
dismiss()
|
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "doc.text")
|
Image(systemName: "doc.text")
|
||||||
@@ -629,17 +662,20 @@ struct ExportOptionsSheet: View {
|
|||||||
Text("Select Format")
|
Text("Select Format")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Export Data")
|
}
|
||||||
|
.navigationTitle(viewModel.isExporting ? "Exporting" : "Export Data")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button("Cancel") {
|
Button("Cancel") {
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
.disabled(viewModel.isExporting)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.presentationDetents([.medium])
|
.presentationDetents([.medium])
|
||||||
|
.interactiveDismissDisabled(viewModel.isExporting)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,19 +10,28 @@ struct AddSourceView: View {
|
|||||||
@State private var initialValue = ""
|
@State private var initialValue = ""
|
||||||
@State private var showingCategoryPicker = false
|
@State private var showingCategoryPicker = false
|
||||||
@State private var selectedAccountId: UUID?
|
@State private var selectedAccountId: UUID?
|
||||||
|
@State private var duplicateError: String?
|
||||||
|
|
||||||
@StateObject private var categoryRepository = CategoryRepository()
|
@StateObject private var categoryRepository = CategoryRepository()
|
||||||
|
@StateObject private var sourceRepository = InvestmentSourceRepository()
|
||||||
|
|
||||||
|
private var availableAccounts: [Account] {
|
||||||
|
accountStore.accounts.filter { $0.safeId != nil }
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
if accountStore.accounts.count > 1 {
|
if availableAccounts.count > 1 {
|
||||||
Section {
|
Section {
|
||||||
Picker("Account", selection: $selectedAccountId) {
|
Picker("Account", selection: $selectedAccountId) {
|
||||||
ForEach(accountStore.accounts) { account in
|
ForEach(availableAccounts, id: \.objectID) { account in
|
||||||
Text(account.name).tag(Optional(account.id))
|
Text(account.name).tag(Optional(account.safeId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: selectedAccountId) { _, _ in
|
||||||
|
validateSourceName(name)
|
||||||
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Account")
|
Text("Account")
|
||||||
}
|
}
|
||||||
@@ -32,6 +41,9 @@ struct AddSourceView: View {
|
|||||||
Section {
|
Section {
|
||||||
TextField("Source Name", text: $name)
|
TextField("Source Name", text: $name)
|
||||||
.textContentType(.organizationName)
|
.textContentType(.organizationName)
|
||||||
|
.onChange(of: name) { _, newValue in
|
||||||
|
validateSourceName(newValue)
|
||||||
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
showingCategoryPicker = true
|
showingCategoryPicker = true
|
||||||
@@ -61,6 +73,11 @@ struct AddSourceView: View {
|
|||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Source Information")
|
Text("Source Information")
|
||||||
|
} footer: {
|
||||||
|
if let error = duplicateError {
|
||||||
|
Text(error)
|
||||||
|
.foregroundColor(.negativeRed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial Value (Optional)
|
// Initial Value (Optional)
|
||||||
@@ -107,7 +124,7 @@ struct AddSourceView: View {
|
|||||||
selectedCategory = categoryRepository.categories.first
|
selectedCategory = categoryRepository.categories.first
|
||||||
}
|
}
|
||||||
if selectedAccountId == nil {
|
if selectedAccountId == nil {
|
||||||
selectedAccountId = accountStore.selectedAccount?.id ?? accountStore.accounts.first?.id
|
selectedAccountId = accountStore.selectedAccount?.safeId ?? availableAccounts.first?.safeId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,7 +133,21 @@ struct AddSourceView: View {
|
|||||||
// MARK: - Validation
|
// MARK: - Validation
|
||||||
|
|
||||||
private var isValid: Bool {
|
private var isValid: Bool {
|
||||||
!name.trimmingCharacters(in: .whitespaces).isEmpty && selectedCategory != nil
|
!name.trimmingCharacters(in: .whitespaces).isEmpty && selectedCategory != nil && duplicateError == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func validateSourceName(_ newName: String) {
|
||||||
|
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else {
|
||||||
|
duplicateError = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if sourceRepository.sourceExists(name: trimmed, in: selectedAccount) {
|
||||||
|
duplicateError = "A source with this name already exists."
|
||||||
|
} else {
|
||||||
|
duplicateError = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Save
|
// MARK: - Save
|
||||||
@@ -177,7 +208,7 @@ struct AddSourceView: View {
|
|||||||
|
|
||||||
private var selectedAccount: Account? {
|
private var selectedAccount: Account? {
|
||||||
guard let id = selectedAccountId else { return nil }
|
guard let id = selectedAccountId else { return nil }
|
||||||
return accountStore.accounts.first { $0.id == id }
|
return availableAccounts.first { $0.safeId == id }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,17 +279,17 @@ struct EditSourceView: View {
|
|||||||
self.source = source
|
self.source = source
|
||||||
_name = State(initialValue: source.name)
|
_name = State(initialValue: source.name)
|
||||||
_selectedCategory = State(initialValue: source.category)
|
_selectedCategory = State(initialValue: source.category)
|
||||||
_selectedAccountId = State(initialValue: source.account?.id)
|
_selectedAccountId = State(initialValue: source.account?.safeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
if accountStore.accounts.count > 1 {
|
if availableAccounts.count > 1 {
|
||||||
Section {
|
Section {
|
||||||
Picker("Account", selection: $selectedAccountId) {
|
Picker("Account", selection: $selectedAccountId) {
|
||||||
ForEach(accountStore.accounts) { account in
|
ForEach(availableAccounts, id: \.objectID) { account in
|
||||||
Text(account.name).tag(Optional(account.id))
|
Text(account.name).tag(Optional(account.safeId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
@@ -320,6 +351,10 @@ struct EditSourceView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var availableAccounts: [Account] {
|
||||||
|
accountStore.accounts.filter { $0.safeId != nil }
|
||||||
|
}
|
||||||
|
|
||||||
private var isValid: Bool {
|
private var isValid: Bool {
|
||||||
!name.trimmingCharacters(in: .whitespaces).isEmpty && selectedCategory != nil
|
!name.trimmingCharacters(in: .whitespaces).isEmpty && selectedCategory != nil
|
||||||
}
|
}
|
||||||
@@ -343,7 +378,7 @@ struct EditSourceView: View {
|
|||||||
|
|
||||||
private var selectedAccount: Account? {
|
private var selectedAccount: Account? {
|
||||||
guard let id = selectedAccountId else { return nil }
|
guard let id = selectedAccountId else { return nil }
|
||||||
return accountStore.accounts.first { $0.id == id }
|
return availableAccounts.first { $0.safeId == id }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -424,7 +424,10 @@ struct SourceDetailView: View {
|
|||||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use LazyVStack for better performance with many snapshots
|
||||||
|
LazyVStack(spacing: 0) {
|
||||||
ForEach(viewModel.visibleSnapshots) { snapshot in
|
ForEach(viewModel.visibleSnapshots) { snapshot in
|
||||||
|
VStack(spacing: 0) {
|
||||||
SnapshotRowView(snapshot: snapshot, onEdit: {
|
SnapshotRowView(snapshot: snapshot, onEdit: {
|
||||||
editingSnapshot = snapshot
|
editingSnapshot = snapshot
|
||||||
})
|
})
|
||||||
@@ -457,9 +460,12 @@ struct SourceDetailView: View {
|
|||||||
Divider()
|
Divider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if viewModel.snapshots.count > 10 {
|
// Show "more snapshots" only for free users who have limited history
|
||||||
Text("+ \(viewModel.snapshots.count - 10) more snapshots")
|
if viewModel.isHistoryLimited && viewModel.hiddenSnapshotCount > 0 {
|
||||||
|
Text("+ \(viewModel.hiddenSnapshotCount) older snapshots hidden")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,13 +234,13 @@ struct SourceListView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
ForEach(accountStore.accounts) { account in
|
ForEach(availableAccounts, id: \.objectID) { account in
|
||||||
Button {
|
Button {
|
||||||
accountStore.selectAccount(account)
|
accountStore.selectAccount(account)
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Text(account.name)
|
Text(account.name)
|
||||||
if accountStore.selectedAccount?.id == account.id && !accountStore.showAllAccounts {
|
if accountStore.selectedAccount?.safeId == account.safeId && !accountStore.showAllAccounts {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,6 +257,10 @@ struct SourceListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var availableAccounts: [Account] {
|
||||||
|
accountStore.accounts.filter { $0.safeId != nil }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Source Row View
|
// MARK: - Source Row View
|
||||||
|
|||||||
Reference in New Issue
Block a user