CurrencySpecification.swift (20689B)
1 /* 2 * This file is part of GNU Taler, ©2022-26 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 = false, 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 fractionalDigits(_ 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, fractionalDigits(currencyInfo)) 95 } 96 97 func removeDigit(_ currencyInfo: CurrencyInfo) { 98 shiftRight() // divide by 10 99 mask(fractionalDigits(currencyInfo)) // replace all digits after #inputDigit with 0 100 } 101 102 func plainString(_ currencyInfo: CurrencyInfo) -> String { 103 return plainString(inputDigits: fractionalDigits(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.isEmpty { 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 // String(fraction) can be "0.5" (its own '.' would double up the separator we just 273 // appended) or scientific notation for tiny values (e.g. "5e-08"). 274 // Format with a fixed number of digits, then drop the leading "0." 275 let fixedFractionStr = String(format: "%.8f", fraction) 276 madeUpStr += String(fixedFractionStr.dropFirst(2)) 277 // TODO: fractionalNormalDigits, fractionalTrailingZeroDigits 278 return madeUpStr 279 } 280 } 281 // MARK: - 282 public struct CurrencySpecification: Codable, Equatable, Hashable, Sendable { 283 enum CodingKeys: String, CodingKey { 284 case name 285 case commonAmounts = "common_amounts" 286 case fractionalInputDigits = "num_fractional_input_digits" 287 case fractionalNormalDigits = "num_fractional_normal_digits" 288 case fractionalTrailingZeroDigits = "num_fractional_trailing_zero_digits" 289 case altUnitNames = "alt_unit_names" 290 } 291 /// some name for this CurrencySpecification 292 let name: String 293 let commonAmounts: [Amount]? 294 /// how much digits the user may enter after the decimal separator 295 let fractionalInputDigits: Int 296 /// €,$,£: 2; some arabic currencies: 3, ¥: 0 297 let fractionalNormalDigits: Int 298 /// usually same as numFractionalNormalDigits, but e.g. might be 2 for ¥ 299 let fractionalTrailingZeroDigits: Int 300 /// map of powers of 10 to alternative currency names / symbols 301 /// must always have an entry under "0" that defines the base name 302 /// e.g. "0 => €" or "3 => k€". For BTC, would be "0 => BTC, -3 => mBTC". 303 /// This way, we can also communicate the currency symbol to be used. 304 let altUnitNames: [Int : String]? 305 } 306 // MARK: - 307 /// `name` and `altUnitNames` come from the exchange's /keys response and are rendered 308 /// verbatim into nav titles and accessibility labels on money-movement screens, so strip 309 /// Unicode formatting control characters (bidi overrides, isolates, zero-width joiners): 310 /// a malicious or misconfigured exchange must not be able to reorder what the user reads. 311 /// 312 /// This lives in an extension on purpose. A custom `init(from:)` written inside the struct's 313 /// own declaration would suppress the synthesized memberwise initializer, which 314 /// `CurrencyInfo.zero(_:nu:)` and three other call sites in this file rely on. 315 extension CurrencySpecification { 316 private static func sanitized(_ text: String) -> String { 317 String(String.UnicodeScalarView(text.unicodeScalars.filter { 318 $0.properties.generalCategory != .format 319 })) 320 } 321 322 public init(from decoder: Decoder) throws { 323 let container = try decoder.container(keyedBy: CodingKeys.self) 324 name = Self.sanitized(try container.decode(String.self, forKey: .name)) 325 commonAmounts = try container.decodeIfPresent([Amount].self, forKey: .commonAmounts) 326 fractionalInputDigits = try container.decode(Int.self, forKey: .fractionalInputDigits) 327 fractionalNormalDigits = try container.decode(Int.self, forKey: .fractionalNormalDigits) 328 fractionalTrailingZeroDigits = try container.decode(Int.self, forKey: .fractionalTrailingZeroDigits) 329 if let rawAltUnitNames = try container.decodeIfPresent([Int : String].self, forKey: .altUnitNames) { 330 altUnitNames = rawAltUnitNames.mapValues(Self.sanitized) 331 } else { 332 altUnitNames = nil 333 } 334 } 335 } 336 // MARK: - 337 public class CurrencyFormatter: NumberFormatter, @unchecked Sendable { 338 339 var name: String? 340 var altUnitName0: String? // specs.altUnitNames[0] should have either the name 341 var altUnitSymbol: String? // specs.altUnitNames[0] should have the Symbol ($,€,¥) 342 var currency: String 343 var leadingCurrencySymbol: Bool 344 /// factory 345 346 static func formatter(currency: String, specs: CurrencySpecification) -> CurrencyFormatter { 347 let formatter = CurrencyFormatter() 348 if let altUnitNameZero = specs.altUnitNames?[0] { 349 if altUnitNameZero.hasPrefix(SPACE) { 350 formatter.altUnitName0 = String(altUnitNameZero.dropFirst()) 351 } else { 352 formatter.altUnitSymbol = altUnitNameZero 353 } 354 } 355 formatter.name = specs.name 356 formatter.currency = currency 357 // formatter.setCode(to: EUR_4217) 358 // formatter.setSymbol(to: "€") 359 // clamp: specs comes from the exchange and is untrusted; an out-of-range or negative 360 // value here would make NumberFormatter try to render millions of fractional digits 361 let trailingZeroDigits = min(max(specs.fractionalTrailingZeroDigits, 0), 8) 362 formatter.setMinimumFractionDigits(trailingZeroDigits) 363 return formatter 364 } 365 366 public override init() { 367 self.name = nil 368 self.altUnitName0 = nil 369 self.altUnitSymbol = nil 370 self.currency = EMPTYSTRING 371 self.leadingCurrencySymbol = false 372 super.init() 373 // self.currencyCode = EUR_4217 374 // self.currencySymbol = "€" 375 self.locale = Locale.autoupdatingCurrent // TODO: Yikes, might override my currency! Needs testing! 376 if #available(iOS 16.4, *) { 377 let currency = self.locale.currency 378 if let currencyCode = currency?.identifier { 379 self.name = self.locale.localizedString(forCurrencyCode: currencyCode) 380 } 381 } else { 382 if let currencyCode = self.locale.currencyCode { 383 self.name = self.locale.localizedString(forCurrencyCode: currencyCode) 384 } 385 } 386 self.usesGroupingSeparator = true 387 self.numberStyle = .currencyISOCode // .currency 388 self.maximumFractionDigits = 8 // ensure that formatter will not round 389 390 // self.currencyCode = code // EUR, USD, JPY, GBP 391 // self.currencySymbol = symbol 392 // self.internationalCurrencySymbol = 393 // self.minimumFractionDigits = fractionDigits 394 // self.maximumFractionDigits = fractionDigits 395 // self.groupingSize = 3 // thousands 396 // self.groupingSeparator = "," 397 // self.decimalSeparator = "." 398 let positiveFormat = self.positiveFormat as NSString 399 let currencySymbolLocation = positiveFormat.range(of: "¤").location 400 self.leadingCurrencySymbol = currencySymbolLocation == 0 401 } 402 403 func setUseISO(_ useISO: Bool) { 404 numberStyle = useISO ? .currencyISOCode : .currency // currencyPlural or currencyAccounting 405 } 406 407 // func setCode(to code:String) { 408 // currencyCode = code 409 // } 410 411 // func setSymbol(to symbol:String) { 412 // currencySymbol = symbol 413 // } 414 415 func setMinimumFractionDigits(_ digits: Int) { 416 minimumFractionDigits = digits 417 } 418 419 func setLocale(to newLocale: String) { 420 locale = Locale(identifier: newLocale) 421 maximumFractionDigits = 8 // ensure that formatter will not round 422 // NumberFormatter.RoundingMode 423 } 424 425 @available(*, unavailable) 426 required init?(coder aDecoder: NSCoder) { 427 fatalError("init(coder:) has not been implemented") 428 } 429 } 430 // MARK: - 431 #if DEBUG 432 func PreviewCurrencyInfo(_ currency: String, digits: Int) -> CurrencyInfo { 433 let unitName = digits == 0 ? "テ" : "ク" // do not use real currency symbols like "¥" : "€" 434 let specs = CurrencySpecification(name: currency, 435 commonAmounts: nil, 436 fractionalInputDigits: digits, 437 fractionalNormalDigits: digits, 438 fractionalTrailingZeroDigits: digits, 439 altUnitNames: [0 : unitName]) 440 let previewFormatter = CurrencyFormatter.formatter(currency: currency, specs: specs) 441 return CurrencyInfo(specs: specs, formatter: previewFormatter) 442 } 443 #endif