initial version
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
|
||||
@MainActor
|
||||
class AccountStore: ObservableObject {
|
||||
@Published private(set) var accounts: [Account] = []
|
||||
@Published var selectedAccount: Account?
|
||||
@Published var showAllAccounts = true
|
||||
|
||||
private let accountRepository: AccountRepository
|
||||
private let iapService: IAPService
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
accountRepository: AccountRepository? = nil,
|
||||
iapService: IAPService
|
||||
) {
|
||||
self.accountRepository = accountRepository ?? AccountRepository()
|
||||
self.iapService = iapService
|
||||
|
||||
self.accountRepository.fetchAccounts()
|
||||
let defaultAccount = self.accountRepository.createDefaultAccountIfNeeded()
|
||||
accounts = self.accountRepository.accounts
|
||||
selectedAccount = defaultAccount
|
||||
|
||||
loadSelection()
|
||||
setupObservers()
|
||||
}
|
||||
|
||||
private func setupObservers() {
|
||||
accountRepository.$accounts
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] accounts in
|
||||
self?.accounts = accounts
|
||||
self?.syncSelectedAccount()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func loadSelection() {
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
let settings = AppSettings.getOrCreate(in: context)
|
||||
showAllAccounts = settings.showAllAccounts
|
||||
|
||||
if let selectedId = settings.selectedAccountId,
|
||||
let account = accountRepository.fetchAccount(by: selectedId) {
|
||||
selectedAccount = account
|
||||
} else {
|
||||
selectedAccount = accounts.first
|
||||
}
|
||||
}
|
||||
|
||||
private func syncSelectedAccount() {
|
||||
if showAllAccounts { return }
|
||||
if let selected = selectedAccount,
|
||||
accounts.contains(where: { $0.id == selected.id }) {
|
||||
return
|
||||
}
|
||||
selectedAccount = accounts.first
|
||||
}
|
||||
|
||||
func selectAllAccounts() {
|
||||
showAllAccounts = true
|
||||
persistSelection()
|
||||
}
|
||||
|
||||
func selectAccount(_ account: Account) {
|
||||
showAllAccounts = false
|
||||
selectedAccount = account
|
||||
persistSelection()
|
||||
}
|
||||
|
||||
func persistSelection() {
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
let settings = AppSettings.getOrCreate(in: context)
|
||||
settings.showAllAccounts = showAllAccounts
|
||||
settings.selectedAccountId = showAllAccounts ? nil : selectedAccount?.id
|
||||
CoreDataStack.shared.save()
|
||||
}
|
||||
|
||||
func canAddAccount() -> Bool {
|
||||
iapService.isPremium || accounts.count < 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import GoogleMobileAds
|
||||
import AppTrackingTransparency
|
||||
import AdSupport
|
||||
|
||||
@MainActor
|
||||
class AdMobService: ObservableObject {
|
||||
// MARK: - Published Properties
|
||||
|
||||
@Published var isConsentObtained = false
|
||||
@Published var canShowAds = false
|
||||
@Published var isLoading = false
|
||||
|
||||
// MARK: - Ad Unit IDs
|
||||
|
||||
// Test Ad Unit IDs - Replace with production IDs before release
|
||||
#if DEBUG
|
||||
static let bannerAdUnitID = "ca-app-pub-3940256099942544/2934735716"
|
||||
#else
|
||||
static let bannerAdUnitID = "ca-app-pub-XXXXXXXXXXXXXXXX/YYYYYYYYYY" // Replace with your Ad Unit ID
|
||||
#endif
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
checkConsentStatus()
|
||||
}
|
||||
|
||||
// MARK: - Consent Management
|
||||
|
||||
func checkConsentStatus() {
|
||||
// Check if we already have consent
|
||||
let consentStatus = UserDefaults.standard.bool(forKey: "adConsentObtained")
|
||||
isConsentObtained = consentStatus
|
||||
canShowAds = consentStatus
|
||||
}
|
||||
|
||||
func requestConsent() async {
|
||||
if #available(iOS 14.5, *) {
|
||||
let status = await ATTrackingManager.requestTrackingAuthorization()
|
||||
|
||||
switch status {
|
||||
case .authorized:
|
||||
isConsentObtained = true
|
||||
canShowAds = true
|
||||
case .denied, .restricted:
|
||||
// Can still show non-personalized ads
|
||||
isConsentObtained = true
|
||||
canShowAds = true
|
||||
case .notDetermined:
|
||||
// Will be asked again later
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// iOS 14.4 and earlier - consent assumed
|
||||
isConsentObtained = true
|
||||
canShowAds = true
|
||||
}
|
||||
|
||||
UserDefaults.standard.set(isConsentObtained, forKey: "adConsentObtained")
|
||||
}
|
||||
|
||||
// MARK: - GDPR Consent (UMP SDK)
|
||||
|
||||
func requestGDPRConsent() async {
|
||||
// Implement UMP SDK consent flow if targeting EU users
|
||||
// This is a simplified version - full implementation requires UMP SDK
|
||||
|
||||
let isEUUser = isUserInEU()
|
||||
|
||||
if isEUUser {
|
||||
// Show GDPR consent dialog
|
||||
// For now, assume consent if user continues
|
||||
isConsentObtained = true
|
||||
canShowAds = true
|
||||
} else {
|
||||
isConsentObtained = true
|
||||
canShowAds = true
|
||||
}
|
||||
|
||||
UserDefaults.standard.set(isConsentObtained, forKey: "adConsentObtained")
|
||||
}
|
||||
|
||||
private func isUserInEU() -> Bool {
|
||||
let euCountries = [
|
||||
"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
|
||||
"DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
|
||||
"PL", "PT", "RO", "SK", "SI", "ES", "SE", "GB", "IS", "LI",
|
||||
"NO", "CH"
|
||||
]
|
||||
|
||||
let countryCode = Locale.current.region?.identifier ?? ""
|
||||
return euCountries.contains(countryCode)
|
||||
}
|
||||
|
||||
// MARK: - Analytics
|
||||
|
||||
func logAdImpression() {
|
||||
FirebaseService.shared.logAdImpression(adType: "banner")
|
||||
}
|
||||
|
||||
func logAdClick() {
|
||||
FirebaseService.shared.logAdClick(adType: "banner")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Banner Ad Coordinator
|
||||
|
||||
class BannerAdCoordinator: NSObject, BannerViewDelegate {
|
||||
weak var adMobService: AdMobService?
|
||||
|
||||
func bannerViewDidReceiveAd(_ bannerView: BannerView) {
|
||||
print("Banner ad received")
|
||||
adMobService?.logAdImpression()
|
||||
}
|
||||
|
||||
func bannerView(_ bannerView: BannerView, didFailToReceiveAdWithError error: Error) {
|
||||
print("Banner ad failed to load: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
func bannerViewDidRecordImpression(_ bannerView: BannerView) {
|
||||
// Impression recorded
|
||||
}
|
||||
|
||||
func bannerViewDidRecordClick(_ bannerView: BannerView) {
|
||||
adMobService?.logAdClick()
|
||||
}
|
||||
|
||||
func bannerViewWillPresentScreen(_ bannerView: BannerView) {
|
||||
// Ad will present full screen
|
||||
}
|
||||
|
||||
func bannerViewWillDismissScreen(_ bannerView: BannerView) {
|
||||
// Ad will dismiss
|
||||
}
|
||||
|
||||
func bannerViewDidDismissScreen(_ bannerView: BannerView) {
|
||||
// Ad dismissed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIKit Banner View Wrapper
|
||||
|
||||
struct BannerAdView: UIViewRepresentable {
|
||||
@EnvironmentObject var adMobService: AdMobService
|
||||
|
||||
func makeUIView(context: Context) -> BannerView {
|
||||
let bannerView = BannerView(adSize: AdSizeBanner)
|
||||
bannerView.adUnitID = AdMobService.bannerAdUnitID
|
||||
|
||||
// Get root view controller
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let rootViewController = windowScene.windows.first?.rootViewController {
|
||||
bannerView.rootViewController = rootViewController
|
||||
}
|
||||
|
||||
bannerView.delegate = context.coordinator
|
||||
bannerView.load(Request())
|
||||
|
||||
return bannerView
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: BannerView, context: Context) {
|
||||
// Banner updates automatically
|
||||
}
|
||||
|
||||
func makeCoordinator() -> BannerAdCoordinator {
|
||||
let coordinator = BannerAdCoordinator()
|
||||
coordinator.adMobService = adMobService
|
||||
return coordinator
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
import Foundation
|
||||
|
||||
class CalculationService {
|
||||
static let shared = CalculationService()
|
||||
|
||||
// MARK: - Performance: Caching
|
||||
private var cachedMonthlyReturns: [ObjectIdentifier: [InvestmentMetrics.MonthlyReturn]] = [:]
|
||||
private var cacheVersion: Int = 0
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Call this when underlying data changes to invalidate caches
|
||||
func invalidateCache() {
|
||||
cachedMonthlyReturns.removeAll()
|
||||
cacheVersion += 1
|
||||
}
|
||||
|
||||
// MARK: - Shared DateFormatter (avoid repeated allocations)
|
||||
private static let monthYearFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
// MARK: - Portfolio Summary
|
||||
|
||||
func calculatePortfolioSummary(
|
||||
from sources: [InvestmentSource],
|
||||
snapshots: [Snapshot]
|
||||
) -> PortfolioSummary {
|
||||
let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
let totalContributions = sources.reduce(Decimal.zero) { $0 + $1.totalContributions }
|
||||
|
||||
// Calculate period changes
|
||||
let now = Date()
|
||||
let dayAgo = Calendar.current.date(byAdding: .day, value: -1, to: now) ?? now
|
||||
let weekAgo = Calendar.current.date(byAdding: .weekOfYear, value: -1, to: now) ?? now
|
||||
let monthAgo = Calendar.current.date(byAdding: .month, value: -1, to: now) ?? now
|
||||
let yearAgo = Calendar.current.date(byAdding: .year, value: -1, to: now) ?? now
|
||||
|
||||
let dayChange = calculatePeriodChange(sources: sources, from: dayAgo)
|
||||
let weekChange = calculatePeriodChange(sources: sources, from: weekAgo)
|
||||
let monthChange = calculatePeriodChange(sources: sources, from: monthAgo)
|
||||
let yearChange = calculatePeriodChange(sources: sources, from: yearAgo)
|
||||
|
||||
let allTimeReturn = totalValue - totalContributions
|
||||
let allTimeReturnPercentage = totalContributions > 0
|
||||
? NSDecimalNumber(decimal: allTimeReturn / totalContributions).doubleValue * 100
|
||||
: 0
|
||||
|
||||
let lastUpdated = snapshots.map { $0.date }.max()
|
||||
|
||||
return PortfolioSummary(
|
||||
totalValue: totalValue,
|
||||
totalContributions: totalContributions,
|
||||
dayChange: dayChange.absolute,
|
||||
dayChangePercentage: dayChange.percentage,
|
||||
weekChange: weekChange.absolute,
|
||||
weekChangePercentage: weekChange.percentage,
|
||||
monthChange: monthChange.absolute,
|
||||
monthChangePercentage: monthChange.percentage,
|
||||
yearChange: yearChange.absolute,
|
||||
yearChangePercentage: yearChange.percentage,
|
||||
allTimeReturn: allTimeReturn,
|
||||
allTimeReturnPercentage: allTimeReturnPercentage,
|
||||
sourceCount: sources.count,
|
||||
lastUpdated: lastUpdated
|
||||
)
|
||||
}
|
||||
|
||||
private func calculatePeriodChange(
|
||||
sources: [InvestmentSource],
|
||||
from startDate: Date
|
||||
) -> (absolute: Decimal, percentage: Double) {
|
||||
var previousTotal: Decimal = 0
|
||||
var currentTotal: Decimal = 0
|
||||
|
||||
for source in sources {
|
||||
currentTotal += source.latestValue
|
||||
|
||||
// Find snapshot closest to start date
|
||||
let snapshots = source.sortedSnapshotsByDateAscending
|
||||
let previousSnapshot = snapshots.last { $0.date <= startDate } ?? snapshots.first
|
||||
previousTotal += previousSnapshot?.decimalValue ?? 0
|
||||
}
|
||||
|
||||
let absolute = currentTotal - previousTotal
|
||||
let percentage = previousTotal > 0
|
||||
? NSDecimalNumber(decimal: absolute / previousTotal).doubleValue * 100
|
||||
: 0
|
||||
|
||||
return (absolute, percentage)
|
||||
}
|
||||
|
||||
// MARK: - Investment Metrics
|
||||
|
||||
func calculateMetrics(for snapshots: [Snapshot]) -> InvestmentMetrics {
|
||||
guard !snapshots.isEmpty else { return .empty }
|
||||
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
let values = sortedSnapshots.map { $0.decimalValue }
|
||||
|
||||
guard let firstValue = values.first,
|
||||
let lastValue = values.last,
|
||||
firstValue != 0 else {
|
||||
return .empty
|
||||
}
|
||||
|
||||
let totalValue = lastValue
|
||||
let totalContributions = sortedSnapshots.reduce(Decimal.zero) { $0 + $1.decimalContribution }
|
||||
let absoluteReturn = lastValue - firstValue
|
||||
let percentageReturn = (absoluteReturn / firstValue) * 100
|
||||
|
||||
// Calculate monthly returns for advanced metrics
|
||||
let monthlyReturns = calculateMonthlyReturns(from: sortedSnapshots)
|
||||
|
||||
let cagr = calculateCAGR(
|
||||
startValue: firstValue,
|
||||
endValue: lastValue,
|
||||
startDate: sortedSnapshots.first?.date ?? Date(),
|
||||
endDate: sortedSnapshots.last?.date ?? Date()
|
||||
)
|
||||
|
||||
let twr = calculateTWR(snapshots: sortedSnapshots)
|
||||
let volatility = calculateVolatility(monthlyReturns: monthlyReturns)
|
||||
let maxDrawdown = calculateMaxDrawdown(values: values)
|
||||
let sharpeRatio = calculateSharpeRatio(
|
||||
averageReturn: monthlyReturns.map { $0.returnPercentage }.average(),
|
||||
volatility: volatility
|
||||
)
|
||||
|
||||
let winRate = calculateWinRate(monthlyReturns: monthlyReturns)
|
||||
let averageMonthlyReturn = monthlyReturns.map { $0.returnPercentage }.average()
|
||||
|
||||
return InvestmentMetrics(
|
||||
totalValue: totalValue,
|
||||
totalContributions: totalContributions,
|
||||
absoluteReturn: absoluteReturn,
|
||||
percentageReturn: percentageReturn,
|
||||
cagr: cagr,
|
||||
twr: twr,
|
||||
volatility: volatility,
|
||||
maxDrawdown: maxDrawdown,
|
||||
sharpeRatio: sharpeRatio,
|
||||
bestMonth: monthlyReturns.max(by: { $0.returnPercentage < $1.returnPercentage }),
|
||||
worstMonth: monthlyReturns.min(by: { $0.returnPercentage < $1.returnPercentage }),
|
||||
winRate: winRate,
|
||||
averageMonthlyReturn: averageMonthlyReturn,
|
||||
startDate: sortedSnapshots.first?.date,
|
||||
endDate: sortedSnapshots.last?.date,
|
||||
totalMonths: monthlyReturns.count
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Monthly Summary
|
||||
|
||||
func calculateMonthlySummary(
|
||||
sources: [InvestmentSource],
|
||||
snapshots: [Snapshot],
|
||||
range: DateRange = .thisMonth
|
||||
) -> MonthlySummary {
|
||||
let startDate = range.start
|
||||
let endDate = range.end
|
||||
|
||||
var startingValue: Decimal = 0
|
||||
var endingValue: Decimal = 0
|
||||
var contributions: Decimal = 0
|
||||
|
||||
let snapshotsBySource = Dictionary(grouping: snapshots) { $0.source?.id }
|
||||
|
||||
for source in sources {
|
||||
let sourceSnapshots = snapshotsBySource[source.id] ?? []
|
||||
let sorted = sourceSnapshots.sorted { $0.date < $1.date }
|
||||
|
||||
let startSnapshot = sorted.last { $0.date <= startDate }
|
||||
let endSnapshot = sorted.last { $0.date <= endDate }
|
||||
|
||||
startingValue += startSnapshot?.decimalValue ?? 0
|
||||
endingValue += endSnapshot?.decimalValue ?? 0
|
||||
|
||||
let rangeContributions = sorted
|
||||
.filter { $0.date >= startDate && $0.date <= endDate }
|
||||
.reduce(Decimal.zero) { $0 + $1.decimalContribution }
|
||||
contributions += rangeContributions
|
||||
}
|
||||
|
||||
let netPerformance = endingValue - startingValue - contributions
|
||||
|
||||
return MonthlySummary(
|
||||
periodLabel: "This Month",
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
startingValue: startingValue,
|
||||
endingValue: endingValue,
|
||||
contributions: contributions,
|
||||
netPerformance: netPerformance
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - CAGR (Compound Annual Growth Rate)
|
||||
|
||||
func calculateCAGR(
|
||||
startValue: Decimal,
|
||||
endValue: Decimal,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
) -> Double {
|
||||
guard startValue > 0 else { return 0 }
|
||||
|
||||
let years = Calendar.current.dateComponents(
|
||||
[.day],
|
||||
from: startDate,
|
||||
to: endDate
|
||||
).day.map { Double($0) / 365.25 } ?? 0
|
||||
|
||||
guard years > 0 else { return 0 }
|
||||
|
||||
let ratio = NSDecimalNumber(decimal: endValue / startValue).doubleValue
|
||||
let cagr = pow(ratio, 1 / years) - 1
|
||||
|
||||
return cagr * 100
|
||||
}
|
||||
|
||||
// MARK: - TWR (Time-Weighted Return)
|
||||
|
||||
func calculateTWR(snapshots: [Snapshot]) -> Double {
|
||||
guard snapshots.count >= 2 else { return 0 }
|
||||
|
||||
var twr: Double = 1.0
|
||||
|
||||
for i in 1..<snapshots.count {
|
||||
let previousValue = snapshots[i-1].decimalValue
|
||||
let currentValue = snapshots[i].decimalValue
|
||||
let contribution = snapshots[i].decimalContribution
|
||||
|
||||
guard previousValue > 0 else { continue }
|
||||
|
||||
// Adjust for contributions
|
||||
let adjustedPreviousValue = previousValue + contribution
|
||||
let periodReturn = NSDecimalNumber(
|
||||
decimal: currentValue / adjustedPreviousValue
|
||||
).doubleValue
|
||||
|
||||
twr *= periodReturn
|
||||
}
|
||||
|
||||
return (twr - 1) * 100
|
||||
}
|
||||
|
||||
// MARK: - Volatility (Annualized Standard Deviation)
|
||||
|
||||
func calculateVolatility(monthlyReturns: [InvestmentMetrics.MonthlyReturn]) -> Double {
|
||||
let returns = monthlyReturns.map { $0.returnPercentage }
|
||||
guard returns.count >= 2 else { return 0 }
|
||||
|
||||
let mean = returns.average()
|
||||
let squaredDifferences = returns.map { pow($0 - mean, 2) }
|
||||
let variance = squaredDifferences.reduce(0, +) / Double(returns.count - 1)
|
||||
let stdDev = sqrt(variance)
|
||||
|
||||
// Annualize (multiply by sqrt(12) for monthly data)
|
||||
return stdDev * sqrt(12)
|
||||
}
|
||||
|
||||
// MARK: - Max Drawdown
|
||||
|
||||
func calculateMaxDrawdown(values: [Decimal]) -> Double {
|
||||
guard !values.isEmpty else { return 0 }
|
||||
|
||||
var maxDrawdown: Double = 0
|
||||
var peak = values[0]
|
||||
|
||||
for value in values {
|
||||
if value > peak {
|
||||
peak = value
|
||||
}
|
||||
|
||||
guard peak > 0 else { continue }
|
||||
|
||||
let drawdown = NSDecimalNumber(
|
||||
decimal: (peak - value) / peak
|
||||
).doubleValue * 100
|
||||
|
||||
maxDrawdown = max(maxDrawdown, drawdown)
|
||||
}
|
||||
|
||||
return maxDrawdown
|
||||
}
|
||||
|
||||
// MARK: - Sharpe Ratio
|
||||
|
||||
func calculateSharpeRatio(
|
||||
averageReturn: Double,
|
||||
volatility: Double,
|
||||
riskFreeRate: Double = 2.0 // Assume 2% annual risk-free rate
|
||||
) -> Double {
|
||||
guard volatility > 0 else { return 0 }
|
||||
|
||||
// Convert to monthly risk-free rate
|
||||
let monthlyRiskFree = riskFreeRate / 12
|
||||
|
||||
return (averageReturn - monthlyRiskFree) / volatility * sqrt(12)
|
||||
}
|
||||
|
||||
// MARK: - Win Rate
|
||||
|
||||
func calculateWinRate(monthlyReturns: [InvestmentMetrics.MonthlyReturn]) -> Double {
|
||||
guard !monthlyReturns.isEmpty else { return 0 }
|
||||
|
||||
let positiveMonths = monthlyReturns.filter { $0.returnPercentage > 0 }.count
|
||||
return Double(positiveMonths) / Double(monthlyReturns.count) * 100
|
||||
}
|
||||
|
||||
// MARK: - Monthly Returns
|
||||
|
||||
func calculateMonthlyReturns(from snapshots: [Snapshot]) -> [InvestmentMetrics.MonthlyReturn] {
|
||||
guard snapshots.count >= 2 else { return [] }
|
||||
|
||||
// Performance: Use shared formatter instead of creating new one each call
|
||||
let formatter = Self.monthYearFormatter
|
||||
|
||||
// Group snapshots by month - pre-allocate capacity
|
||||
var monthlySnapshots: [String: [Snapshot]] = [:]
|
||||
monthlySnapshots.reserveCapacity(min(snapshots.count, 60)) // Reasonable max months
|
||||
|
||||
for snapshot in snapshots {
|
||||
let key = formatter.string(from: snapshot.date)
|
||||
monthlySnapshots[key, default: []].append(snapshot)
|
||||
}
|
||||
|
||||
// Sort months
|
||||
let sortedMonths = monthlySnapshots.keys.sorted()
|
||||
guard sortedMonths.count >= 2 else { return [] }
|
||||
|
||||
// Pre-allocate result array
|
||||
var monthlyReturns: [InvestmentMetrics.MonthlyReturn] = []
|
||||
monthlyReturns.reserveCapacity(sortedMonths.count - 1)
|
||||
|
||||
for i in 1..<sortedMonths.count {
|
||||
let previousMonth = sortedMonths[i-1]
|
||||
let currentMonth = sortedMonths[i]
|
||||
|
||||
guard let previousSnapshots = monthlySnapshots[previousMonth],
|
||||
let currentSnapshots = monthlySnapshots[currentMonth],
|
||||
let previousValue = previousSnapshots.last?.decimalValue,
|
||||
let currentValue = currentSnapshots.last?.decimalValue,
|
||||
previousValue > 0 else {
|
||||
continue
|
||||
}
|
||||
|
||||
let returnPercentage = NSDecimalNumber(
|
||||
decimal: (currentValue - previousValue) / previousValue
|
||||
).doubleValue * 100
|
||||
|
||||
if let date = formatter.date(from: currentMonth) {
|
||||
monthlyReturns.append(InvestmentMetrics.MonthlyReturn(
|
||||
date: date,
|
||||
returnPercentage: returnPercentage
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return monthlyReturns
|
||||
}
|
||||
|
||||
// MARK: - Category Metrics
|
||||
|
||||
func calculateCategoryMetrics(
|
||||
for categories: [Category],
|
||||
sources: [InvestmentSource],
|
||||
totalPortfolioValue: Decimal
|
||||
) -> [CategoryMetrics] {
|
||||
categories.map { category in
|
||||
let categorySources = sources.filter { $0.category?.id == category.id }
|
||||
let allSnapshots = categorySources.flatMap { $0.snapshotsArray }
|
||||
let metrics = calculateCategoryMetrics(from: allSnapshots)
|
||||
let categoryValue = categorySources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
|
||||
let percentage = totalPortfolioValue > 0
|
||||
? NSDecimalNumber(decimal: categoryValue / totalPortfolioValue).doubleValue * 100
|
||||
: 0
|
||||
|
||||
return CategoryMetrics(
|
||||
id: category.id,
|
||||
categoryName: category.name,
|
||||
colorHex: category.colorHex,
|
||||
icon: category.icon,
|
||||
totalValue: categoryValue,
|
||||
percentageOfPortfolio: percentage,
|
||||
metrics: metrics
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SeriesPoint {
|
||||
let date: Date
|
||||
let value: Decimal
|
||||
let contribution: Decimal
|
||||
}
|
||||
|
||||
private func calculateCategoryMetrics(from snapshots: [Snapshot]) -> InvestmentMetrics {
|
||||
let series = buildCategorySeries(from: snapshots)
|
||||
guard !series.isEmpty else { return .empty }
|
||||
|
||||
let sortedSeries = series.sorted { $0.date < $1.date }
|
||||
let values = sortedSeries.map { $0.value }
|
||||
|
||||
guard let firstValue = values.first,
|
||||
let lastValue = values.last,
|
||||
firstValue != 0 else {
|
||||
return .empty
|
||||
}
|
||||
|
||||
let totalValue = lastValue
|
||||
let totalContributions = sortedSeries.reduce(Decimal.zero) { $0 + $1.contribution }
|
||||
let absoluteReturn = lastValue - firstValue
|
||||
let percentageReturn = (absoluteReturn / firstValue) * 100
|
||||
|
||||
let monthlyReturns = calculateMonthlyReturns(from: sortedSeries)
|
||||
|
||||
let cagr = calculateCAGR(
|
||||
startValue: firstValue,
|
||||
endValue: lastValue,
|
||||
startDate: sortedSeries.first?.date ?? Date(),
|
||||
endDate: sortedSeries.last?.date ?? Date()
|
||||
)
|
||||
|
||||
let twr = calculateTWR(series: sortedSeries)
|
||||
let volatility = calculateVolatility(monthlyReturns: monthlyReturns)
|
||||
let maxDrawdown = calculateMaxDrawdown(values: values)
|
||||
let sharpeRatio = calculateSharpeRatio(
|
||||
averageReturn: monthlyReturns.map { $0.returnPercentage }.average(),
|
||||
volatility: volatility
|
||||
)
|
||||
|
||||
let winRate = calculateWinRate(monthlyReturns: monthlyReturns)
|
||||
let averageMonthlyReturn = monthlyReturns.map { $0.returnPercentage }.average()
|
||||
|
||||
return InvestmentMetrics(
|
||||
totalValue: totalValue,
|
||||
totalContributions: totalContributions,
|
||||
absoluteReturn: absoluteReturn,
|
||||
percentageReturn: percentageReturn,
|
||||
cagr: cagr,
|
||||
twr: twr,
|
||||
volatility: volatility,
|
||||
maxDrawdown: maxDrawdown,
|
||||
sharpeRatio: sharpeRatio,
|
||||
bestMonth: monthlyReturns.max(by: { $0.returnPercentage < $1.returnPercentage }),
|
||||
worstMonth: monthlyReturns.min(by: { $0.returnPercentage < $1.returnPercentage }),
|
||||
winRate: winRate,
|
||||
averageMonthlyReturn: averageMonthlyReturn,
|
||||
startDate: sortedSeries.first?.date,
|
||||
endDate: sortedSeries.last?.date,
|
||||
totalMonths: monthlyReturns.count
|
||||
)
|
||||
}
|
||||
|
||||
private func buildCategorySeries(from snapshots: [Snapshot]) -> [SeriesPoint] {
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
let uniqueDates = Array(Set(sortedSnapshots.map { Calendar.current.startOfDay(for: $0.date) }))
|
||||
.sorted()
|
||||
guard !uniqueDates.isEmpty else { return [] }
|
||||
|
||||
var snapshotsBySource: [UUID: [(date: Date, value: Decimal)]] = [:]
|
||||
var contributionsByDate: [Date: Decimal] = [:]
|
||||
|
||||
for snapshot in sortedSnapshots {
|
||||
guard let sourceId = snapshot.source?.id else { continue }
|
||||
snapshotsBySource[sourceId, default: []].append(
|
||||
(date: snapshot.date, value: snapshot.decimalValue)
|
||||
)
|
||||
let day = Calendar.current.startOfDay(for: snapshot.date)
|
||||
contributionsByDate[day, default: 0] += snapshot.decimalContribution
|
||||
}
|
||||
|
||||
var indices: [UUID: Int] = [:]
|
||||
var series: [SeriesPoint] = []
|
||||
|
||||
for (index, date) in uniqueDates.enumerated() {
|
||||
let nextDate = index + 1 < uniqueDates.count
|
||||
? uniqueDates[index + 1]
|
||||
: Date.distantFuture
|
||||
var total: Decimal = 0
|
||||
|
||||
for (sourceId, sourceSnapshots) in snapshotsBySource {
|
||||
var currentIndex = indices[sourceId] ?? 0
|
||||
var latest: (date: Date, value: Decimal)?
|
||||
|
||||
while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextDate {
|
||||
latest = sourceSnapshots[currentIndex]
|
||||
currentIndex += 1
|
||||
}
|
||||
|
||||
indices[sourceId] = currentIndex
|
||||
|
||||
if let latest {
|
||||
total += latest.value
|
||||
}
|
||||
}
|
||||
|
||||
series.append(
|
||||
SeriesPoint(
|
||||
date: date,
|
||||
value: total,
|
||||
contribution: contributionsByDate[date] ?? 0
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return series
|
||||
}
|
||||
|
||||
private func calculateMonthlyReturns(from series: [SeriesPoint]) -> [InvestmentMetrics.MonthlyReturn] {
|
||||
guard series.count >= 2 else { return [] }
|
||||
|
||||
// Performance: Use shared formatter
|
||||
let formatter = Self.monthYearFormatter
|
||||
|
||||
var monthlySeries: [String: [SeriesPoint]] = [:]
|
||||
monthlySeries.reserveCapacity(min(series.count, 60))
|
||||
|
||||
for point in series {
|
||||
let key = formatter.string(from: point.date)
|
||||
monthlySeries[key, default: []].append(point)
|
||||
}
|
||||
|
||||
let sortedMonths = monthlySeries.keys.sorted()
|
||||
guard sortedMonths.count >= 2 else { return [] }
|
||||
|
||||
var monthlyReturns: [InvestmentMetrics.MonthlyReturn] = []
|
||||
monthlyReturns.reserveCapacity(sortedMonths.count - 1)
|
||||
|
||||
for i in 1..<sortedMonths.count {
|
||||
let previousMonth = sortedMonths[i - 1]
|
||||
let currentMonth = sortedMonths[i]
|
||||
|
||||
guard let previousPoints = monthlySeries[previousMonth],
|
||||
let currentPoints = monthlySeries[currentMonth],
|
||||
let previousValue = previousPoints.last?.value,
|
||||
let currentValue = currentPoints.last?.value,
|
||||
previousValue > 0 else {
|
||||
continue
|
||||
}
|
||||
|
||||
let returnPercentage = NSDecimalNumber(
|
||||
decimal: (currentValue - previousValue) / previousValue
|
||||
).doubleValue * 100
|
||||
|
||||
if let date = formatter.date(from: currentMonth) {
|
||||
monthlyReturns.append(InvestmentMetrics.MonthlyReturn(
|
||||
date: date,
|
||||
returnPercentage: returnPercentage
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return monthlyReturns
|
||||
}
|
||||
|
||||
private func calculateTWR(series: [SeriesPoint]) -> Double {
|
||||
guard series.count >= 2 else { return 0 }
|
||||
|
||||
var twr: Double = 1.0
|
||||
for i in 1..<series.count {
|
||||
let previousValue = series[i - 1].value
|
||||
let currentValue = series[i].value
|
||||
let contribution = series[i].contribution
|
||||
|
||||
guard previousValue > 0 else { continue }
|
||||
|
||||
let periodReturn = (currentValue - contribution) / previousValue
|
||||
twr *= NSDecimalNumber(decimal: periodReturn).doubleValue
|
||||
}
|
||||
|
||||
return (twr - 1) * 100
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Array Extension
|
||||
|
||||
extension Array where Element == Double {
|
||||
func average() -> Double {
|
||||
guard !isEmpty else { return 0 }
|
||||
return reduce(0, +) / Double(count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
class ExportService {
|
||||
static let shared = ExportService()
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Export Formats
|
||||
|
||||
enum ExportFormat: String, CaseIterable, Identifiable {
|
||||
case csv = "CSV"
|
||||
case json = "JSON"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var fileExtension: String {
|
||||
switch self {
|
||||
case .csv: return "csv"
|
||||
case .json: return "json"
|
||||
}
|
||||
}
|
||||
|
||||
var mimeType: String {
|
||||
switch self {
|
||||
case .csv: return "text/csv"
|
||||
case .json: return "application/json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Export Data
|
||||
|
||||
func exportToCSV(
|
||||
sources: [InvestmentSource],
|
||||
categories: [Category]
|
||||
) -> String {
|
||||
let currencyCode = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
|
||||
var csv = "Account,Category,Source,Date,Value (\(currencyCode)),Contribution (\(currencyCode)),Notes\n"
|
||||
|
||||
for source in sources.sorted(by: { $0.name < $1.name }) {
|
||||
let accountName = source.account?.name ?? "Default"
|
||||
let categoryName = source.category?.name ?? "Uncategorized"
|
||||
|
||||
for snapshot in source.snapshotsArray {
|
||||
let date = formatDate(snapshot.date)
|
||||
let value = formatDecimal(snapshot.decimalValue)
|
||||
let contribution = snapshot.contribution != nil
|
||||
? formatDecimal(snapshot.decimalContribution)
|
||||
: ""
|
||||
let notes = escapeCSV(snapshot.notes ?? "")
|
||||
|
||||
csv += "\(escapeCSV(accountName)),\(escapeCSV(categoryName)),\(escapeCSV(source.name)),\(date),\(value),\(contribution),\(notes)\n"
|
||||
}
|
||||
}
|
||||
|
||||
return csv
|
||||
}
|
||||
|
||||
func exportToJSON(
|
||||
sources: [InvestmentSource],
|
||||
categories: [Category]
|
||||
) -> String {
|
||||
var exportData: [String: Any] = [:]
|
||||
exportData["exportDate"] = ISO8601DateFormatter().string(from: Date())
|
||||
exportData["version"] = 2
|
||||
exportData["currency"] = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
|
||||
|
||||
let accounts = Dictionary(grouping: sources) { $0.account?.id.uuidString ?? "default" }
|
||||
var accountsArray: [[String: Any]] = []
|
||||
|
||||
for (_, accountSources) in accounts {
|
||||
let account = accountSources.first?.account
|
||||
var accountDict: [String: Any] = [
|
||||
"name": account?.name ?? "Default",
|
||||
"currency": account?.currency ?? exportData["currency"] as? String ?? "EUR",
|
||||
"inputMode": account?.inputMode ?? InputMode.simple.rawValue,
|
||||
"notificationFrequency": account?.notificationFrequency ?? NotificationFrequency.monthly.rawValue,
|
||||
"customFrequencyMonths": account?.customFrequencyMonths ?? 1
|
||||
]
|
||||
|
||||
// Export categories for this account
|
||||
let categoriesById = Dictionary(uniqueKeysWithValues: categories.map { ($0.id, $0) })
|
||||
let sourcesByCategory = Dictionary(grouping: accountSources) { $0.category?.id ?? UUID() }
|
||||
var categoriesArray: [[String: Any]] = []
|
||||
|
||||
for (categoryId, categorySources) in sourcesByCategory {
|
||||
let category = categoriesById[categoryId]
|
||||
var categoryDict: [String: Any] = [
|
||||
"name": category?.name ?? "Uncategorized",
|
||||
"color": category?.colorHex ?? "#3B82F6",
|
||||
"icon": category?.icon ?? "chart.pie.fill"
|
||||
]
|
||||
|
||||
var sourcesArray: [[String: Any]] = []
|
||||
for source in categorySources {
|
||||
var sourceDict: [String: Any] = [
|
||||
"name": source.name,
|
||||
"isActive": source.isActive,
|
||||
"notificationFrequency": source.notificationFrequency
|
||||
]
|
||||
|
||||
var snapshotsArray: [[String: Any]] = []
|
||||
for snapshot in source.snapshotsArray {
|
||||
var snapshotDict: [String: Any] = [
|
||||
"date": ISO8601DateFormatter().string(from: snapshot.date),
|
||||
"value": NSDecimalNumber(decimal: snapshot.decimalValue).doubleValue
|
||||
]
|
||||
|
||||
if snapshot.contribution != nil {
|
||||
snapshotDict["contribution"] = NSDecimalNumber(
|
||||
decimal: snapshot.decimalContribution
|
||||
).doubleValue
|
||||
}
|
||||
|
||||
if let notes = snapshot.notes, !notes.isEmpty {
|
||||
snapshotDict["notes"] = notes
|
||||
}
|
||||
|
||||
snapshotsArray.append(snapshotDict)
|
||||
}
|
||||
|
||||
sourceDict["snapshots"] = snapshotsArray
|
||||
sourcesArray.append(sourceDict)
|
||||
}
|
||||
|
||||
categoryDict["sources"] = sourcesArray
|
||||
categoriesArray.append(categoryDict)
|
||||
}
|
||||
|
||||
accountDict["categories"] = categoriesArray
|
||||
accountsArray.append(accountDict)
|
||||
}
|
||||
|
||||
exportData["accounts"] = accountsArray
|
||||
|
||||
// Add summary
|
||||
let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
exportData["summary"] = [
|
||||
"totalSources": sources.count,
|
||||
"totalCategories": categories.count,
|
||||
"totalValue": NSDecimalNumber(decimal: totalValue).doubleValue,
|
||||
"totalSnapshots": sources.reduce(0) { $0 + $1.snapshotCount }
|
||||
]
|
||||
|
||||
// Convert to JSON
|
||||
do {
|
||||
let jsonData = try JSONSerialization.data(
|
||||
withJSONObject: exportData,
|
||||
options: [.prettyPrinted, .sortedKeys]
|
||||
)
|
||||
return String(data: jsonData, encoding: .utf8) ?? "{}"
|
||||
} catch {
|
||||
print("JSON export error: \(error)")
|
||||
return "{}"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Share
|
||||
|
||||
func share(
|
||||
format: ExportFormat,
|
||||
sources: [InvestmentSource],
|
||||
categories: [Category],
|
||||
from viewController: UIViewController
|
||||
) {
|
||||
let content: String
|
||||
let fileName: String
|
||||
|
||||
switch format {
|
||||
case .csv:
|
||||
content = exportToCSV(sources: sources, categories: categories)
|
||||
fileName = "investment_tracker_export.csv"
|
||||
case .json:
|
||||
content = exportToJSON(sources: sources, categories: categories)
|
||||
fileName = "investment_tracker_export.json"
|
||||
}
|
||||
|
||||
// Create temporary file
|
||||
let tempURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(fileName)
|
||||
|
||||
do {
|
||||
try content.write(to: tempURL, atomically: true, encoding: .utf8)
|
||||
|
||||
let activityVC = UIActivityViewController(
|
||||
activityItems: [tempURL],
|
||||
applicationActivities: nil
|
||||
)
|
||||
|
||||
// iPad support
|
||||
if let popover = activityVC.popoverPresentationController {
|
||||
popover.sourceView = viewController.view
|
||||
popover.sourceRect = CGRect(
|
||||
x: viewController.view.bounds.midX,
|
||||
y: viewController.view.bounds.midY,
|
||||
width: 0,
|
||||
height: 0
|
||||
)
|
||||
}
|
||||
|
||||
viewController.present(activityVC, animated: true) {
|
||||
FirebaseService.shared.logExportAttempt(
|
||||
format: format.rawValue,
|
||||
success: true
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
print("Export error: \(error)")
|
||||
FirebaseService.shared.logExportAttempt(
|
||||
format: format.rawValue,
|
||||
success: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private func formatDecimal(_ decimal: Decimal) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.minimumFractionDigits = 2
|
||||
formatter.maximumFractionDigits = 2
|
||||
formatter.decimalSeparator = "."
|
||||
formatter.groupingSeparator = ""
|
||||
return formatter.string(from: decimal as NSDecimalNumber) ?? "0.00"
|
||||
}
|
||||
|
||||
private func escapeCSV(_ value: String) -> String {
|
||||
var escaped = value
|
||||
if escaped.contains("\"") || escaped.contains(",") || escaped.contains("\n") {
|
||||
escaped = escaped.replacingOccurrences(of: "\"", with: "\"\"")
|
||||
escaped = "\"\(escaped)\""
|
||||
}
|
||||
return escaped
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Import Service (Future)
|
||||
|
||||
extension ExportService {
|
||||
func importFromCSV(_ content: String) -> (sources: [ImportedSource], errors: [String]) {
|
||||
// Future implementation for importing data
|
||||
return ([], ["Import not yet implemented"])
|
||||
}
|
||||
|
||||
struct ImportedSource {
|
||||
let name: String
|
||||
let categoryName: String
|
||||
let snapshots: [(date: Date, value: Decimal, contribution: Decimal?, notes: String?)]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import Foundation
|
||||
import FirebaseAnalytics
|
||||
import FirebaseCore
|
||||
|
||||
class FirebaseService {
|
||||
static let shared = FirebaseService()
|
||||
|
||||
private var isConfigured: Bool {
|
||||
FirebaseApp.app() != nil
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - User Properties
|
||||
|
||||
func setUserTier(_ tier: UserTier) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.setUserProperty(tier.rawValue, forName: "user_tier")
|
||||
}
|
||||
|
||||
enum UserTier: String {
|
||||
case free = "free"
|
||||
case premium = "premium"
|
||||
}
|
||||
|
||||
// MARK: - Screen Tracking
|
||||
|
||||
func logScreenView(screenName: String, screenClass: String? = nil) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent(AnalyticsEventScreenView, parameters: [
|
||||
AnalyticsParameterScreenName: screenName,
|
||||
AnalyticsParameterScreenClass: screenClass ?? screenName
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Investment Events
|
||||
|
||||
func logSourceAdded(categoryName: String, sourceCount: Int) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("source_added", parameters: [
|
||||
"category_name": categoryName,
|
||||
"total_sources": sourceCount
|
||||
])
|
||||
}
|
||||
|
||||
func logSourceDeleted(categoryName: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("source_deleted", parameters: [
|
||||
"category_name": categoryName
|
||||
])
|
||||
}
|
||||
|
||||
func logSnapshotAdded(sourceName: String, value: Decimal) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("snapshot_added", parameters: [
|
||||
"source_name": sourceName,
|
||||
"value": NSDecimalNumber(decimal: value).doubleValue
|
||||
])
|
||||
}
|
||||
|
||||
func logCategoryCreated(name: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("category_created", parameters: [
|
||||
"category_name": name
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Purchase Events
|
||||
|
||||
func logPaywallShown(trigger: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("paywall_shown", parameters: [
|
||||
"trigger": trigger
|
||||
])
|
||||
}
|
||||
|
||||
func logPurchaseAttempt(productId: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("purchase_attempt", parameters: [
|
||||
"product_id": productId
|
||||
])
|
||||
}
|
||||
|
||||
func logPurchaseSuccess(productId: String, price: Decimal, isFamilyShared: Bool) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent(AnalyticsEventPurchase, parameters: [
|
||||
AnalyticsParameterItemID: productId,
|
||||
AnalyticsParameterPrice: NSDecimalNumber(decimal: price).doubleValue,
|
||||
AnalyticsParameterCurrency: "EUR",
|
||||
"is_family_shared": isFamilyShared
|
||||
])
|
||||
}
|
||||
|
||||
func logPurchaseFailure(productId: String, error: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("purchase_failed", parameters: [
|
||||
"product_id": productId,
|
||||
"error": error
|
||||
])
|
||||
}
|
||||
|
||||
func logRestorePurchases(success: Bool) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("restore_purchases", parameters: [
|
||||
"success": success
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Feature Usage Events
|
||||
|
||||
func logChartViewed(chartType: String, isPremium: Bool) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("chart_viewed", parameters: [
|
||||
"chart_type": chartType,
|
||||
"is_premium_chart": isPremium
|
||||
])
|
||||
}
|
||||
|
||||
func logPredictionViewed(algorithm: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("prediction_viewed", parameters: [
|
||||
"algorithm": algorithm
|
||||
])
|
||||
}
|
||||
|
||||
func logExportAttempt(format: String, success: Bool) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("export_attempt", parameters: [
|
||||
"format": format,
|
||||
"success": success
|
||||
])
|
||||
}
|
||||
|
||||
func logNotificationScheduled(frequency: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("notification_scheduled", parameters: [
|
||||
"frequency": frequency
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Ad Events
|
||||
|
||||
func logAdImpression(adType: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("ad_impression", parameters: [
|
||||
"ad_type": adType
|
||||
])
|
||||
}
|
||||
|
||||
func logAdClick(adType: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("ad_click", parameters: [
|
||||
"ad_type": adType
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Engagement Events
|
||||
|
||||
func logOnboardingCompleted(stepCount: Int) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("onboarding_completed", parameters: [
|
||||
"steps_completed": stepCount
|
||||
])
|
||||
}
|
||||
|
||||
func logWidgetUsed(widgetType: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("widget_used", parameters: [
|
||||
"widget_type": widgetType
|
||||
])
|
||||
}
|
||||
|
||||
func logAppOpened(fromWidget: Bool) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("app_opened", parameters: [
|
||||
"from_widget": fromWidget
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Portfolio Events
|
||||
|
||||
func logPortfolioMilestone(totalValue: Decimal, milestone: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("portfolio_milestone", parameters: [
|
||||
"total_value": NSDecimalNumber(decimal: totalValue).doubleValue,
|
||||
"milestone": milestone
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Error Events
|
||||
|
||||
func logError(type: String, message: String, context: String? = nil) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("app_error", parameters: [
|
||||
"error_type": type,
|
||||
"error_message": message,
|
||||
"context": context ?? ""
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
class GoalShareService {
|
||||
static let shared = GoalShareService()
|
||||
|
||||
private init() {}
|
||||
|
||||
@MainActor
|
||||
func shareGoal(
|
||||
name: String,
|
||||
progress: Double,
|
||||
currentValue: Decimal,
|
||||
targetValue: Decimal
|
||||
) {
|
||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let viewController = windowScene.windows.first?.rootViewController else {
|
||||
return
|
||||
}
|
||||
|
||||
let card = GoalShareCardView(
|
||||
name: name,
|
||||
progress: progress,
|
||||
currentValue: currentValue,
|
||||
targetValue: targetValue
|
||||
)
|
||||
|
||||
if #available(iOS 16.0, *) {
|
||||
let renderer = ImageRenderer(content: card)
|
||||
let scale = viewController.view.window?.windowScene?.screen.scale
|
||||
?? viewController.traitCollection.displayScale
|
||||
renderer.scale = scale
|
||||
if let image = renderer.uiImage {
|
||||
let activityVC = UIActivityViewController(activityItems: [image], applicationActivities: nil)
|
||||
viewController.present(activityVC, animated: true)
|
||||
}
|
||||
} else {
|
||||
let text = "I am \(Int(progress * 100))% towards \(name) on Portfolio Journal!"
|
||||
let activityVC = UIActivityViewController(activityItems: [text], applicationActivities: nil)
|
||||
viewController.present(activityVC, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import Foundation
|
||||
import StoreKit
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
class IAPService: ObservableObject {
|
||||
// MARK: - Published Properties
|
||||
|
||||
@Published private(set) var isPremium = false
|
||||
@Published private(set) var products: [Product] = []
|
||||
@Published private(set) var purchaseState: PurchaseState = .idle
|
||||
@Published private(set) var isFamilyShared = false
|
||||
#if DEBUG
|
||||
@Published var debugOverrideEnabled = false
|
||||
#endif
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
static let premiumProductID = "com.portfoliojournal.premium"
|
||||
static let premiumPrice = "€4.69"
|
||||
|
||||
// MARK: - Private Properties
|
||||
|
||||
private var updateListenerTask: Task<Void, Error>?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private let sharedDefaults = UserDefaults(suiteName: AppConstants.appGroupIdentifier)
|
||||
|
||||
// MARK: - Purchase State
|
||||
|
||||
enum PurchaseState: Equatable {
|
||||
case idle
|
||||
case purchasing
|
||||
case purchased
|
||||
case failed(String)
|
||||
case restored
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
#if DEBUG
|
||||
debugOverrideEnabled = UserDefaults.standard.bool(forKey: "debugPremiumOverride")
|
||||
#endif
|
||||
updateListenerTask = listenForTransactions()
|
||||
|
||||
Task {
|
||||
await loadProducts()
|
||||
await updatePremiumStatus()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
updateListenerTask?.cancel()
|
||||
}
|
||||
|
||||
// MARK: - Load Products
|
||||
|
||||
func loadProducts() async {
|
||||
do {
|
||||
products = try await Product.products(for: [Self.premiumProductID])
|
||||
print("Loaded \(products.count) products")
|
||||
} catch {
|
||||
print("Failed to load products: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Purchase
|
||||
|
||||
func purchase() async throws {
|
||||
guard let product = products.first else {
|
||||
throw IAPError.productNotFound
|
||||
}
|
||||
|
||||
purchaseState = .purchasing
|
||||
|
||||
do {
|
||||
let result = try await product.purchase()
|
||||
|
||||
switch result {
|
||||
case .success(let verification):
|
||||
let transaction = try checkVerified(verification)
|
||||
|
||||
// Check if family shared
|
||||
isFamilyShared = transaction.ownershipType == .familyShared
|
||||
|
||||
await transaction.finish()
|
||||
await updatePremiumStatus()
|
||||
|
||||
purchaseState = .purchased
|
||||
|
||||
// Track analytics
|
||||
FirebaseService.shared.logPurchaseSuccess(
|
||||
productId: product.id,
|
||||
price: product.price,
|
||||
isFamilyShared: isFamilyShared
|
||||
)
|
||||
|
||||
case .userCancelled:
|
||||
purchaseState = .idle
|
||||
|
||||
case .pending:
|
||||
purchaseState = .idle
|
||||
|
||||
@unknown default:
|
||||
purchaseState = .idle
|
||||
}
|
||||
} catch {
|
||||
purchaseState = .failed(error.localizedDescription)
|
||||
FirebaseService.shared.logPurchaseFailure(
|
||||
productId: product.id,
|
||||
error: error.localizedDescription
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Restore Purchases
|
||||
|
||||
func restorePurchases() async {
|
||||
purchaseState = .purchasing
|
||||
|
||||
do {
|
||||
try await AppStore.sync()
|
||||
await updatePremiumStatus()
|
||||
|
||||
if isPremium {
|
||||
purchaseState = .restored
|
||||
} else {
|
||||
purchaseState = .failed("No purchases to restore")
|
||||
}
|
||||
} catch {
|
||||
purchaseState = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Update Premium Status
|
||||
|
||||
func updatePremiumStatus() async {
|
||||
var isEntitled = false
|
||||
var familyShared = false
|
||||
|
||||
#if DEBUG
|
||||
if debugOverrideEnabled {
|
||||
isPremium = true
|
||||
isFamilyShared = false
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
for await result in StoreKit.Transaction.currentEntitlements {
|
||||
if case .verified(let transaction) = result {
|
||||
if transaction.productID == Self.premiumProductID {
|
||||
isEntitled = true
|
||||
familyShared = transaction.ownershipType == .familyShared
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isPremium = isEntitled
|
||||
isFamilyShared = familyShared
|
||||
sharedDefaults?.set(isEntitled, forKey: "premiumUnlocked")
|
||||
|
||||
// Update Core Data
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
PremiumStatus.updateStatus(
|
||||
isPremium: isEntitled,
|
||||
productIdentifier: Self.premiumProductID,
|
||||
transactionId: nil,
|
||||
isFamilyShared: familyShared,
|
||||
in: context
|
||||
)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func setDebugPremiumOverride(_ enabled: Bool) {
|
||||
debugOverrideEnabled = enabled
|
||||
UserDefaults.standard.set(enabled, forKey: "debugPremiumOverride")
|
||||
if enabled {
|
||||
isPremium = true
|
||||
isFamilyShared = false
|
||||
sharedDefaults?.set(true, forKey: "premiumUnlocked")
|
||||
} else {
|
||||
Task { await updatePremiumStatus() }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Transaction Listener
|
||||
|
||||
private func listenForTransactions() -> Task<Void, Error> {
|
||||
return Task.detached { [weak self] in
|
||||
for await result in StoreKit.Transaction.updates {
|
||||
if case .verified(let transaction) = result {
|
||||
await transaction.finish()
|
||||
await self?.updatePremiumStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Verification
|
||||
|
||||
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
|
||||
switch result {
|
||||
case .unverified(_, let error):
|
||||
throw IAPError.verificationFailed(error.localizedDescription)
|
||||
case .verified(let safe):
|
||||
return safe
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Product Info
|
||||
|
||||
var premiumProduct: Product? {
|
||||
products.first { $0.id == Self.premiumProductID }
|
||||
}
|
||||
|
||||
var formattedPrice: String {
|
||||
premiumProduct?.displayPrice ?? Self.premiumPrice
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - IAP Error
|
||||
|
||||
enum IAPError: LocalizedError {
|
||||
case productNotFound
|
||||
case verificationFailed(String)
|
||||
case purchaseFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .productNotFound:
|
||||
return "Product not found. Please try again later."
|
||||
case .verificationFailed(let message):
|
||||
return "Verification failed: \(message)"
|
||||
case .purchaseFailed(let message):
|
||||
return "Purchase failed: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Premium Features
|
||||
|
||||
extension IAPService {
|
||||
static let premiumFeatures: [(icon: String, title: String, description: String)] = [
|
||||
("person.2", "Multiple Accounts", "Separate portfolios for business or family"),
|
||||
("infinity", "Unlimited Sources", "Track as many investments as you want"),
|
||||
("clock.arrow.circlepath", "Full History", "Access your complete investment history"),
|
||||
("chart.bar.xaxis", "Advanced Charts", "5 types of detailed analytics charts"),
|
||||
("wand.and.stars", "Predictions", "AI-powered 12-month forecasts"),
|
||||
("square.and.arrow.up", "Export Data", "Export to CSV and JSON formats"),
|
||||
("xmark.circle", "No Ads", "Ad-free experience forever"),
|
||||
("person.2", "Family Sharing", "Share with up to 5 family members")
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
import Combine
|
||||
|
||||
class ImportService {
|
||||
static let shared = ImportService()
|
||||
|
||||
private init() {}
|
||||
|
||||
struct ImportResult {
|
||||
let accountsCreated: Int
|
||||
let sourcesCreated: Int
|
||||
let snapshotsCreated: Int
|
||||
let errors: [String]
|
||||
}
|
||||
|
||||
struct ImportProgress {
|
||||
let completed: Int
|
||||
let total: Int
|
||||
let message: String
|
||||
|
||||
var fraction: Double {
|
||||
guard total > 0 else { return 0 }
|
||||
return min(max(Double(completed) / Double(total), 0), 1)
|
||||
}
|
||||
}
|
||||
|
||||
enum ImportFormat {
|
||||
case csv
|
||||
case json
|
||||
}
|
||||
|
||||
struct ImportedAccount {
|
||||
let name: String
|
||||
let currency: String?
|
||||
let inputMode: InputMode
|
||||
let notificationFrequency: NotificationFrequency
|
||||
let customFrequencyMonths: Int
|
||||
let categories: [ImportedCategory]
|
||||
}
|
||||
|
||||
struct ImportedCategory {
|
||||
let name: String
|
||||
let colorHex: String?
|
||||
let icon: String?
|
||||
let sources: [ImportedSource]
|
||||
}
|
||||
|
||||
struct ImportedSource {
|
||||
let name: String
|
||||
let snapshots: [ImportedSnapshot]
|
||||
}
|
||||
|
||||
struct ImportedSnapshot {
|
||||
let date: Date
|
||||
let value: Decimal
|
||||
let contribution: Decimal?
|
||||
let notes: String?
|
||||
}
|
||||
|
||||
func importData(
|
||||
content: String,
|
||||
format: ImportFormat,
|
||||
allowMultipleAccounts: Bool,
|
||||
defaultAccountName: String? = nil
|
||||
) -> ImportResult {
|
||||
switch format {
|
||||
case .csv:
|
||||
let parsed = parseCSV(
|
||||
content,
|
||||
allowMultipleAccounts: allowMultipleAccounts,
|
||||
defaultAccountName: defaultAccountName
|
||||
)
|
||||
return applyImport(parsed, context: CoreDataStack.shared.viewContext)
|
||||
case .json:
|
||||
let parsed = parseJSON(content, allowMultipleAccounts: allowMultipleAccounts)
|
||||
return applyImport(parsed, context: CoreDataStack.shared.viewContext)
|
||||
}
|
||||
}
|
||||
|
||||
func importDataAsync(
|
||||
content: String,
|
||||
format: ImportFormat,
|
||||
allowMultipleAccounts: Bool,
|
||||
defaultAccountName: String? = nil,
|
||||
progress: @escaping (ImportProgress) -> Void
|
||||
) async -> ImportResult {
|
||||
await withCheckedContinuation { continuation in
|
||||
CoreDataStack.shared.performBackgroundTask { context in
|
||||
let parsed: [ImportedAccount]
|
||||
switch format {
|
||||
case .csv:
|
||||
parsed = self.parseCSV(
|
||||
content,
|
||||
allowMultipleAccounts: allowMultipleAccounts,
|
||||
defaultAccountName: defaultAccountName
|
||||
)
|
||||
case .json:
|
||||
parsed = self.parseJSON(content, allowMultipleAccounts: allowMultipleAccounts)
|
||||
}
|
||||
|
||||
let totalSnapshots = parsed.reduce(0) { total, account in
|
||||
total + account.categories.reduce(0) { subtotal, category in
|
||||
subtotal + category.sources.reduce(0) { sourceTotal, source in
|
||||
sourceTotal + source.snapshots.count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
progress(ImportProgress(completed: 0, total: totalSnapshots, message: "Importing data"))
|
||||
}
|
||||
|
||||
let result = self.applyImport(parsed, context: context) { completed in
|
||||
DispatchQueue.main.async {
|
||||
progress(ImportProgress(
|
||||
completed: completed,
|
||||
total: totalSnapshots,
|
||||
message: "Imported \(completed) of \(totalSnapshots) snapshots"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func sampleCSV() -> String {
|
||||
return """
|
||||
Account,Category,Source,Date,Value,Contribution,Notes
|
||||
Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
|
||||
Personal,Crypto,BTC,2024-01-01,3200,,Cold storage
|
||||
Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
"""
|
||||
}
|
||||
|
||||
static func sampleJSON() -> String {
|
||||
return """
|
||||
{
|
||||
"version": 2,
|
||||
"currency": "EUR",
|
||||
"accounts": [{
|
||||
"name": "Personal",
|
||||
"inputMode": "simple",
|
||||
"notificationFrequency": "monthly",
|
||||
"categories": [{
|
||||
"name": "Stocks",
|
||||
"color": "#3B82F6",
|
||||
"icon": "chart.line.uptrend.xyaxis",
|
||||
"sources": [{
|
||||
"name": "Index Fund",
|
||||
"snapshots": [{
|
||||
"date": "2024-01-01T00:00:00Z",
|
||||
"value": 15000,
|
||||
"contribution": 12000
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
// MARK: - Parsing
|
||||
|
||||
private func parseCSV(
|
||||
_ content: String,
|
||||
allowMultipleAccounts: Bool,
|
||||
defaultAccountName: String?
|
||||
) -> [ImportedAccount] {
|
||||
let rows = parseCSVRows(content)
|
||||
guard rows.count > 1 else { return [] }
|
||||
|
||||
let headers = rows[0].map { $0.lowercased().trimmingCharacters(in: .whitespaces) }
|
||||
let indexOfAccount = headers.firstIndex(of: "account")
|
||||
let indexOfCategory = headers.firstIndex(of: "category")
|
||||
let indexOfSource = headers.firstIndex(of: "source")
|
||||
let indexOfDate = headers.firstIndex(of: "date")
|
||||
let indexOfValue = headers.firstIndex(where: { $0.hasPrefix("value") })
|
||||
let indexOfContribution = headers.firstIndex(where: { $0.hasPrefix("contribution") })
|
||||
let indexOfNotes = headers.firstIndex(of: "notes")
|
||||
|
||||
var grouped: [String: [String: [String: [ImportedSnapshot]]]] = [:]
|
||||
|
||||
for row in rows.dropFirst() {
|
||||
let providedAccount = indexOfAccount.flatMap { row.safeValue(at: $0) }
|
||||
let fallbackAccount = defaultAccountName ?? "Personal"
|
||||
let normalizedAccount = (providedAccount ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let rawAccountName = normalizedAccount.isEmpty ? fallbackAccount : normalizedAccount
|
||||
let accountName = allowMultipleAccounts ? rawAccountName : "Personal"
|
||||
|
||||
guard let categoryName = indexOfCategory.flatMap({ row.safeValue(at: $0) }), !categoryName.isEmpty,
|
||||
let sourceName = indexOfSource.flatMap({ row.safeValue(at: $0) }), !sourceName.isEmpty,
|
||||
let dateString = indexOfDate.flatMap({ row.safeValue(at: $0) }),
|
||||
let valueString = indexOfValue.flatMap({ row.safeValue(at: $0) }) else {
|
||||
continue
|
||||
}
|
||||
|
||||
guard let date = parseDate(dateString),
|
||||
let value = parseDecimal(valueString) else { continue }
|
||||
|
||||
let contribution = indexOfContribution
|
||||
.flatMap { row.safeValue(at: $0) }
|
||||
.flatMap(parseDecimal)
|
||||
let notes = indexOfNotes
|
||||
.flatMap { row.safeValue(at: $0) }
|
||||
.flatMap { $0.isEmpty ? nil : $0 }
|
||||
|
||||
let snapshot = ImportedSnapshot(
|
||||
date: date,
|
||||
value: value,
|
||||
contribution: contribution,
|
||||
notes: notes
|
||||
)
|
||||
|
||||
grouped[accountName, default: [:]][categoryName, default: [:]][sourceName, default: []].append(snapshot)
|
||||
}
|
||||
|
||||
return grouped.map { accountName, categories in
|
||||
let importedCategories = categories.map { categoryName, sources in
|
||||
let importedSources = sources.map { sourceName, snapshots in
|
||||
ImportedSource(name: sourceName, snapshots: snapshots)
|
||||
}
|
||||
return ImportedCategory(name: categoryName, colorHex: nil, icon: nil, sources: importedSources)
|
||||
}
|
||||
|
||||
return ImportedAccount(
|
||||
name: accountName,
|
||||
currency: nil,
|
||||
inputMode: .simple,
|
||||
notificationFrequency: .monthly,
|
||||
customFrequencyMonths: 1,
|
||||
categories: importedCategories
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func parseJSON(_ content: String, allowMultipleAccounts: Bool) -> [ImportedAccount] {
|
||||
guard let data = content.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return []
|
||||
}
|
||||
|
||||
if let accountsArray = json["accounts"] as? [[String: Any]] {
|
||||
return accountsArray.compactMap { accountDict in
|
||||
let rawName = accountDict["name"] as? String ?? "Personal"
|
||||
let name = allowMultipleAccounts ? rawName : "Personal"
|
||||
|
||||
let currency = accountDict["currency"] as? String
|
||||
let inputMode = InputMode(rawValue: accountDict["inputMode"] as? String ?? "") ?? .simple
|
||||
let notificationFrequency = NotificationFrequency(
|
||||
rawValue: accountDict["notificationFrequency"] as? String ?? ""
|
||||
) ?? .monthly
|
||||
let customFrequencyMonths = accountDict["customFrequencyMonths"] as? Int ?? 1
|
||||
|
||||
let categoriesArray = accountDict["categories"] as? [[String: Any]] ?? []
|
||||
let categories = categoriesArray.map { categoryDict in
|
||||
let categoryName = categoryDict["name"] as? String ?? "Uncategorized"
|
||||
let colorHex = categoryDict["color"] as? String
|
||||
let icon = categoryDict["icon"] as? String
|
||||
let sourcesArray = categoryDict["sources"] as? [[String: Any]] ?? []
|
||||
let sources = sourcesArray.map { sourceDict in
|
||||
let sourceName = sourceDict["name"] as? String ?? "Source"
|
||||
let snapshotsArray = sourceDict["snapshots"] as? [[String: Any]] ?? []
|
||||
let snapshots = snapshotsArray.compactMap { snapshotDict -> ImportedSnapshot? in
|
||||
guard let dateString = snapshotDict["date"] as? String,
|
||||
let date = ISO8601DateFormatter().date(from: dateString),
|
||||
let value = snapshotDict["value"] as? Double else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let contribution = (snapshotDict["contribution"] as? Double).map { Decimal($0) }
|
||||
let notes = snapshotDict["notes"] as? String
|
||||
return ImportedSnapshot(
|
||||
date: date,
|
||||
value: Decimal(value),
|
||||
contribution: contribution,
|
||||
notes: notes
|
||||
)
|
||||
}
|
||||
|
||||
return ImportedSource(name: sourceName, snapshots: snapshots)
|
||||
}
|
||||
|
||||
return ImportedCategory(
|
||||
name: categoryName,
|
||||
colorHex: colorHex,
|
||||
icon: icon,
|
||||
sources: sources
|
||||
)
|
||||
}
|
||||
|
||||
return ImportedAccount(
|
||||
name: name,
|
||||
currency: currency,
|
||||
inputMode: inputMode,
|
||||
notificationFrequency: notificationFrequency,
|
||||
customFrequencyMonths: customFrequencyMonths,
|
||||
categories: categories
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy JSON: categories only
|
||||
if let categoriesArray = json["categories"] as? [[String: Any]] {
|
||||
let categories = categoriesArray.map { categoryDict in
|
||||
let categoryName = categoryDict["name"] as? String ?? "Uncategorized"
|
||||
let colorHex = categoryDict["color"] as? String
|
||||
let icon = categoryDict["icon"] as? String
|
||||
let sourcesArray = categoryDict["sources"] as? [[String: Any]] ?? []
|
||||
let sources = sourcesArray.map { sourceDict in
|
||||
let sourceName = sourceDict["name"] as? String ?? "Source"
|
||||
let snapshotsArray = sourceDict["snapshots"] as? [[String: Any]] ?? []
|
||||
let snapshots = snapshotsArray.compactMap { snapshotDict -> ImportedSnapshot? in
|
||||
guard let dateString = snapshotDict["date"] as? String,
|
||||
let date = ISO8601DateFormatter().date(from: dateString),
|
||||
let value = snapshotDict["value"] as? Double else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let contribution = (snapshotDict["contribution"] as? Double).map { Decimal($0) }
|
||||
let notes = snapshotDict["notes"] as? String
|
||||
return ImportedSnapshot(
|
||||
date: date,
|
||||
value: Decimal(value),
|
||||
contribution: contribution,
|
||||
notes: notes
|
||||
)
|
||||
}
|
||||
|
||||
return ImportedSource(name: sourceName, snapshots: snapshots)
|
||||
}
|
||||
|
||||
return ImportedCategory(
|
||||
name: categoryName,
|
||||
colorHex: colorHex,
|
||||
icon: icon,
|
||||
sources: sources
|
||||
)
|
||||
}
|
||||
|
||||
return [
|
||||
ImportedAccount(
|
||||
name: "Personal",
|
||||
currency: json["currency"] as? String,
|
||||
inputMode: .simple,
|
||||
notificationFrequency: .monthly,
|
||||
customFrequencyMonths: 1,
|
||||
categories: categories
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
// MARK: - Apply Import
|
||||
|
||||
private func applyImport(
|
||||
_ accounts: [ImportedAccount],
|
||||
context: NSManagedObjectContext,
|
||||
snapshotProgress: ((Int) -> Void)? = nil
|
||||
) -> ImportResult {
|
||||
let accountRepository = AccountRepository(context: context)
|
||||
let categoryRepository = CategoryRepository(context: context)
|
||||
let sourceRepository = InvestmentSourceRepository(context: context)
|
||||
let snapshotRepository = SnapshotRepository(context: context)
|
||||
|
||||
var accountsCreated = 0
|
||||
var sourcesCreated = 0
|
||||
var snapshotsCreated = 0
|
||||
var errors: [String] = []
|
||||
|
||||
var categoryLookup = buildCategoryLookup(from: categoryRepository.categories)
|
||||
let otherCategory = resolveExistingCategory(named: "Other", lookup: categoryLookup) ??
|
||||
categoryRepository.createCategory(
|
||||
name: "Other",
|
||||
colorHex: "#64748B",
|
||||
icon: "ellipsis.circle.fill"
|
||||
)
|
||||
categoryLookup[normalizedCategoryName(otherCategory.name)] = otherCategory
|
||||
|
||||
var completionDatesByMonth: [String: Date] = [:]
|
||||
|
||||
for importedAccount in accounts {
|
||||
let existingAccount = accountRepository.accounts.first(where: { $0.name == importedAccount.name })
|
||||
let account = existingAccount ?? accountRepository.createAccount(
|
||||
name: importedAccount.name,
|
||||
currency: importedAccount.currency,
|
||||
inputMode: importedAccount.inputMode,
|
||||
notificationFrequency: importedAccount.notificationFrequency,
|
||||
customFrequencyMonths: importedAccount.customFrequencyMonths
|
||||
)
|
||||
|
||||
if existingAccount == nil {
|
||||
accountsCreated += 1
|
||||
}
|
||||
|
||||
for importedCategory in importedAccount.categories {
|
||||
let existingCategory = resolveExistingCategory(
|
||||
named: importedCategory.name,
|
||||
lookup: categoryLookup
|
||||
)
|
||||
let shouldUseOther = existingCategory == nil &&
|
||||
importedCategory.colorHex == nil &&
|
||||
importedCategory.icon == nil
|
||||
let resolvedName = canonicalCategoryName(for: importedCategory.name) ?? importedCategory.name
|
||||
let category = existingCategory ?? (shouldUseOther
|
||||
? otherCategory
|
||||
: categoryRepository.createCategory(
|
||||
name: resolvedName,
|
||||
colorHex: importedCategory.colorHex ?? "#3B82F6",
|
||||
icon: importedCategory.icon ?? "chart.pie.fill"
|
||||
))
|
||||
categoryLookup[normalizedCategoryName(category.name)] = category
|
||||
|
||||
for importedSource in importedCategory.sources {
|
||||
let existingSource = sourceRepository.sources.first(where: {
|
||||
$0.name == importedSource.name && $0.account?.id == account.id
|
||||
})
|
||||
let source = existingSource ?? sourceRepository.createSource(
|
||||
name: importedSource.name,
|
||||
category: category,
|
||||
notificationFrequency: importedAccount.notificationFrequency,
|
||||
customFrequencyMonths: importedAccount.customFrequencyMonths
|
||||
)
|
||||
source.account = account
|
||||
if existingSource == nil {
|
||||
sourcesCreated += 1
|
||||
}
|
||||
|
||||
for snapshot in importedSource.snapshots {
|
||||
snapshotRepository.createSnapshot(
|
||||
for: source,
|
||||
date: snapshot.date,
|
||||
value: snapshot.value,
|
||||
contribution: snapshot.contribution,
|
||||
notes: snapshot.notes
|
||||
)
|
||||
snapshotsCreated += 1
|
||||
snapshotProgress?(snapshotsCreated)
|
||||
|
||||
let monthKey = MonthlyCheckInStore.monthKey(for: snapshot.date)
|
||||
if let existingDate = completionDatesByMonth[monthKey] {
|
||||
if snapshot.date > existingDate {
|
||||
completionDatesByMonth[monthKey] = snapshot.date
|
||||
}
|
||||
} else {
|
||||
completionDatesByMonth[monthKey] = snapshot.date
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if context.hasChanges {
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
errors.append("Failed to save imported data.")
|
||||
}
|
||||
}
|
||||
|
||||
if !completionDatesByMonth.isEmpty {
|
||||
for (monthKey, completionDate) in completionDatesByMonth {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM"
|
||||
if let monthDate = formatter.date(from: monthKey) {
|
||||
MonthlyCheckInStore.setCompletionDate(completionDate, for: monthDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ImportResult(
|
||||
accountsCreated: accountsCreated,
|
||||
sourcesCreated: sourcesCreated,
|
||||
snapshotsCreated: snapshotsCreated,
|
||||
errors: errors
|
||||
)
|
||||
}
|
||||
|
||||
private func resolveExistingCategory(
|
||||
named rawName: String,
|
||||
lookup: [String: Category]
|
||||
) -> Category? {
|
||||
if let canonical = canonicalCategoryName(for: rawName) {
|
||||
let canonicalKey = normalizedCategoryName(canonical)
|
||||
if let match = lookup[canonicalKey] {
|
||||
return match
|
||||
}
|
||||
}
|
||||
return lookup[normalizedCategoryName(rawName)]
|
||||
}
|
||||
|
||||
private func buildCategoryLookup(from categories: [Category]) -> [String: Category] {
|
||||
var lookup: [String: Category] = [:]
|
||||
for category in categories {
|
||||
lookup[normalizedCategoryName(category.name)] = category
|
||||
}
|
||||
return lookup
|
||||
}
|
||||
|
||||
private func canonicalCategoryName(for rawName: String) -> String? {
|
||||
let normalized = normalizedCategoryName(rawName)
|
||||
for mapping in categoryAliasMappings {
|
||||
if mapping.aliases.contains(where: { normalizedCategoryName($0) == normalized }) {
|
||||
return mapping.canonical
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func normalizedCategoryName(_ value: String) -> String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalized = trimmed.folding(
|
||||
options: [.diacriticInsensitive, .caseInsensitive],
|
||||
locale: .current
|
||||
)
|
||||
return normalized.replacingOccurrences(
|
||||
of: "\\s+",
|
||||
with: " ",
|
||||
options: .regularExpression
|
||||
)
|
||||
}
|
||||
|
||||
private var categoryAliasMappings: [(canonical: String, aliases: [String])] {
|
||||
[
|
||||
(
|
||||
canonical: "Stocks",
|
||||
aliases: ["Stocks", "category_stocks", String(localized: "category_stocks"), "Acciones"]
|
||||
),
|
||||
(
|
||||
canonical: "Bonds",
|
||||
aliases: ["Bonds", "category_bonds", String(localized: "category_bonds"), "Bonos"]
|
||||
),
|
||||
(
|
||||
canonical: "Real Estate",
|
||||
aliases: ["Real Estate", "category_real_estate", String(localized: "category_real_estate"), "Inmobiliario"]
|
||||
),
|
||||
(
|
||||
canonical: "Crypto",
|
||||
aliases: ["Crypto", "category_crypto", String(localized: "category_crypto"), "Cripto"]
|
||||
),
|
||||
(
|
||||
canonical: "Cash",
|
||||
aliases: ["Cash", "category_cash", String(localized: "category_cash"), "Efectivo"]
|
||||
),
|
||||
(
|
||||
canonical: "ETFs",
|
||||
aliases: ["ETFs", "category_etfs", String(localized: "category_etfs"), "ETF"]
|
||||
),
|
||||
(
|
||||
canonical: "Retirement",
|
||||
aliases: ["Retirement", "category_retirement", String(localized: "category_retirement"), "Jubilación"]
|
||||
),
|
||||
(
|
||||
canonical: "Other",
|
||||
aliases: [
|
||||
"Other",
|
||||
"category_other",
|
||||
String(localized: "category_other"),
|
||||
"Uncategorized",
|
||||
"uncategorized",
|
||||
String(localized: "uncategorized"),
|
||||
"Otros",
|
||||
"Sin categoría"
|
||||
]
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - CSV Helpers
|
||||
|
||||
private func parseCSVRows(_ content: String) -> [[String]] {
|
||||
var rows: [[String]] = []
|
||||
var currentRow: [String] = []
|
||||
var currentField = ""
|
||||
var insideQuotes = false
|
||||
|
||||
for char in content {
|
||||
if char == "\"" {
|
||||
insideQuotes.toggle()
|
||||
continue
|
||||
}
|
||||
|
||||
if char == "," && !insideQuotes {
|
||||
currentRow.append(currentField)
|
||||
currentField = ""
|
||||
continue
|
||||
}
|
||||
|
||||
if char == "\n" && !insideQuotes {
|
||||
currentRow.append(currentField)
|
||||
rows.append(currentRow.map { $0.trimmingCharacters(in: .whitespaces) })
|
||||
currentRow = []
|
||||
currentField = ""
|
||||
continue
|
||||
}
|
||||
|
||||
currentField.append(char)
|
||||
}
|
||||
|
||||
if !currentField.isEmpty || !currentRow.isEmpty {
|
||||
currentRow.append(currentField)
|
||||
rows.append(currentRow.map { $0.trimmingCharacters(in: .whitespaces) })
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
private func parseDate(_ value: String) -> Date? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
|
||||
if let iso = ISO8601DateFormatter().date(from: trimmed) {
|
||||
return iso
|
||||
}
|
||||
|
||||
let formats = [
|
||||
"yyyy-MM-dd",
|
||||
"yyyy/MM/dd",
|
||||
"dd/MM/yyyy",
|
||||
"MM/dd/yyyy",
|
||||
"dd-MM-yyyy",
|
||||
"MM-dd-yyyy",
|
||||
"yyyy-MM-dd HH:mm",
|
||||
"yyyy-MM-dd HH:mm:ss",
|
||||
"yyyy/MM/dd HH:mm",
|
||||
"yyyy/MM/dd HH:mm:ss",
|
||||
"dd/MM/yyyy HH:mm",
|
||||
"dd/MM/yyyy HH:mm:ss",
|
||||
"MM/dd/yyyy HH:mm",
|
||||
"MM/dd/yyyy HH:mm:ss",
|
||||
"dd-MM-yyyy HH:mm",
|
||||
"dd-MM-yyyy HH:mm:ss",
|
||||
"MM-dd-yyyy HH:mm",
|
||||
"MM-dd-yyyy HH:mm:ss",
|
||||
"dd/MM/yyyy h:mm a",
|
||||
"dd/MM/yyyy h:mm:ss a",
|
||||
"MM/dd/yyyy h:mm a",
|
||||
"MM/dd/yyyy h:mm:ss a",
|
||||
"dd-MM-yyyy h:mm a",
|
||||
"dd-MM-yyyy h:mm:ss a",
|
||||
"MM-dd-yyyy h:mm a",
|
||||
"MM-dd-yyyy h:mm:ss a"
|
||||
]
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = .current
|
||||
|
||||
for format in formats {
|
||||
formatter.dateFormat = format
|
||||
if let date = formatter.date(from: trimmed) {
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func parseDecimal(_ value: String) -> Decimal? {
|
||||
let cleaned = value
|
||||
.replacingOccurrences(of: ",", with: ".")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
return Decimal(string: cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array where Element == String {
|
||||
func safeValue(at index: Int) -> String? {
|
||||
guard index >= 0, index < count else { return nil }
|
||||
return self[index]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import UserNotifications
|
||||
import UIKit
|
||||
|
||||
class NotificationService: ObservableObject {
|
||||
static let shared = NotificationService()
|
||||
|
||||
@Published var isAuthorized = false
|
||||
@Published var pendingCount = 0
|
||||
|
||||
private let center = UNUserNotificationCenter.current()
|
||||
|
||||
private init() {
|
||||
checkAuthorizationStatus()
|
||||
}
|
||||
|
||||
// MARK: - Authorization
|
||||
|
||||
func checkAuthorizationStatus() {
|
||||
center.getNotificationSettings { [weak self] settings in
|
||||
DispatchQueue.main.async {
|
||||
self?.isAuthorized = settings.authorizationStatus == .authorized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requestAuthorization() async -> Bool {
|
||||
do {
|
||||
let granted = try await center.requestAuthorization(options: [.alert, .badge, .sound])
|
||||
await MainActor.run {
|
||||
self.isAuthorized = granted
|
||||
}
|
||||
return granted
|
||||
} catch {
|
||||
print("Notification authorization error: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Schedule Notifications
|
||||
|
||||
func scheduleReminder(for source: InvestmentSource) {
|
||||
guard let nextDate = source.nextReminderDate else { return }
|
||||
guard source.frequency != .never else { return }
|
||||
|
||||
// Remove existing notification for this source
|
||||
cancelReminder(for: source)
|
||||
|
||||
// Get notification time from settings
|
||||
let settings = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext)
|
||||
let notificationTime = settings.defaultNotificationTime ?? defaultNotificationTime()
|
||||
|
||||
// Combine date and time
|
||||
let calendar = Calendar.current
|
||||
var components = calendar.dateComponents([.year, .month, .day], from: nextDate)
|
||||
let timeComponents = calendar.dateComponents([.hour, .minute], from: notificationTime)
|
||||
components.hour = timeComponents.hour
|
||||
components.minute = timeComponents.minute
|
||||
|
||||
guard let triggerDate = calendar.date(from: components) else { return }
|
||||
|
||||
// Create notification content
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Investment Update Reminder"
|
||||
content.body = "Time to update \(source.name). Tap to add a new snapshot."
|
||||
content.sound = .default
|
||||
content.badge = NSNumber(value: pendingCount + 1)
|
||||
content.userInfo = [
|
||||
"sourceId": source.id.uuidString,
|
||||
"sourceName": source.name
|
||||
]
|
||||
|
||||
// Create trigger
|
||||
let triggerComponents = calendar.dateComponents(
|
||||
[.year, .month, .day, .hour, .minute],
|
||||
from: triggerDate
|
||||
)
|
||||
let trigger = UNCalendarNotificationTrigger(dateMatching: triggerComponents, repeats: false)
|
||||
|
||||
// Create request
|
||||
let request = UNNotificationRequest(
|
||||
identifier: notificationIdentifier(for: source),
|
||||
content: content,
|
||||
trigger: trigger
|
||||
)
|
||||
|
||||
// Schedule
|
||||
center.add(request) { error in
|
||||
if let error = error {
|
||||
print("Failed to schedule notification: \(error)")
|
||||
} else {
|
||||
print("Scheduled reminder for \(source.name) on \(triggerDate)")
|
||||
FirebaseService.shared.logNotificationScheduled(frequency: source.notificationFrequency)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func scheduleAllReminders(for sources: [InvestmentSource]) {
|
||||
for source in sources {
|
||||
scheduleReminder(for: source)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cancel Notifications
|
||||
|
||||
func cancelReminder(for source: InvestmentSource) {
|
||||
center.removePendingNotificationRequests(
|
||||
withIdentifiers: [notificationIdentifier(for: source)]
|
||||
)
|
||||
}
|
||||
|
||||
func cancelAllReminders() {
|
||||
center.removeAllPendingNotificationRequests()
|
||||
}
|
||||
|
||||
// MARK: - Badge Management
|
||||
|
||||
func updateBadgeCount() {
|
||||
let repository = InvestmentSourceRepository()
|
||||
let needsUpdate = repository.fetchSourcesNeedingUpdate()
|
||||
pendingCount = needsUpdate.count
|
||||
|
||||
center.setBadgeCount(pendingCount) { _ in }
|
||||
}
|
||||
|
||||
func clearBadge() {
|
||||
pendingCount = 0
|
||||
center.setBadgeCount(0) { _ in }
|
||||
}
|
||||
|
||||
// MARK: - Pending Notifications
|
||||
|
||||
func getPendingNotifications() async -> [UNNotificationRequest] {
|
||||
await center.pendingNotificationRequests()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func notificationIdentifier(for source: InvestmentSource) -> String {
|
||||
"investment_reminder_\(source.id.uuidString)"
|
||||
}
|
||||
|
||||
private func defaultNotificationTime() -> Date {
|
||||
var components = DateComponents()
|
||||
components.hour = 9
|
||||
components.minute = 0
|
||||
return Calendar.current.date(from: components) ?? Date()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Deep Link Handler
|
||||
|
||||
extension NotificationService {
|
||||
func handleNotificationResponse(_ response: UNNotificationResponse) {
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
|
||||
guard let sourceIdString = userInfo["sourceId"] as? String,
|
||||
let sourceId = UUID(uuidString: sourceIdString) else {
|
||||
return
|
||||
}
|
||||
|
||||
// Post notification for deep linking
|
||||
NotificationCenter.default.post(
|
||||
name: .openSourceDetail,
|
||||
object: nil,
|
||||
userInfo: ["sourceId": sourceId]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification Names
|
||||
|
||||
extension Notification.Name {
|
||||
static let openSourceDetail = Notification.Name("openSourceDetail")
|
||||
}
|
||||
|
||||
// MARK: - Background Refresh
|
||||
|
||||
extension NotificationService {
|
||||
func performBackgroundRefresh() {
|
||||
updateBadgeCount()
|
||||
|
||||
// Reschedule any missed notifications
|
||||
let repository = InvestmentSourceRepository()
|
||||
let sources = repository.fetchActiveSources()
|
||||
scheduleAllReminders(for: sources)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import Foundation
|
||||
|
||||
class PredictionEngine {
|
||||
static let shared = PredictionEngine()
|
||||
|
||||
private let context = CoreDataStack.shared.viewContext
|
||||
|
||||
// MARK: - Performance: Cached Calendar reference
|
||||
private static let calendar = Calendar.current
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Main Prediction Interface
|
||||
|
||||
func predict(
|
||||
snapshots: [Snapshot],
|
||||
monthsAhead: Int = 12,
|
||||
algorithm: PredictionAlgorithm? = nil
|
||||
) -> PredictionResult {
|
||||
guard snapshots.count >= 3 else {
|
||||
return PredictionResult(
|
||||
predictions: [],
|
||||
algorithm: .linear,
|
||||
accuracy: 0,
|
||||
volatility: 0
|
||||
)
|
||||
}
|
||||
|
||||
// Sort snapshots by date
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
|
||||
// Calculate volatility for algorithm selection
|
||||
let volatility = calculateVolatility(snapshots: sortedSnapshots)
|
||||
|
||||
// Select algorithm if not specified
|
||||
let selectedAlgorithm = algorithm ?? selectBestAlgorithm(volatility: volatility)
|
||||
|
||||
// Generate predictions
|
||||
let predictions: [Prediction]
|
||||
let accuracy: Double
|
||||
|
||||
switch selectedAlgorithm {
|
||||
case .linear:
|
||||
predictions = predictLinear(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
|
||||
accuracy = calculateLinearAccuracy(snapshots: sortedSnapshots)
|
||||
case .exponentialSmoothing:
|
||||
predictions = predictExponentialSmoothing(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
|
||||
accuracy = calculateESAccuracy(snapshots: sortedSnapshots)
|
||||
case .movingAverage:
|
||||
predictions = predictMovingAverage(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
|
||||
accuracy = calculateMAAccuracy(snapshots: sortedSnapshots)
|
||||
case .holtTrend:
|
||||
predictions = predictHoltTrend(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
|
||||
accuracy = calculateHoltAccuracy(snapshots: sortedSnapshots)
|
||||
}
|
||||
|
||||
return PredictionResult(
|
||||
predictions: predictions,
|
||||
algorithm: selectedAlgorithm,
|
||||
accuracy: accuracy,
|
||||
volatility: volatility
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Algorithm Selection
|
||||
|
||||
private func selectBestAlgorithm(volatility: Double) -> PredictionAlgorithm {
|
||||
switch volatility {
|
||||
case 0..<8:
|
||||
return .holtTrend
|
||||
case 8..<20:
|
||||
return .exponentialSmoothing
|
||||
default:
|
||||
return .movingAverage
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Linear Regression
|
||||
|
||||
func predictLinear(snapshots: [Snapshot], monthsAhead: Int = 12) -> [Prediction] {
|
||||
guard snapshots.count >= 3 else { return [] }
|
||||
|
||||
guard let firstDate = snapshots.first?.date else { return [] }
|
||||
|
||||
let dataPoints: [(x: Double, y: Double)] = snapshots.map { snapshot in
|
||||
let daysSinceStart = snapshot.date.timeIntervalSince(firstDate) / 86400
|
||||
return (x: daysSinceStart, y: snapshot.decimalValue.doubleValue)
|
||||
}
|
||||
|
||||
let (slope, intercept) = calculateLinearRegression(dataPoints: dataPoints)
|
||||
let residualStdDev = calculateResidualStdDev(dataPoints: dataPoints, slope: slope, intercept: intercept)
|
||||
|
||||
var predictions: [Prediction] = []
|
||||
let lastDate = snapshots.last!.date
|
||||
|
||||
for month in 1...monthsAhead {
|
||||
guard let futureDate = Self.calendar.date(
|
||||
byAdding: .month,
|
||||
value: month,
|
||||
to: lastDate
|
||||
) else { continue }
|
||||
|
||||
let daysFromStart = futureDate.timeIntervalSince(firstDate) / 86400
|
||||
let predictedValue = max(0, slope * daysFromStart + intercept)
|
||||
|
||||
// Widen confidence interval for further predictions
|
||||
let confidenceMultiplier = 1.0 + (Double(month) * 0.02)
|
||||
let intervalWidth = residualStdDev * 1.96 * confidenceMultiplier
|
||||
|
||||
predictions.append(Prediction(
|
||||
date: futureDate,
|
||||
predictedValue: Decimal(predictedValue),
|
||||
algorithm: .linear,
|
||||
confidenceInterval: Prediction.ConfidenceInterval(
|
||||
lower: Decimal(max(0, predictedValue - intervalWidth)),
|
||||
upper: Decimal(predictedValue + intervalWidth)
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
return predictions
|
||||
}
|
||||
|
||||
private func calculateLinearRegression(
|
||||
dataPoints: [(x: Double, y: Double)]
|
||||
) -> (slope: Double, intercept: Double) {
|
||||
let n = Double(dataPoints.count)
|
||||
let sumX = dataPoints.reduce(0) { $0 + $1.x }
|
||||
let sumY = dataPoints.reduce(0) { $0 + $1.y }
|
||||
let sumXY = dataPoints.reduce(0) { $0 + ($1.x * $1.y) }
|
||||
let sumX2 = dataPoints.reduce(0) { $0 + ($1.x * $1.x) }
|
||||
|
||||
let denominator = n * sumX2 - sumX * sumX
|
||||
guard denominator != 0 else { return (0, sumY / n) }
|
||||
|
||||
let slope = (n * sumXY - sumX * sumY) / denominator
|
||||
let intercept = (sumY - slope * sumX) / n
|
||||
|
||||
return (slope, intercept)
|
||||
}
|
||||
|
||||
private func calculateResidualStdDev(
|
||||
dataPoints: [(x: Double, y: Double)],
|
||||
slope: Double,
|
||||
intercept: Double
|
||||
) -> Double {
|
||||
guard dataPoints.count > 2 else { return 0 }
|
||||
|
||||
let residuals = dataPoints.map { point in
|
||||
let predicted = slope * point.x + intercept
|
||||
return pow(point.y - predicted, 2)
|
||||
}
|
||||
|
||||
let meanSquaredError = residuals.reduce(0, +) / Double(dataPoints.count - 2)
|
||||
return sqrt(meanSquaredError)
|
||||
}
|
||||
|
||||
private func calculateLinearAccuracy(snapshots: [Snapshot]) -> Double {
|
||||
guard snapshots.count >= 5 else { return 0.5 }
|
||||
|
||||
// Use last 20% of data for validation
|
||||
let splitIndex = Int(Double(snapshots.count) * 0.8)
|
||||
let trainingData = Array(snapshots.prefix(splitIndex))
|
||||
let validationData = Array(snapshots.suffix(from: splitIndex))
|
||||
|
||||
guard let firstDate = trainingData.first?.date else { return 0.5 }
|
||||
|
||||
let trainPoints = trainingData.map { snapshot in
|
||||
(x: snapshot.date.timeIntervalSince(firstDate) / 86400, y: snapshot.decimalValue.doubleValue)
|
||||
}
|
||||
|
||||
let (slope, intercept) = calculateLinearRegression(dataPoints: trainPoints)
|
||||
|
||||
// Calculate R-squared on validation data
|
||||
let validationValues = validationData.map { $0.decimalValue.doubleValue }
|
||||
let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count)
|
||||
|
||||
var ssRes: Double = 0
|
||||
var ssTot: Double = 0
|
||||
|
||||
for snapshot in validationData {
|
||||
let x = snapshot.date.timeIntervalSince(firstDate) / 86400
|
||||
let actual = snapshot.decimalValue.doubleValue
|
||||
let predicted = slope * x + intercept
|
||||
|
||||
ssRes += pow(actual - predicted, 2)
|
||||
ssTot += pow(actual - meanValidation, 2)
|
||||
}
|
||||
|
||||
guard ssTot != 0 else { return 0.5 }
|
||||
let rSquared = max(0, 1 - (ssRes / ssTot))
|
||||
|
||||
return min(1.0, rSquared)
|
||||
}
|
||||
|
||||
// MARK: - Exponential Smoothing
|
||||
|
||||
func predictExponentialSmoothing(
|
||||
snapshots: [Snapshot],
|
||||
monthsAhead: Int = 12,
|
||||
alpha: Double = 0.3
|
||||
) -> [Prediction] {
|
||||
guard snapshots.count >= 3 else { return [] }
|
||||
|
||||
let values = snapshots.map { $0.decimalValue.doubleValue }
|
||||
|
||||
// Calculate smoothed values
|
||||
var smoothed = values[0]
|
||||
for i in 1..<values.count {
|
||||
smoothed = alpha * values[i] + (1 - alpha) * smoothed
|
||||
}
|
||||
|
||||
// Calculate trend
|
||||
var trend: Double = 0
|
||||
if values.count >= 2 {
|
||||
let recentChange = values.suffix(3).reduce(0) { $0 + $1 } / 3.0 -
|
||||
values.prefix(3).reduce(0) { $0 + $1 } / 3.0
|
||||
trend = recentChange / Double(values.count)
|
||||
}
|
||||
|
||||
// Calculate standard deviation for confidence interval
|
||||
let stdDev = calculateStdDev(values: values)
|
||||
|
||||
var predictions: [Prediction] = []
|
||||
let lastDate = snapshots.last!.date
|
||||
|
||||
for month in 1...monthsAhead {
|
||||
guard let futureDate = Self.calendar.date(
|
||||
byAdding: .month,
|
||||
value: month,
|
||||
to: lastDate
|
||||
) else { continue }
|
||||
|
||||
let predictedValue = max(0, smoothed + trend * Double(month))
|
||||
let intervalWidth = stdDev * 1.96 * (1.0 + Double(month) * 0.05)
|
||||
|
||||
predictions.append(Prediction(
|
||||
date: futureDate,
|
||||
predictedValue: Decimal(predictedValue),
|
||||
algorithm: .exponentialSmoothing,
|
||||
confidenceInterval: Prediction.ConfidenceInterval(
|
||||
lower: Decimal(max(0, predictedValue - intervalWidth)),
|
||||
upper: Decimal(predictedValue + intervalWidth)
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
return predictions
|
||||
}
|
||||
|
||||
private func calculateESAccuracy(snapshots: [Snapshot]) -> Double {
|
||||
guard snapshots.count >= 5 else { return 0.5 }
|
||||
|
||||
let values = snapshots.map { $0.decimalValue.doubleValue }
|
||||
let splitIndex = Int(Double(values.count) * 0.8)
|
||||
|
||||
var smoothed = values[0]
|
||||
for i in 1..<splitIndex {
|
||||
smoothed = 0.3 * values[i] + 0.7 * smoothed
|
||||
}
|
||||
|
||||
let validationValues = Array(values.suffix(from: splitIndex))
|
||||
let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count)
|
||||
|
||||
var ssRes: Double = 0
|
||||
var ssTot: Double = 0
|
||||
|
||||
for (i, actual) in validationValues.enumerated() {
|
||||
let predicted = smoothed + (smoothed - values[splitIndex - 1]) * Double(i + 1) / Double(splitIndex)
|
||||
ssRes += pow(actual - predicted, 2)
|
||||
ssTot += pow(actual - meanValidation, 2)
|
||||
}
|
||||
|
||||
guard ssTot != 0 else { return 0.5 }
|
||||
return max(0, min(1.0, 1 - (ssRes / ssTot)))
|
||||
}
|
||||
|
||||
// MARK: - Moving Average
|
||||
|
||||
func predictMovingAverage(
|
||||
snapshots: [Snapshot],
|
||||
monthsAhead: Int = 12,
|
||||
windowSize: Int = 3
|
||||
) -> [Prediction] {
|
||||
guard snapshots.count >= windowSize else { return [] }
|
||||
|
||||
let values = snapshots.map { $0.decimalValue.doubleValue }
|
||||
|
||||
// Calculate moving average of last window
|
||||
let recentValues = Array(values.suffix(windowSize))
|
||||
let movingAverage = recentValues.reduce(0, +) / Double(windowSize)
|
||||
|
||||
// Calculate average monthly change
|
||||
var changes: [Double] = []
|
||||
for i in 1..<values.count {
|
||||
changes.append(values[i] - values[i - 1])
|
||||
}
|
||||
let avgChange = changes.isEmpty ? 0 : changes.reduce(0, +) / Double(changes.count)
|
||||
|
||||
let stdDev = calculateStdDev(values: values)
|
||||
|
||||
var predictions: [Prediction] = []
|
||||
let lastDate = snapshots.last!.date
|
||||
|
||||
for month in 1...monthsAhead {
|
||||
guard let futureDate = Self.calendar.date(
|
||||
byAdding: .month,
|
||||
value: month,
|
||||
to: lastDate
|
||||
) else { continue }
|
||||
|
||||
let predictedValue = max(0, movingAverage + avgChange * Double(month))
|
||||
let intervalWidth = stdDev * 1.96 * (1.0 + Double(month) * 0.03)
|
||||
|
||||
predictions.append(Prediction(
|
||||
date: futureDate,
|
||||
predictedValue: Decimal(predictedValue),
|
||||
algorithm: .movingAverage,
|
||||
confidenceInterval: Prediction.ConfidenceInterval(
|
||||
lower: Decimal(max(0, predictedValue - intervalWidth)),
|
||||
upper: Decimal(predictedValue + intervalWidth)
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
return predictions
|
||||
}
|
||||
|
||||
private func calculateMAAccuracy(snapshots: [Snapshot]) -> Double {
|
||||
guard snapshots.count >= 5 else { return 0.5 }
|
||||
|
||||
let values = snapshots.map { $0.decimalValue.doubleValue }
|
||||
let windowSize = 3
|
||||
let splitIndex = Int(Double(values.count) * 0.8)
|
||||
|
||||
guard splitIndex > windowSize else { return 0.5 }
|
||||
|
||||
let recentWindow = Array(values[(splitIndex - windowSize)..<splitIndex])
|
||||
let movingAvg = recentWindow.reduce(0, +) / Double(windowSize)
|
||||
|
||||
let validationValues = Array(values.suffix(from: splitIndex))
|
||||
let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count)
|
||||
|
||||
var ssRes: Double = 0
|
||||
var ssTot: Double = 0
|
||||
|
||||
for actual in validationValues {
|
||||
ssRes += pow(actual - movingAvg, 2)
|
||||
ssTot += pow(actual - meanValidation, 2)
|
||||
}
|
||||
|
||||
guard ssTot != 0 else { return 0.5 }
|
||||
return max(0, min(1.0, 1 - (ssRes / ssTot)))
|
||||
}
|
||||
|
||||
// MARK: - Holt Trend (Double Exponential Smoothing)
|
||||
|
||||
func predictHoltTrend(
|
||||
snapshots: [Snapshot],
|
||||
monthsAhead: Int = 12,
|
||||
alpha: Double = 0.4,
|
||||
beta: Double = 0.3
|
||||
) -> [Prediction] {
|
||||
guard snapshots.count >= 3 else { return [] }
|
||||
|
||||
let values = snapshots.map { $0.decimalValue.doubleValue }
|
||||
var level = values[0]
|
||||
var trend = values[1] - values[0]
|
||||
|
||||
var fitted: [Double] = []
|
||||
for value in values {
|
||||
let lastLevel = level
|
||||
level = alpha * value + (1 - alpha) * (level + trend)
|
||||
trend = beta * (level - lastLevel) + (1 - beta) * trend
|
||||
fitted.append(level + trend)
|
||||
}
|
||||
|
||||
let residuals = zip(values, fitted).map { $0 - $1 }
|
||||
let stdDev = calculateStdDev(values: residuals)
|
||||
|
||||
var predictions: [Prediction] = []
|
||||
let lastDate = snapshots.last!.date
|
||||
|
||||
for month in 1...monthsAhead {
|
||||
guard let futureDate = Self.calendar.date(
|
||||
byAdding: .month,
|
||||
value: month,
|
||||
to: lastDate
|
||||
) else { continue }
|
||||
|
||||
let predictedValue = max(0, level + Double(month) * trend)
|
||||
let intervalWidth = stdDev * 1.96 * (1.0 + Double(month) * 0.04)
|
||||
|
||||
predictions.append(Prediction(
|
||||
date: futureDate,
|
||||
predictedValue: Decimal(predictedValue),
|
||||
algorithm: .holtTrend,
|
||||
confidenceInterval: Prediction.ConfidenceInterval(
|
||||
lower: Decimal(max(0, predictedValue - intervalWidth)),
|
||||
upper: Decimal(predictedValue + intervalWidth)
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
return predictions
|
||||
}
|
||||
|
||||
private func calculateHoltAccuracy(snapshots: [Snapshot]) -> Double {
|
||||
guard snapshots.count >= 5 else { return 0.5 }
|
||||
|
||||
let values = snapshots.map { $0.decimalValue.doubleValue }
|
||||
let splitIndex = Int(Double(values.count) * 0.8)
|
||||
guard splitIndex >= 2 else { return 0.5 }
|
||||
|
||||
var level = values[0]
|
||||
var trend = values[1] - values[0]
|
||||
|
||||
for value in values.prefix(splitIndex) {
|
||||
let lastLevel = level
|
||||
level = 0.4 * value + 0.6 * (level + trend)
|
||||
trend = 0.3 * (level - lastLevel) + 0.7 * trend
|
||||
}
|
||||
|
||||
let validationValues = Array(values.suffix(from: splitIndex))
|
||||
let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count)
|
||||
|
||||
var ssRes: Double = 0
|
||||
var ssTot: Double = 0
|
||||
|
||||
for (i, actual) in validationValues.enumerated() {
|
||||
let predicted = level + Double(i + 1) * trend
|
||||
ssRes += pow(actual - predicted, 2)
|
||||
ssTot += pow(actual - meanValidation, 2)
|
||||
}
|
||||
|
||||
guard ssTot != 0 else { return 0.5 }
|
||||
return max(0, min(1.0, 1 - (ssRes / ssTot)))
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func calculateVolatility(snapshots: [Snapshot]) -> Double {
|
||||
let values = snapshots.map { $0.decimalValue.doubleValue }
|
||||
guard values.count >= 2 else { return 0 }
|
||||
|
||||
var returns: [Double] = []
|
||||
for i in 1..<values.count {
|
||||
guard values[i - 1] != 0 else { continue }
|
||||
let periodReturn = (values[i] - values[i - 1]) / values[i - 1] * 100
|
||||
returns.append(periodReturn)
|
||||
}
|
||||
|
||||
return calculateStdDev(values: returns)
|
||||
}
|
||||
|
||||
private func calculateStdDev(values: [Double]) -> Double {
|
||||
guard values.count >= 2 else { return 0 }
|
||||
|
||||
let mean = values.reduce(0, +) / Double(values.count)
|
||||
let squaredDifferences = values.map { pow($0 - mean, 2) }
|
||||
let variance = squaredDifferences.reduce(0, +) / Double(values.count - 1)
|
||||
|
||||
return sqrt(variance)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import Foundation
|
||||
|
||||
class SampleDataService {
|
||||
static let shared = SampleDataService()
|
||||
|
||||
private init() {}
|
||||
|
||||
func seedSampleData() {
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
let sourceRepository = InvestmentSourceRepository(context: context)
|
||||
guard sourceRepository.sourceCount == 0 else { return }
|
||||
|
||||
let categoryRepository = CategoryRepository(context: context)
|
||||
categoryRepository.createDefaultCategoriesIfNeeded()
|
||||
|
||||
let accountRepository = AccountRepository(context: context)
|
||||
let account = accountRepository.createDefaultAccountIfNeeded()
|
||||
|
||||
let snapshotRepository = SnapshotRepository(context: context)
|
||||
let goalRepository = GoalRepository(context: context)
|
||||
let transactionRepository = TransactionRepository(context: context)
|
||||
|
||||
let categories = categoryRepository.categories
|
||||
let stocksCategory = categories.first { $0.name == "Stocks" } ?? categories.first!
|
||||
let cryptoCategory = categories.first { $0.name == "Crypto" } ?? categories.first!
|
||||
let realEstateCategory = categories.first { $0.name == "Real Estate" } ?? categories.first!
|
||||
|
||||
let stocks = sourceRepository.createSource(
|
||||
name: "Index Fund",
|
||||
category: stocksCategory,
|
||||
account: account
|
||||
)
|
||||
let crypto = sourceRepository.createSource(
|
||||
name: "BTC",
|
||||
category: cryptoCategory,
|
||||
account: account
|
||||
)
|
||||
let realEstate = sourceRepository.createSource(
|
||||
name: "Rental Property",
|
||||
category: realEstateCategory,
|
||||
account: account
|
||||
)
|
||||
|
||||
seedSnapshots(for: stocks, baseValue: 12000, monthlyIncrease: 450, repository: snapshotRepository)
|
||||
seedSnapshots(for: crypto, baseValue: 3000, monthlyIncrease: 250, repository: snapshotRepository)
|
||||
seedSnapshots(for: realEstate, baseValue: 80000, monthlyIncrease: 600, repository: snapshotRepository)
|
||||
|
||||
seedMonthlyNotes()
|
||||
|
||||
transactionRepository.createTransaction(
|
||||
source: stocks,
|
||||
type: .buy,
|
||||
date: Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date(),
|
||||
shares: 10,
|
||||
price: 400,
|
||||
amount: nil,
|
||||
notes: "Sample buy"
|
||||
)
|
||||
|
||||
_ = goalRepository.createGoal(
|
||||
name: "1M Goal",
|
||||
targetAmount: 1_000_000,
|
||||
targetDate: nil,
|
||||
account: account
|
||||
)
|
||||
}
|
||||
|
||||
private func seedSnapshots(
|
||||
for source: InvestmentSource,
|
||||
baseValue: Decimal,
|
||||
monthlyIncrease: Decimal,
|
||||
repository: SnapshotRepository
|
||||
) {
|
||||
let calendar = Calendar.current
|
||||
for monthOffset in (0..<6).reversed() {
|
||||
let date = calendar.date(byAdding: .month, value: -monthOffset, to: Date()) ?? Date()
|
||||
let value = baseValue + Decimal(monthOffset) * monthlyIncrease
|
||||
repository.createSnapshot(
|
||||
for: source,
|
||||
date: date,
|
||||
value: value,
|
||||
contribution: monthOffset == 0 ? monthlyIncrease : nil,
|
||||
notes: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func seedMonthlyNotes() {
|
||||
let calendar = Calendar.current
|
||||
let notes = [
|
||||
"Rebalanced slightly toward equities. Stayed calm despite noise.",
|
||||
"Focused on contributions. No major changes.",
|
||||
"Reviewed allocation drift and decided to hold positions."
|
||||
]
|
||||
|
||||
for (index, note) in notes.enumerated() {
|
||||
let date = calendar.date(byAdding: .month, value: -index, to: Date()) ?? Date()
|
||||
MonthlyCheckInStore.setNote(note, for: date)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
class ShareService {
|
||||
static let shared = ShareService()
|
||||
|
||||
private init() {}
|
||||
|
||||
func shareTextFile(content: String, fileName: String) {
|
||||
guard let viewController = ShareService.topViewController() else { return }
|
||||
|
||||
let tempURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(fileName)
|
||||
|
||||
do {
|
||||
try content.write(to: tempURL, atomically: true, encoding: .utf8)
|
||||
|
||||
let activityVC = UIActivityViewController(
|
||||
activityItems: [tempURL],
|
||||
applicationActivities: nil
|
||||
)
|
||||
|
||||
if let popover = activityVC.popoverPresentationController {
|
||||
popover.sourceView = viewController.view
|
||||
popover.sourceRect = CGRect(
|
||||
x: viewController.view.bounds.midX,
|
||||
y: viewController.view.bounds.midY,
|
||||
width: 0,
|
||||
height: 0
|
||||
)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
viewController.present(activityVC, animated: true)
|
||||
}
|
||||
} catch {
|
||||
print("Share file error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func shareCalendarEvent(
|
||||
title: String,
|
||||
notes: String,
|
||||
startDate: Date,
|
||||
durationMinutes: Int = 60
|
||||
) {
|
||||
let endDate = startDate.addingTimeInterval(TimeInterval(durationMinutes * 60))
|
||||
let icsContent = calendarICS(
|
||||
title: title,
|
||||
notes: notes,
|
||||
startDate: startDate,
|
||||
endDate: endDate
|
||||
)
|
||||
shareTextFile(content: icsContent, fileName: "PortfolioJournal-CheckIn.ics")
|
||||
}
|
||||
|
||||
private static func topViewController(
|
||||
base: UIViewController? = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap { $0.windows }
|
||||
.first(where: { $0.isKeyWindow })?.rootViewController
|
||||
) -> UIViewController? {
|
||||
if let nav = base as? UINavigationController {
|
||||
return topViewController(base: nav.visibleViewController)
|
||||
}
|
||||
if let tab = base as? UITabBarController {
|
||||
return topViewController(base: tab.selectedViewController)
|
||||
}
|
||||
if let presented = base?.presentedViewController {
|
||||
return topViewController(base: presented)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
private func calendarICS(
|
||||
title: String,
|
||||
notes: String,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
) -> String {
|
||||
let uid = UUID().uuidString
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
let stamp = formatter.string(from: Date())
|
||||
let start = formatter.string(from: startDate)
|
||||
let end = formatter.string(from: endDate)
|
||||
|
||||
return """
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//PortfolioJournal//MonthlyCheckIn//EN
|
||||
BEGIN:VEVENT
|
||||
UID:\(uid)
|
||||
DTSTAMP:\(stamp)
|
||||
DTSTART:\(start)
|
||||
DTEND:\(end)
|
||||
SUMMARY:\(escapeICS(title))
|
||||
DESCRIPTION:\(escapeICS(notes))
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
"""
|
||||
}
|
||||
|
||||
private func escapeICS(_ value: String) -> String {
|
||||
value
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "\n", with: "\\n")
|
||||
.replacingOccurrences(of: ";", with: "\\;")
|
||||
.replacingOccurrences(of: ",", with: "\\,")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user