CurrencySpecification.swift (18363B)
1 /* 2 * This file is part of GNU Taler, ©2022-25 Taler Systems S.A. 3 * See LICENSE.md 4 */ 5 /** 6 * @author Marc Stibane 7 */ 8 import Foundation 9 import SwiftUI 10 import taler_swift 11 12 extension Locale { 13 static var preferredLanguageCode: String { 14 guard let preferredLanguage = preferredLanguages.first, 15 let code = Locale(identifier: preferredLanguage).languageCode else { 16 return "en" 17 } 18 return code 19 } 20 21 static var preferredLanguageCodes: [String] { 22 return Locale.preferredLanguages.compactMap({Locale(identifier: $0).languageCode}) 23 } 24 } 25 // MARK: - 26 extension Locale { 27 func leadingCurrencySymbol() -> Bool { 28 let currencyFormatter = NumberFormatter() 29 currencyFormatter.numberStyle = .currency 30 currencyFormatter.locale = self 31 32 let positiveFormat = currencyFormatter.positiveFormat as NSString 33 let currencySymbolLocation = positiveFormat.range(of: "¤").location 34 35 return currencySymbolLocation == 0 36 } 37 } 38 // MARK: - 39 extension Amount { 40 func formatted(_ currencyInfo: CurrencyInfo?, isNegative: Bool, 41 useISO: Bool = false, a11yDecSep: String? = nil 42 ) -> (String, String) { 43 if let currencyInfo { 44 let a11y = currencyInfo.string(for: valueAsFloatTuple, 45 isNegative: isNegative, 46 currency: currencyStr, 47 useISO: true, 48 a11yDecSep: a11yDecSep) 49 let strg = currencyInfo.string(for: valueAsFloatTuple, 50 isNegative: isNegative, 51 currency: currencyStr, 52 useISO: useISO, 53 a11yDecSep: nil) 54 return (strg, a11y) 55 } else { 56 return (valueStr, valueStr) 57 } 58 } 59 60 // this is the function to use 61 func formatted(_ scope: ScopeInfo?, isNegative: Bool, 62 useISO: Bool = false, a11yDecSep: String? = nil 63 ) -> (String, String) { 64 let controller = Controller.shared 65 if let scope { 66 if let currencyInfo = controller.info(for: scope) { 67 return self.formatted(currencyInfo, isNegative: isNegative, useISO: useISO, a11yDecSep: a11yDecSep) 68 } 69 } 70 return (self.readableDescription, self.readableDescription) 71 } 72 73 func formatted(specs: CurrencySpecification?, isNegative: Bool, 74 scope: ScopeInfo? = nil, useISO: Bool = false 75 ) -> (String, String) { 76 if let specs { 77 let formatter = CurrencyFormatter.formatter(currency: currencyStr, specs: specs) 78 let currencyInfo = CurrencyInfo(specs: specs, formatter: formatter) 79 return formatted(currencyInfo, isNegative: isNegative, useISO: useISO) 80 } else if let scope { 81 return formatted(scope, isNegative: isNegative, useISO: useISO) 82 } 83 return (self.readableDescription, self.readableDescription) 84 } 85 86 func inputDigits(_ currencyInfo: CurrencyInfo) -> UInt { 87 let inputDigits = currencyInfo.specs.fractionalInputDigits 88 if inputDigits < 0 { return 0 } 89 if inputDigits > 8 { return 8} 90 return UInt(inputDigits) 91 } 92 93 func addDigit(_ digit: UInt8, currencyInfo: CurrencyInfo) { 94 shiftLeft(add: digit, inputDigits(currencyInfo)) 95 } 96 97 func removeDigit(_ currencyInfo: CurrencyInfo) { 98 shiftRight() // divide by 10 99 mask(inputDigits(currencyInfo)) // replace all digits after #inputDigit with 0 100 } 101 102 func plainString(_ currencyInfo: CurrencyInfo) -> String { 103 return plainString(inputDigits: inputDigits(currencyInfo)) 104 } 105 } 106 // MARK: - 107 public struct CurrencyInfo: Sendable { 108 // let scope: ScopeInfo 109 let specs: CurrencySpecification 110 let formatter: CurrencyFormatter 111 112 // public static func zero(_ currency: String) -> CurrencyInfo { 113 public static func zero(_ currency: String, nu: Bool = false) -> CurrencyInfo { 114 let null = nu ? "ヌ" // use `nu´ for Null 115 : EMPTYSTRING 116 let specs = CurrencySpecification(name: currency, 117 commonAmounts: nil, 118 fractionalInputDigits: 0, 119 fractionalNormalDigits: 0, 120 fractionalTrailingZeroDigits: 0, 121 altUnitNames: [0 : null]) 122 let formatter = CurrencyFormatter.formatter(currency: currency, specs: specs) 123 return CurrencyInfo(specs: specs, formatter: formatter) 124 } 125 126 public static func euro() -> CurrencyInfo { 127 let currency = EUR_4217 128 let specs = CurrencySpecification(name: "Euro", 129 commonAmounts: nil, 130 fractionalInputDigits: 2, 131 fractionalNormalDigits: 2, 132 fractionalTrailingZeroDigits: 2, 133 altUnitNames: [0 : "€"]) // ensure altUnitSymbol 134 let formatter = CurrencyFormatter.formatter(currency: currency, specs: specs) 135 print(formatter.name ?? formatter.altUnitSymbol ?? formatter.altUnitName0 ?? formatter.currency) 136 return CurrencyInfo(specs: specs, formatter: formatter) 137 } 138 139 public static func francs() -> CurrencyInfo { 140 let currency = CHF_4217 141 let specs = CurrencySpecification(name: "Franken", 142 commonAmounts: nil, 143 fractionalInputDigits: 2, 144 fractionalNormalDigits: 2, 145 fractionalTrailingZeroDigits: 2, 146 altUnitNames: [0 : " Fr."]) // ensure altUnitName0 147 let formatter = CurrencyFormatter.formatter(currency: currency, specs: specs) 148 print(formatter.name ?? formatter.altUnitSymbol ?? formatter.altUnitName0 ?? formatter.currency) 149 return CurrencyInfo(specs: specs, formatter: formatter) 150 } 151 152 153 var currency: String { formatter.currency } 154 private var altUnitName0: String? { formatter.altUnitName0 } 155 private var altUnitSymbol: String? { formatter.altUnitSymbol } 156 var name: String { specs.name } 157 var symbol: String { altUnitSymbol ?? name } // fall back to name if no symbol defined 158 var hasSymbol: Bool { 159 if symbol != name { 160 let count = symbol.count 161 return count > 0 && count <= 3 162 } 163 return false 164 } 165 var commonAmounts: [Amount]? { specs.commonAmounts } 166 167 /// returns all characters left from the decimalSeparator 168 func integerPartStr(_ integerStr: String, decimalSeparator: String) -> String { 169 if let integerIndex = integerStr.endIndex(of: decimalSeparator) { 170 // decimalSeparator was found ==> return all characters left of it 171 return String(integerStr[..<integerIndex]) 172 } 173 guard let firstChar = integerStr.first else { return "0" } // TODO: should NEVER happen! Show error 174 let digitSet = CharacterSet.decimalDigits 175 if digitSet.contains(firstChar) { 176 // Currency Symbol is after the amount ==> return only the digits 177 return String(integerStr.unicodeScalars.filter { digitSet.contains($0) }) 178 } else { 179 // Currency Symbol is in front of the amount ==> return everything 180 return integerStr 181 } 182 } 183 184 func currencyString(_ aString: String, useISO: Bool = false) -> String { 185 if !useISO { 186 if let aSymbol = altUnitSymbol { 187 let symbolString = aString.replacingOccurrences(of: formatter.currencySymbol, with: aSymbol) 188 return symbolString.replacingOccurrences(of: formatter.currencyCode, with: aSymbol) 189 } 190 if let aName = altUnitName0 { 191 let spacedName = formatter.leadingCurrencySymbol ? aName + SPACE 192 : SPACE + aName 193 let spacedString1 = aString.replacingOccurrences(of: formatter.currencySymbol, with: spacedName) 194 let spacedString2 = spacedString1.replacingOccurrences(of: formatter.currencyCode, with: spacedName) 195 let spacedString3 = spacedString2.replacingOccurrences(of: " ", with: " ") // ensure we have only 1 space 196 return spacedString3 197 } 198 } 199 let currency = formatter.currency 200 if currency.count > 0 { 201 let spacedName = formatter.leadingCurrencySymbol ? currency + SPACE 202 : SPACE + currency 203 let spacedString1 = aString.replacingOccurrences(of: formatter.currencySymbol, with: spacedName) 204 let spacedString2 = spacedString1.replacingOccurrences(of: formatter.currencyCode, with: spacedName) 205 let spacedString3 = spacedString2.replacingOccurrences(of: " ", with: " ") // ensure we have only 1 space 206 return spacedString3 207 } 208 return aString 209 } 210 211 // TODO: use valueAsDecimalTuple instead of valueAsFloatTuple 212 func string(for valueTuple: (Double, Double), isNegative: Bool, currency: String, 213 useISO: Bool = false, a11yDecSep: String? = nil) -> String { 214 formatter.setUseISO(useISO) 215 let (integer, fraction) = valueTuple 216 if let integerStr = formatter.string(for: isNegative ? -integer : integer) { 217 let integerSpaced = integerStr.spaced 218 if fraction == 0 { 219 return currencyString(integerSpaced, useISO: useISO) // formatter already added trailing zeroes 220 } 221 if let fractionStr = formatter.string(for: fraction) { 222 let fractionSpaced = fractionStr.spaced 223 if let decimalSeparator = formatter.currencyDecimalSeparator { 224 if let fractionIndex = fractionSpaced.endIndex(of: decimalSeparator) { 225 var fractionPartStr = String(fractionSpaced[fractionIndex...]) 226 var resultStr = integerPartStr(integerSpaced, decimalSeparator: decimalSeparator) 227 if !resultStr.contains(decimalSeparator) { 228 resultStr += decimalSeparator 229 } 230 // print(resultStr, fractionPartStr) 231 var fractionCnt = 1 232 for character in fractionPartStr { 233 let isSuper = fractionCnt > specs.fractionalNormalDigits 234 let charStr = String(character) 235 if let digit = Int(charStr) { 236 let digitStr = isSuper ? SuperScriptDigits(charStr) : charStr 237 resultStr += digitStr 238 if (fractionCnt > 0) { fractionCnt += 1 } 239 } else { 240 // probably the Currency Code or Symbol. Just pass it on... 241 resultStr += charStr 242 // make sure any following digits (part of the currency name) are not converted to SuperScript 243 fractionCnt = 0 244 } 245 } 246 // print(resultStr) 247 if let a11yDecSep { 248 resultStr = resultStr.replacingOccurrences(of: decimalSeparator, with: a11yDecSep) 249 } 250 return currencyString(resultStr, useISO: useISO) 251 } 252 // if we arrive here then fractionStr doesn't have a decimal separator. Yikes! 253 } 254 // if we arrive here then the formatter doesn't have a currencyDecimalSeparator 255 } 256 // if we arrive here then we do not get a formatted string for fractionStr. Yikes! 257 } 258 // if we arrive here then we do not get a formatted string for integerStr. Yikes! 259 // TODO: log.error(formatter doesn't work) 260 // we need to format ourselves 261 // var currencyName = scope.currency 262 var currencyName = currency 263 if !useISO { 264 if let altUnitName0 { 265 currencyName = altUnitName0 266 } 267 } 268 var madeUpStr = currencyName + (isNegative ? " -" + String(integer) 269 : " " + String(integer)) 270 // let homeCurrency = Locale.current.currency //'currency' is only available in iOS 16 or newer 271 madeUpStr += formatter.currencyDecimalSeparator ?? Locale.current.decimalSeparator ?? "." 272 madeUpStr += String(String(fraction).dropFirst()) // remove the leading 0 273 // TODO: fractionalNormalDigits, fractionalTrailingZeroDigits 274 return madeUpStr 275 } 276 } 277 // MARK: - 278 public struct CurrencySpecification: Codable, Equatable, Sendable { 279 enum CodingKeys: String, CodingKey { 280 case name 281 case commonAmounts = "common_amounts" 282 case fractionalInputDigits = "num_fractional_input_digits" 283 case fractionalNormalDigits = "num_fractional_normal_digits" 284 case fractionalTrailingZeroDigits = "num_fractional_trailing_zero_digits" 285 case altUnitNames = "alt_unit_names" 286 } 287 /// some name for this CurrencySpecification 288 let name: String 289 let commonAmounts: [Amount]? 290 /// how much digits the user may enter after the decimal separator 291 let fractionalInputDigits: Int 292 /// €,$,£: 2; some arabic currencies: 3, ¥: 0 293 let fractionalNormalDigits: Int 294 /// usually same as numFractionalNormalDigits, but e.g. might be 2 for ¥ 295 let fractionalTrailingZeroDigits: Int 296 /// map of powers of 10 to alternative currency names / symbols 297 /// must always have an entry under "0" that defines the base name 298 /// e.g. "0 => €" or "3 => k€". For BTC, would be "0 => BTC, -3 => mBTC". 299 /// This way, we can also communicate the currency symbol to be used. 300 let altUnitNames: [Int : String]? 301 } 302 // MARK: - 303 public class CurrencyFormatter: NumberFormatter { 304 305 var name: String? 306 var altUnitName0: String? // specs.altUnitNames[0] should have either the name 307 var altUnitSymbol: String? // specs.altUnitNames[0] should have the Symbol ($,€,¥) 308 var currency: String 309 var leadingCurrencySymbol: Bool 310 /// factory 311 312 static func formatter(currency: String, specs: CurrencySpecification) -> CurrencyFormatter { 313 let formatter = CurrencyFormatter() 314 if let altUnitNameZero = specs.altUnitNames?[0] { 315 if altUnitNameZero.hasPrefix(SPACE) { 316 formatter.altUnitName0 = String(altUnitNameZero.dropFirst()) 317 } else { 318 formatter.altUnitSymbol = altUnitNameZero 319 } 320 } 321 formatter.name = specs.name 322 formatter.currency = currency 323 // formatter.setCode(to: EUR_4217) 324 // formatter.setSymbol(to: "€") 325 formatter.setMinimumFractionDigits(specs.fractionalTrailingZeroDigits) 326 return formatter 327 } 328 329 public override init() { 330 self.name = nil 331 self.altUnitName0 = nil 332 self.altUnitSymbol = nil 333 self.currency = EMPTYSTRING 334 self.leadingCurrencySymbol = false 335 super.init() 336 // self.currencyCode = EUR_4217 337 // self.currencySymbol = "€" 338 self.locale = Locale.autoupdatingCurrent // TODO: Yikes, might override my currency! Needs testing! 339 if #available(iOS 16.4, *) { 340 let currency = self.locale.currency 341 if let currencyCode = currency?.identifier { 342 self.name = self.locale.localizedString(forCurrencyCode: currencyCode) 343 } 344 } else { 345 if let currencyCode = self.locale.currencyCode { 346 self.name = self.locale.localizedString(forCurrencyCode: currencyCode) 347 } 348 } 349 self.usesGroupingSeparator = true 350 self.numberStyle = .currencyISOCode // .currency 351 self.maximumFractionDigits = 8 // ensure that formatter will not round 352 353 // self.currencyCode = code // EUR, USD, JPY, GBP 354 // self.currencySymbol = symbol 355 // self.internationalCurrencySymbol = 356 // self.minimumFractionDigits = fractionDigits 357 // self.maximumFractionDigits = fractionDigits 358 // self.groupingSize = 3 // thousands 359 // self.groupingSeparator = "," 360 // self.decimalSeparator = "." 361 let positiveFormat = self.positiveFormat as NSString 362 let currencySymbolLocation = positiveFormat.range(of: "¤").location 363 self.leadingCurrencySymbol = currencySymbolLocation == 0 364 } 365 366 func setUseISO(_ useISO: Bool) { 367 numberStyle = useISO ? .currencyISOCode : .currency // currencyPlural or currencyAccounting 368 } 369 370 // func setCode(to code:String) { 371 // currencyCode = code 372 // } 373 374 // func setSymbol(to symbol:String) { 375 // currencySymbol = symbol 376 // } 377 378 func setMinimumFractionDigits(_ digits: Int) { 379 minimumFractionDigits = digits 380 } 381 382 func setLocale(to newLocale: String) { 383 locale = Locale(identifier: newLocale) 384 maximumFractionDigits = 8 // ensure that formatter will not round 385 // NumberFormatter.RoundingMode 386 } 387 388 required init?(coder aDecoder: NSCoder) { 389 fatalError("init(coder:) has not been implemented") 390 } 391 } 392 // MARK: - 393 #if DEBUG 394 func PreviewCurrencyInfo(_ currency: String, digits: Int) -> CurrencyInfo { 395 let unitName = digits == 0 ? "テ" : "ク" // do not use real currency symbols like "¥" : "€" 396 let specs = CurrencySpecification(name: currency, 397 commonAmounts: nil, 398 fractionalInputDigits: digits, 399 fractionalNormalDigits: digits, 400 fractionalTrailingZeroDigits: digits, 401 altUnitNames: [0 : unitName]) 402 let previewFormatter = CurrencyFormatter.formatter(currency: currency, specs: specs) 403 return CurrencyInfo(specs: specs, formatter: previewFormatter) 404 } 405 #endif