initial version
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user