initial version

This commit is contained in:
alexandrev-tibco
2026-01-15 09:24:06 +01:00
parent bab350dd22
commit 7988257399
139 changed files with 13149 additions and 3233 deletions
@@ -0,0 +1,41 @@
import Foundation
enum AllocationTargetStore {
private static let targetsKey = "allocationTargets"
static func target(for categoryId: UUID) -> Double? {
loadTargets()[categoryId.uuidString]
}
static func setTarget(_ value: Double?, for categoryId: UUID) {
var targets = loadTargets()
let key = categoryId.uuidString
if let value, value > 0 {
targets[key] = value
} else {
targets.removeValue(forKey: key)
}
saveTargets(targets)
}
static func totalTargetPercentage(for categoryIds: [UUID]) -> Double {
let targets = loadTargets()
return categoryIds.reduce(0) { total, id in
total + (targets[id.uuidString] ?? 0)
}
}
private static func loadTargets() -> [String: Double] {
guard let data = UserDefaults.standard.data(forKey: targetsKey),
let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else {
return [:]
}
return decoded
}
private static func saveTargets(_ targets: [String: Double]) {
if let data = try? JSONEncoder().encode(targets) {
UserDefaults.standard.set(data, forKey: targetsKey)
}
}
}
@@ -0,0 +1,19 @@
import Foundation
import LocalAuthentication
enum AppLockService {
static func canUseBiometrics() -> Bool {
let context = LAContext()
var error: NSError?
return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
}
static func authenticate(reason: String, completion: @escaping (Bool) -> Void) {
let context = LAContext()
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in
DispatchQueue.main.async {
completion(success)
}
}
}
}
@@ -0,0 +1,175 @@
import Foundation
enum AppConstants {
// MARK: - App Info
static let appName = "Portfolio Journal"
static let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
static let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
// MARK: - Bundle Identifiers
static let bundleIdentifier = "com.alexandrevazquez.portfoliojournal"
static let appGroupIdentifier = "group.com.alexandrevazquez.portfoliojournal"
static let cloudKitContainerIdentifier = "iCloud.com.alexandrevazquez.portfoliojournal"
// MARK: - StoreKit
static let premiumProductID = "com.portfoliojournal.premium"
static let premiumPrice = "€4.69"
// MARK: - AdMob
#if DEBUG
static let adMobAppID = "ca-app-pub-3940256099942544~1458002511" // Test App ID
static let bannerAdUnitID = "ca-app-pub-3940256099942544/2934735716" // Test Banner
#else
static let adMobAppID = "ca-app-pub-XXXXXXXXXXXXXXXX~YYYYYYYYYY" // Replace with real App ID
static let bannerAdUnitID = "ca-app-pub-XXXXXXXXXXXXXXXX/YYYYYYYYYY" // Replace with real Ad Unit ID
#endif
// MARK: - Currency
static let defaultCurrency = "EUR"
static let currencySymbol = ""
// MARK: - Freemium Limits
static let maxFreeSources = 5
static let maxFreeHistoricalMonths = 12
// MARK: - UI Constants
enum UI {
static let cornerRadius: CGFloat = 12
static let smallCornerRadius: CGFloat = 8
static let largeCornerRadius: CGFloat = 16
static let padding: CGFloat = 16
static let smallPadding: CGFloat = 8
static let largePadding: CGFloat = 24
static let iconSize: CGFloat = 24
static let smallIconSize: CGFloat = 16
static let largeIconSize: CGFloat = 32
static let bannerAdHeight: CGFloat = 50
static let tabBarHeight: CGFloat = 49
static let cardShadowRadius: CGFloat = 4
static let cardShadowOpacity: CGFloat = 0.1
}
// MARK: - Animation
enum Animation {
static let defaultDuration: Double = 0.3
static let shortDuration: Double = 0.15
static let longDuration: Double = 0.5
}
// MARK: - Charts
enum Charts {
static let defaultMonthsToShow = 12
static let predictionMonths = 12
static let minDataPointsForPrediction = 3
static let confidenceIntervalPercentage = 0.15
}
// MARK: - Notifications
enum Notifications {
static let defaultHour = 9
static let defaultMinute = 0
static let categoryIdentifier = "INVESTMENT_REMINDER"
}
// MARK: - Storage Keys
enum StorageKeys {
static let onboardingCompleted = "onboardingCompleted"
static let adConsentObtained = "adConsentObtained"
static let lastSyncDate = "lastSyncDate"
static let selectedCategoryFilter = "selectedCategoryFilter"
static let preferredChartType = "preferredChartType"
}
// MARK: - Deep Links
enum DeepLinks {
static let scheme = "portfoliojournal"
static let sourceDetail = "source"
static let addSnapshot = "addSnapshot"
static let premium = "premium"
}
// MARK: - URLs
enum URLs {
static let privacyPolicy = "https://portfoliojournal.app/privacy.html"
static let termsOfService = "https://portfoliojournal.app/terms.html"
static let support = "https://portfoliojournal.app/support.html"
static let appStore = "https://apps.apple.com/app/idXXXXXXXXXX"
}
// MARK: - Feature Flags
enum Features {
static let enablePredictions = true
static let enableExport = true
static let enableWidgets = true
static let enableNotifications = true
static let enableAnalytics = true
}
}
// MARK: - SF Symbols
enum SFSymbol {
// Navigation
static let dashboard = "chart.pie.fill"
static let sources = "list.bullet"
static let charts = "chart.xyaxis.line"
static let settings = "gearshape.fill"
// Actions
static let add = "plus"
static let edit = "pencil"
static let delete = "trash"
static let share = "square.and.arrow.up"
static let export = "arrow.up.doc"
// Status
static let checkmark = "checkmark.circle.fill"
static let warning = "exclamationmark.triangle.fill"
static let error = "xmark.circle.fill"
static let info = "info.circle.fill"
// Financial
static let trendUp = "arrow.up.right"
static let trendDown = "arrow.down.right"
static let money = "eurosign.circle.fill"
static let chart = "chart.line.uptrend.xyaxis"
// Categories
static let stocks = "chart.line.uptrend.xyaxis"
static let bonds = "building.columns.fill"
static let realEstate = "house.fill"
static let crypto = "bitcoinsign.circle.fill"
static let cash = "banknote.fill"
static let etf = "chart.bar.fill"
static let retirement = "person.fill"
static let other = "ellipsis.circle.fill"
// Premium
static let premium = "crown.fill"
static let lock = "lock.fill"
static let unlock = "lock.open.fill"
// Misc
static let calendar = "calendar"
static let notification = "bell.fill"
static let refresh = "arrow.clockwise"
static let close = "xmark"
}
@@ -0,0 +1,23 @@
import Foundation
enum CurrencyFormatter {
static func currentCurrencyCode() -> String {
let context = CoreDataStack.shared.viewContext
return AppSettings.getOrCreate(in: context).currency
}
static func format(_ decimal: Decimal, style: NumberFormatter.Style = .currency, maximumFractionDigits: Int = 2) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = style
formatter.currencyCode = currentCurrencyCode()
formatter.maximumFractionDigits = maximumFractionDigits
return formatter.string(from: decimal as NSDecimalNumber) ?? "\(decimal)"
}
static func symbol(for code: String) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = code
return formatter.currencySymbol ?? code
}
}
@@ -0,0 +1,9 @@
import Foundation
enum CurrencyPicker {
static let commonCodes: [String] = [
"EUR", "USD", "GBP", "CHF", "JPY",
"CAD", "AUD", "SEK", "NOK", "DKK"
]
}
@@ -0,0 +1,96 @@
import Foundation
struct DashboardSectionConfig: Identifiable, Codable, Hashable {
let id: String
var isVisible: Bool
var isCollapsed: Bool
}
enum DashboardSection: String, CaseIterable, Identifiable {
case totalValue
case monthlyCheckIn
case momentumStreaks
case monthlySummary
case evolution
case categoryBreakdown
case goals
case pendingUpdates
case periodReturns
var id: String { rawValue }
var title: String {
switch self {
case .totalValue:
return "Total Portfolio Value"
case .monthlyCheckIn:
return "Monthly Check-in"
case .momentumStreaks:
return "Momentum & Streaks"
case .monthlySummary:
return "Cashflow vs Growth"
case .evolution:
return "Portfolio Evolution"
case .categoryBreakdown:
return "By Category"
case .goals:
return "Goals"
case .pendingUpdates:
return "Pending Updates"
case .periodReturns:
return "Returns"
}
}
}
enum DashboardLayoutStore {
private static let storageKey = "dashboardLayoutConfig"
static func load() -> [DashboardSectionConfig] {
let defaults = defaultConfigs()
guard let data = UserDefaults.standard.data(forKey: storageKey),
let decoded = try? JSONDecoder().decode([DashboardSectionConfig].self, from: data) else {
return defaults
}
var merged: [DashboardSectionConfig] = []
for config in decoded {
if let section = DashboardSection(rawValue: config.id) {
merged.append(config)
} else {
continue
}
}
for section in DashboardSection.allCases {
if !merged.contains(where: { $0.id == section.id }) {
merged.append(defaults.first(where: { $0.id == section.id }) ?? DashboardSectionConfig(
id: section.id,
isVisible: true,
isCollapsed: false
))
}
}
return merged
}
static func save(_ configs: [DashboardSectionConfig]) {
guard let data = try? JSONEncoder().encode(configs) else { return }
UserDefaults.standard.set(data, forKey: storageKey)
}
static func reset() {
UserDefaults.standard.removeObject(forKey: storageKey)
}
private static func defaultConfigs() -> [DashboardSectionConfig] {
DashboardSection.allCases.map { section in
DashboardSectionConfig(
id: section.id,
isVisible: true,
isCollapsed: false
)
}
}
}
@@ -0,0 +1,166 @@
import SwiftUI
extension Color {
// MARK: - Hex Initialization
init?(hex: String) {
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
var rgb: UInt64 = 0
guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else {
return nil
}
let length = hexSanitized.count
switch length {
case 6:
self.init(
red: Double((rgb & 0xFF0000) >> 16) / 255.0,
green: Double((rgb & 0x00FF00) >> 8) / 255.0,
blue: Double(rgb & 0x0000FF) / 255.0
)
case 8:
self.init(
red: Double((rgb & 0xFF000000) >> 24) / 255.0,
green: Double((rgb & 0x00FF0000) >> 16) / 255.0,
blue: Double((rgb & 0x0000FF00) >> 8) / 255.0,
opacity: Double(rgb & 0x000000FF) / 255.0
)
default:
return nil
}
}
// MARK: - Hex String Output
var hexString: String {
guard let components = UIColor(self).cgColor.components else {
return "#000000"
}
let r = Int(components[0] * 255.0)
let g = Int(components[1] * 255.0)
let b = Int(components[2] * 255.0)
return String(format: "#%02X%02X%02X", r, g, b)
}
// MARK: - App Colors
static let appPrimary = Color(hex: "#3B82F6") ?? .blue
static let appSecondary = Color(hex: "#10B981") ?? .green
static let appAccent = Color(hex: "#F59E0B") ?? .orange
static let appError = Color(hex: "#EF4444") ?? .red
static let appSuccess = Color(hex: "#10B981") ?? .green
static let appWarning = Color(hex: "#F59E0B") ?? .orange
// MARK: - Financial Colors
static let positiveGreen = Color(hex: "#10B981") ?? .green
static let negativeRed = Color(hex: "#EF4444") ?? .red
static let neutralGray = Color(hex: "#6B7280") ?? .gray
static func financialColor(for value: Decimal) -> Color {
if value > 0 {
return .positiveGreen
} else if value < 0 {
return .negativeRed
} else {
return .neutralGray
}
}
static func financialColor(for value: Double) -> Color {
if value > 0 {
return .positiveGreen
} else if value < 0 {
return .negativeRed
} else {
return .neutralGray
}
}
// MARK: - Category Colors
static let categoryColors: [String] = [
"#3B82F6", // Blue
"#10B981", // Green
"#F59E0B", // Amber
"#EF4444", // Red
"#8B5CF6", // Purple
"#EC4899", // Pink
"#14B8A6", // Teal
"#F97316", // Orange
"#6366F1", // Indigo
"#84CC16", // Lime
"#06B6D4", // Cyan
"#A855F7" // Violet
]
static func categoryColor(at index: Int) -> Color {
let hex = categoryColors[index % categoryColors.count]
return Color(hex: hex) ?? .blue
}
// MARK: - Chart Colors
static let chartColors: [Color] = categoryColors.compactMap { Color(hex: $0) }
// MARK: - Adjustments
func lighter(by percentage: Double = 0.2) -> Color {
adjustBrightness(by: abs(percentage))
}
func darker(by percentage: Double = 0.2) -> Color {
adjustBrightness(by: -abs(percentage))
}
private func adjustBrightness(by percentage: Double) -> Color {
var hue: CGFloat = 0
var saturation: CGFloat = 0
var brightness: CGFloat = 0
var alpha: CGFloat = 0
UIColor(self).getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
let newBrightness = max(0, min(1, brightness + CGFloat(percentage)))
return Color(UIColor(hue: hue, saturation: saturation, brightness: newBrightness, alpha: alpha))
}
// MARK: - Opacity Variants
var soft: Color {
self.opacity(0.1)
}
var medium: Color {
self.opacity(0.5)
}
}
// MARK: - Gradient Extensions
extension LinearGradient {
static let appPrimaryGradient = LinearGradient(
colors: [Color.appPrimary, Color.appPrimary.lighter()],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
static let positiveGradient = LinearGradient(
colors: [Color.positiveGreen.lighter(), Color.positiveGreen],
startPoint: .top,
endPoint: .bottom
)
static let negativeGradient = LinearGradient(
colors: [Color.negativeRed.lighter(), Color.negativeRed],
startPoint: .top,
endPoint: .bottom
)
}
@@ -0,0 +1,199 @@
import Foundation
extension Date {
// MARK: - Formatting
var shortDateString: String {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .none
return formatter.string(from: self)
}
var mediumDateString: String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
return formatter.string(from: self)
}
var longDateString: String {
let formatter = DateFormatter()
formatter.dateStyle = .long
formatter.timeStyle = .none
return formatter.string(from: self)
}
var monthYearString: String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM yyyy"
return formatter.string(from: self)
}
var yearString: String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy"
return formatter.string(from: self)
}
var iso8601String: String {
ISO8601DateFormatter().string(from: self)
}
// MARK: - Components
var startOfDay: Date {
Calendar.current.startOfDay(for: self)
}
var startOfMonth: Date {
let components = Calendar.current.dateComponents([.year, .month], from: self)
return Calendar.current.date(from: components) ?? self
}
var startOfYear: Date {
let components = Calendar.current.dateComponents([.year], from: self)
return Calendar.current.date(from: components) ?? self
}
var endOfMonth: Date {
let startOfNextMonth = Calendar.current.date(
byAdding: .month,
value: 1,
to: startOfMonth
) ?? self
return Calendar.current.date(byAdding: .day, value: -1, to: startOfNextMonth) ?? self
}
// MARK: - Comparisons
func isSameDay(as other: Date) -> Bool {
Calendar.current.isDate(self, inSameDayAs: other)
}
func isSameMonth(as other: Date) -> Bool {
let selfComponents = Calendar.current.dateComponents([.year, .month], from: self)
let otherComponents = Calendar.current.dateComponents([.year, .month], from: other)
return selfComponents.year == otherComponents.year &&
selfComponents.month == otherComponents.month
}
func isSameYear(as other: Date) -> Bool {
Calendar.current.component(.year, from: self) ==
Calendar.current.component(.year, from: other)
}
var isToday: Bool {
Calendar.current.isDateInToday(self)
}
var isYesterday: Bool {
Calendar.current.isDateInYesterday(self)
}
var isThisMonth: Bool {
isSameMonth(as: Date())
}
var isThisYear: Bool {
isSameYear(as: Date())
}
// MARK: - Calculations
func adding(days: Int) -> Date {
Calendar.current.date(byAdding: .day, value: days, to: self) ?? self
}
func adding(months: Int) -> Date {
Calendar.current.date(byAdding: .month, value: months, to: self) ?? self
}
func adding(years: Int) -> Date {
Calendar.current.date(byAdding: .year, value: years, to: self) ?? self
}
func monthsBetween(_ other: Date) -> Int {
let components = Calendar.current.dateComponents([.month], from: self, to: other)
return components.month ?? 0
}
func daysBetween(_ other: Date) -> Int {
let components = Calendar.current.dateComponents([.day], from: self, to: other)
return components.day ?? 0
}
func yearsBetween(_ other: Date) -> Double {
let days = Double(daysBetween(other))
return days / 365.25
}
// MARK: - Relative Description
var relativeDescription: String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter.localizedString(for: self, relativeTo: Date())
}
var friendlyDescription: String {
if isToday {
return String(localized: "date_today")
} else if isYesterday {
return String(localized: "date_yesterday")
} else if isThisMonth {
let formatter = DateFormatter()
formatter.dateFormat = "EEEE, d"
return formatter.string(from: self)
} else if isThisYear {
let formatter = DateFormatter()
formatter.dateFormat = "MMM d"
return formatter.string(from: self)
} else {
return mediumDateString
}
}
}
// MARK: - Date Range
struct DateRange {
let start: Date
let end: Date
static var thisMonth: DateRange {
let now = Date()
return DateRange(start: now.startOfMonth, end: now)
}
static var lastMonth: DateRange {
let now = Date()
let lastMonth = now.adding(months: -1)
return DateRange(start: lastMonth.startOfMonth, end: lastMonth.endOfMonth)
}
static var thisYear: DateRange {
let now = Date()
return DateRange(start: now.startOfYear, end: now)
}
static var lastYear: DateRange {
let now = Date()
let lastYear = now.adding(years: -1)
return DateRange(start: lastYear.startOfYear, end: now.startOfYear.adding(days: -1))
}
static func month(containing date: Date) -> DateRange {
DateRange(start: date.startOfMonth, end: date.endOfMonth)
}
static func last(months: Int) -> DateRange {
let now = Date()
let start = now.adding(months: -months)
return DateRange(start: start, end: now)
}
func contains(_ date: Date) -> Bool {
date >= start && date <= end
}
}
@@ -0,0 +1,153 @@
import Foundation
extension Decimal {
// MARK: - Performance: Shared formatters (avoid creating on every call)
private static let percentFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .percent
formatter.maximumFractionDigits = 2
formatter.multiplier = 1
return formatter
}()
private static let decimalFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = 2
return formatter
}()
// MARK: - Performance: Cached currency symbol
private static var _cachedCurrencySymbol: String?
private static var currencySymbolCacheTime: Date?
private static var cachedCurrencySymbol: String {
// Refresh cache every 60 seconds to pick up settings changes
let now = Date()
if let cached = _cachedCurrencySymbol,
let cacheTime = currencySymbolCacheTime,
now.timeIntervalSince(cacheTime) < 60 {
return cached
}
let symbol = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol
_cachedCurrencySymbol = symbol
currencySymbolCacheTime = now
return symbol
}
/// Call this when currency settings change to invalidate the cache
static func invalidateCurrencyCache() {
_cachedCurrencySymbol = nil
currencySymbolCacheTime = nil
}
// MARK: - Formatting
var currencyString: String {
CurrencyFormatter.format(self, style: .currency, maximumFractionDigits: 2)
}
var compactCurrencyString: String {
CurrencyFormatter.format(self, style: .currency, maximumFractionDigits: 0)
}
var shortCurrencyString: String {
let value = NSDecimalNumber(decimal: self).doubleValue
let symbol = Self.cachedCurrencySymbol
switch Swift.abs(value) {
case 1_000_000...:
return String(format: "%@%.1fM", symbol, value / 1_000_000)
case 1_000...:
return String(format: "%@%.1fK", symbol, value / 1_000)
default:
return compactCurrencyString
}
}
var percentageString: String {
Self.percentFormatter.string(from: self as NSDecimalNumber) ?? "0%"
}
var signedPercentageString: String {
let prefix = self >= 0 ? "+" : ""
return prefix + percentageString
}
var decimalString: String {
Self.decimalFormatter.string(from: self as NSDecimalNumber) ?? "0"
}
// MARK: - Conversions
var doubleValue: Double {
NSDecimalNumber(decimal: self).doubleValue
}
var intValue: Int {
NSDecimalNumber(decimal: self).intValue
}
// MARK: - Math Operations
var abs: Decimal {
self < 0 ? -self : self
}
func rounded(scale: Int = 2) -> Decimal {
var result = Decimal()
var mutableSelf = self
NSDecimalRound(&result, &mutableSelf, scale, .plain)
return result
}
// MARK: - Comparisons
var isPositive: Bool {
self > 0
}
var isNegative: Bool {
self < 0
}
var isZero: Bool {
self == 0
}
// MARK: - Static Helpers
static func from(_ double: Double) -> Decimal {
Decimal(double)
}
static func from(_ string: String) -> Decimal? {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
return formatter.number(from: string)?.decimalValue
}
}
// MARK: - NSDecimalNumber Extension
extension NSDecimalNumber {
var currencyString: String {
decimalValue.currencyString
}
var compactCurrencyString: String {
decimalValue.compactCurrencyString
}
}
// MARK: - Optional Decimal
extension Optional where Wrapped == Decimal {
var orZero: Decimal {
self ?? Decimal.zero
}
var currencyString: String {
(self ?? Decimal.zero).currencyString
}
}
@@ -0,0 +1,194 @@
import Foundation
import Combine
enum FreemiumLimits {
static let maxSources = 5
static let maxHistoricalMonths = 12
}
class FreemiumValidator: ObservableObject {
private let iapService: IAPService
init(iapService: IAPService) {
self.iapService = iapService
}
// MARK: - Source Limits
var isPremium: Bool {
iapService.isPremium
}
func canAddSource(currentCount: Int) -> Bool {
if iapService.isPremium { return true }
return currentCount < FreemiumLimits.maxSources
}
func remainingSources(currentCount: Int) -> Int {
if iapService.isPremium { return Int.max }
return max(0, FreemiumLimits.maxSources - currentCount)
}
var sourceLimit: Int {
iapService.isPremium ? Int.max : FreemiumLimits.maxSources
}
var sourceLimitDescription: String {
if iapService.isPremium {
return "Unlimited"
}
return "\(FreemiumLimits.maxSources) sources"
}
// MARK: - Historical Data Limits
func filterSnapshots(_ snapshots: [Snapshot]) -> [Snapshot] {
if iapService.isPremium { return snapshots }
let cutoffDate = Calendar.current.date(
byAdding: .month,
value: -FreemiumLimits.maxHistoricalMonths,
to: Date()
) ?? Date()
return snapshots.filter { $0.date >= cutoffDate }
}
func isSnapshotAccessible(_ snapshot: Snapshot) -> Bool {
if iapService.isPremium { return true }
let cutoffDate = Calendar.current.date(
byAdding: .month,
value: -FreemiumLimits.maxHistoricalMonths,
to: Date()
) ?? Date()
return snapshot.date >= cutoffDate
}
var historicalLimit: Int {
iapService.isPremium ? Int.max : FreemiumLimits.maxHistoricalMonths
}
var historicalLimitDescription: String {
if iapService.isPremium {
return "Full history"
}
return "Last \(FreemiumLimits.maxHistoricalMonths) months"
}
// MARK: - Feature Access
func canExport() -> Bool {
return iapService.isPremium
}
func canViewPredictions() -> Bool {
return iapService.isPremium
}
func canViewAdvancedCharts() -> Bool {
return iapService.isPremium
}
func canAccessFeature(_ feature: PremiumFeature) -> Bool {
if iapService.isPremium { return true }
switch feature {
case .multipleAccounts:
return false
case .unlimitedSources:
return false
case .fullHistory:
return false
case .advancedCharts:
return false
case .predictions:
return false
case .export:
return false
case .noAds:
return false
}
}
// MARK: - Premium Features Enum
enum PremiumFeature: String, CaseIterable, Identifiable {
case multipleAccounts = "multiple_accounts"
case unlimitedSources = "unlimited_sources"
case fullHistory = "full_history"
case advancedCharts = "advanced_charts"
case predictions = "predictions"
case export = "export"
case noAds = "no_ads"
var id: String { rawValue }
var title: String {
switch self {
case .multipleAccounts: return "Multiple Accounts"
case .unlimitedSources: return "Unlimited Sources"
case .fullHistory: return "Full History"
case .advancedCharts: return "Advanced Charts"
case .predictions: return "Predictions"
case .export: return "Export Data"
case .noAds: return "No Ads"
}
}
var icon: String {
switch self {
case .multipleAccounts: return "person.2"
case .unlimitedSources: return "infinity"
case .fullHistory: return "clock.arrow.circlepath"
case .advancedCharts: return "chart.bar.xaxis"
case .predictions: return "wand.and.stars"
case .export: return "square.and.arrow.up"
case .noAds: return "xmark.circle"
}
}
var description: String {
switch self {
case .multipleAccounts:
return "Track separate portfolios for family or business"
case .unlimitedSources:
return "Track as many investment sources as you want"
case .fullHistory:
return "Access your complete investment history"
case .advancedCharts:
return "5 types of detailed analytics charts"
case .predictions:
return "AI-powered 12-month forecasts"
case .export:
return "Export to CSV and JSON formats"
case .noAds:
return "Ad-free experience forever"
}
}
}
// MARK: - Paywall Triggers
enum PaywallTrigger: String {
case sourceLimit = "source_limit"
case historyLimit = "history_limit"
case advancedCharts = "advanced_charts"
case predictions = "predictions"
case export = "export"
case settingsUpgrade = "settings_upgrade"
case manualTap = "manual_tap"
}
func shouldShowPaywall(for trigger: PaywallTrigger) -> Bool {
guard !iapService.isPremium else { return false }
switch trigger {
case .sourceLimit, .historyLimit, .advancedCharts, .predictions, .export:
return true
case .settingsUpgrade, .manualTap:
return true
}
}
}
@@ -0,0 +1,47 @@
import Foundation
import Security
enum KeychainService {
private static let service = "PortfolioJournal"
private static let pinKey = "appLockPin"
static func savePin(_ pin: String) -> Bool {
guard let data = pin.data(using: .utf8) else { return false }
deletePin()
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: pinKey,
kSecValueData: data
]
return SecItemAdd(query as CFDictionary, nil) == errSecSuccess
}
static func readPin() -> String? {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: pinKey,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let pin = String(data: data, encoding: .utf8) else {
return nil
}
return pin
}
static func deletePin() {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: pinKey
]
SecItemDelete(query as CFDictionary)
}
}
@@ -0,0 +1,455 @@
import Foundation
enum MonthlyCheckInStore {
private static let notesKey = "monthlyCheckInNotes"
private static let completionsKey = "monthlyCheckInCompletions"
private static let legacyLastCheckInKey = "lastCheckInDate"
private static let entriesKey = "monthlyCheckInEntries"
// MARK: - Public Accessors
static func note(for date: Date) -> String {
entry(for: date)?.note ?? ""
}
static func setNote(_ note: String, for date: Date) {
updateEntry(for: date) { entry in
let trimmed = note.trimmingCharacters(in: .whitespacesAndNewlines)
entry.note = trimmed.isEmpty ? nil : note
}
}
static func rating(for date: Date) -> Int? {
entry(for: date)?.rating
}
static func setRating(_ rating: Int?, for date: Date) {
updateEntry(for: date) { entry in
if let rating, rating > 0 {
entry.rating = min(max(1, rating), 5)
} else {
entry.rating = nil
}
}
}
static func mood(for date: Date) -> MonthlyCheckInMood? {
entry(for: date)?.mood
}
static func setMood(_ mood: MonthlyCheckInMood?, for date: Date) {
updateEntry(for: date) { entry in
entry.mood = mood
}
}
static func monthKey(for date: Date) -> String {
Self.monthFormatter.string(from: date)
}
static func allNotes() -> [(date: Date, note: String)] {
loadEntries()
.compactMap { key, entry in
guard let date = Self.monthFormatter.date(from: key) else { return nil }
return (date: date, note: entry.note ?? "")
}
.sorted { $0.date > $1.date }
}
static func entry(for date: Date) -> MonthlyCheckInEntry? {
loadEntries()[monthKey(for: date)]
}
static func allEntries() -> [(date: Date, entry: MonthlyCheckInEntry)] {
loadEntries()
.compactMap { key, entry in
guard let date = Self.monthFormatter.date(from: key) else { return nil }
return (date: date, entry: entry)
}
.sorted { $0.date > $1.date }
}
static func completionDate(for date: Date) -> Date? {
entry(for: date)?.completionDate
}
static func setCompletionDate(_ completionDate: Date, for month: Date) {
updateEntry(for: month) { entry in
entry.completionTime = completionDate.timeIntervalSince1970
}
}
static func latestCompletionDate() -> Date? {
let latestEntryDate = loadEntries().values
.compactMap { $0.completionDate }
.max()
if let latestEntryDate {
return latestEntryDate
}
let legacy = UserDefaults.standard.double(forKey: legacyLastCheckInKey)
guard legacy > 0 else { return nil }
return Date(timeIntervalSince1970: legacy)
}
static func stats(referenceDate: Date = Date()) -> MonthlyCheckInStats {
let cutoff = referenceDate.endOfMonth
let entries = allEntries().filter { $0.date <= cutoff }
let completions: [(month: Date, completion: Date, mood: MonthlyCheckInMood?)] = entries.compactMap { entry in
guard let completion = entry.entry.completionDate else { return nil }
return (month: entry.date.startOfMonth, completion: completion, mood: entry.entry.mood)
}
guard !completions.isEmpty else { return .empty }
let deadlineDiffs = completions.map { item -> Double in
let deadline = item.month.endOfMonth
return deadline.timeIntervalSince(item.completion) / 86_400
}
let onTimeCompletions = completions.filter { item in
item.completion <= item.month.endOfMonth
}
let onTimeMonths = Set(onTimeCompletions.map { $0.month })
let totalCheckIns = completions.count
let onTimeCount = onTimeMonths.count
// Current streak counts consecutive on-time months up to the reference month.
var currentStreak = 0
var cursor = referenceDate.startOfMonth
while onTimeMonths.contains(cursor) {
currentStreak += 1
cursor = cursor.adding(months: -1).startOfMonth
}
// Best streak across history.
let sortedMonths = onTimeMonths.sorted()
var bestStreak = 0
var running = 0
var previousMonth: Date?
for month in sortedMonths {
if let previousMonth, month == previousMonth.adding(months: 1).startOfMonth {
running += 1
} else {
running = 1
}
bestStreak = max(bestStreak, running)
previousMonth = month
}
let averageDaysBeforeDeadline = onTimeCount > 0
? deadlineDiffs
.filter { $0 >= 0 }
.average()
: nil
let closestCutoffDays = onTimeCount > 0
? deadlineDiffs.filter { $0 >= 0 }.min()
: nil
let recentMood = completions.sorted { $0.month > $1.month }.first?.mood
let achievements = buildAchievements(
currentStreak: currentStreak,
bestStreak: bestStreak,
onTimeCount: onTimeCount,
totalCheckIns: totalCheckIns,
closestCutoffDays: closestCutoffDays,
averageDaysBeforeDeadline: averageDaysBeforeDeadline
)
return MonthlyCheckInStats(
currentStreak: currentStreak,
bestStreak: bestStreak,
onTimeCount: onTimeCount,
totalCheckIns: totalCheckIns,
averageDaysBeforeDeadline: averageDaysBeforeDeadline,
closestCutoffDays: closestCutoffDays,
recentMood: recentMood,
achievements: achievements
)
}
static func achievementStatuses(referenceDate: Date = Date()) -> [MonthlyCheckInAchievementStatus] {
let stats = stats(referenceDate: referenceDate)
return achievementStatuses(for: stats)
}
// MARK: - Private Helpers
private static func updateEntry(for date: Date, mutate: (inout MonthlyCheckInEntry) -> Void) {
let key = monthKey(for: date)
var entries = loadEntries()
var entry = entries[key] ?? MonthlyCheckInEntry(
note: nil,
rating: nil,
mood: nil,
completionTime: legacyCompletion(for: key),
createdAt: Date().timeIntervalSince1970
)
mutate(&entry)
if entry.note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true {
entry.note = nil
}
let isEmpty = entry.note == nil && entry.rating == nil && entry.mood == nil && entry.completionTime == nil
if isEmpty {
entries.removeValue(forKey: key)
} else {
entries[key] = entry
}
saveEntries(entries)
persistLegacyMirrors(entries)
}
private static func loadEntries() -> [String: MonthlyCheckInEntry] {
guard let data = UserDefaults.standard.data(forKey: entriesKey),
let decoded = try? JSONDecoder().decode([String: MonthlyCheckInEntry].self, from: data) else {
return migrateLegacyData()
}
// Ensure legacy data is merged if it existed before this release.
return mergeLegacy(into: decoded)
}
private static func saveEntries(_ entries: [String: MonthlyCheckInEntry]) {
guard let data = try? JSONEncoder().encode(entries) else { return }
UserDefaults.standard.set(data, forKey: entriesKey)
}
private static func migrateLegacyData() -> [String: MonthlyCheckInEntry] {
let notes = loadNotes()
let completions = loadCompletions()
guard !notes.isEmpty || !completions.isEmpty else { return [:] }
var entries: [String: MonthlyCheckInEntry] = [:]
let now = Date().timeIntervalSince1970
for (key, note) in notes {
entries[key] = MonthlyCheckInEntry(
note: note,
rating: nil,
mood: nil,
completionTime: completions[key],
createdAt: now
)
}
for (key, completion) in completions where entries[key] == nil {
entries[key] = MonthlyCheckInEntry(
note: nil,
rating: nil,
mood: nil,
completionTime: completion,
createdAt: completion
)
}
saveEntries(entries)
return entries
}
private static func mergeLegacy(into entries: [String: MonthlyCheckInEntry]) -> [String: MonthlyCheckInEntry] {
var merged = entries
let notes = loadNotes()
let completions = loadCompletions()
var shouldSave = false
for (key, note) in notes where merged[key]?.note == nil {
var entry = merged[key] ?? MonthlyCheckInEntry(
note: nil,
rating: nil,
mood: nil,
completionTime: completions[key],
createdAt: Date().timeIntervalSince1970
)
entry.note = note
merged[key] = entry
shouldSave = true
}
for (key, completion) in completions where merged[key]?.completionTime == nil {
var entry = merged[key] ?? MonthlyCheckInEntry(
note: nil,
rating: nil,
mood: nil,
completionTime: nil,
createdAt: completion
)
entry.completionTime = completion
merged[key] = entry
shouldSave = true
}
if shouldSave {
saveEntries(merged)
}
return merged
}
private static func persistLegacyMirrors(_ entries: [String: MonthlyCheckInEntry]) {
var notes: [String: String] = [:]
var completions: [String: Double] = [:]
for (key, entry) in entries {
if let note = entry.note {
notes[key] = note
}
if let completion = entry.completionTime {
completions[key] = completion
}
}
saveNotes(notes)
saveCompletions(completions)
}
private static func loadNotes() -> [String: String] {
guard let data = UserDefaults.standard.data(forKey: notesKey),
let decoded = try? JSONDecoder().decode([String: String].self, from: data) else {
return [:]
}
return decoded
}
private static func saveNotes(_ notes: [String: String]) {
guard let data = try? JSONEncoder().encode(notes) else { return }
UserDefaults.standard.set(data, forKey: notesKey)
}
private static func loadCompletions() -> [String: Double] {
guard let data = UserDefaults.standard.data(forKey: completionsKey),
let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else {
return [:]
}
return decoded
}
private static func saveCompletions(_ completions: [String: Double]) {
guard let data = try? JSONEncoder().encode(completions) else { return }
UserDefaults.standard.set(data, forKey: completionsKey)
}
private static func legacyCompletion(for key: String) -> Double? {
loadCompletions()[key]
}
private struct MonthlyCheckInAchievementRule {
let achievement: MonthlyCheckInAchievement
let isUnlocked: (Int, Int, Int, Int, Double?, Double?) -> Bool
}
private static let achievementRules: [MonthlyCheckInAchievementRule] = [
MonthlyCheckInAchievementRule(
achievement: MonthlyCheckInAchievement(
key: "streak_3",
title: String(localized: "achievement_streak_3_title"),
detail: String(localized: "achievement_streak_3_detail"),
icon: "flame.fill"
),
isUnlocked: { currentStreak, _, _, _, _, _ in currentStreak >= 3 }
),
MonthlyCheckInAchievementRule(
achievement: MonthlyCheckInAchievement(
key: "streak_6",
title: String(localized: "achievement_streak_6_title"),
detail: String(localized: "achievement_streak_6_detail"),
icon: "bolt.heart.fill"
),
isUnlocked: { currentStreak, _, _, _, _, _ in currentStreak >= 6 }
),
MonthlyCheckInAchievementRule(
achievement: MonthlyCheckInAchievement(
key: "streak_12",
title: String(localized: "achievement_streak_12_title"),
detail: String(localized: "achievement_streak_12_detail"),
icon: "calendar.circle.fill"
),
isUnlocked: { _, bestStreak, _, _, _, _ in bestStreak >= 12 }
),
MonthlyCheckInAchievementRule(
achievement: MonthlyCheckInAchievement(
key: "perfect_on_time",
title: String(localized: "achievement_perfect_on_time_title"),
detail: String(localized: "achievement_perfect_on_time_detail"),
icon: "checkmark.seal.fill"
),
isUnlocked: { _, _, onTimeCount, totalCheckIns, _, _ in
onTimeCount == totalCheckIns && totalCheckIns >= 3
}
),
MonthlyCheckInAchievementRule(
achievement: MonthlyCheckInAchievement(
key: "clutch_finish",
title: String(localized: "achievement_clutch_finish_title"),
detail: String(localized: "achievement_clutch_finish_detail"),
icon: "hourglass"
),
isUnlocked: { _, _, _, _, closestCutoffDays, _ in
if let closestCutoffDays {
return closestCutoffDays <= 2
}
return false
}
),
MonthlyCheckInAchievementRule(
achievement: MonthlyCheckInAchievement(
key: "early_bird",
title: String(localized: "achievement_early_bird_title"),
detail: String(localized: "achievement_early_bird_detail"),
icon: "sun.max.fill"
),
isUnlocked: { _, _, _, totalCheckIns, _, averageDaysBeforeDeadline in
if let averageDaysBeforeDeadline {
return averageDaysBeforeDeadline >= 10 && totalCheckIns >= 3
}
return false
}
)
]
private static func achievementStatuses(for stats: MonthlyCheckInStats) -> [MonthlyCheckInAchievementStatus] {
achievementRules.map { rule in
MonthlyCheckInAchievementStatus(
achievement: rule.achievement,
isUnlocked: rule.isUnlocked(
stats.currentStreak,
stats.bestStreak,
stats.onTimeCount,
stats.totalCheckIns,
stats.closestCutoffDays,
stats.averageDaysBeforeDeadline
)
)
}
}
private static func buildAchievements(
currentStreak: Int,
bestStreak: Int,
onTimeCount: Int,
totalCheckIns: Int,
closestCutoffDays: Double?,
averageDaysBeforeDeadline: Double?
) -> [MonthlyCheckInAchievement] {
achievementRules.compactMap { rule in
rule.isUnlocked(
currentStreak,
bestStreak,
onTimeCount,
totalCheckIns,
closestCutoffDays,
averageDaysBeforeDeadline
) ? rule.achievement : nil
}
}
private static var monthFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM"
return formatter
}
}
@@ -0,0 +1,7 @@
import Foundation
import Combine
@MainActor
final class TabSelectionStore: ObservableObject {
@Published var selectedTab = 0
}