CurrencySpecification.swift (18462B)
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) -> CurrencyInfo { 114 let specs = CurrencySpecification(name: currency, 115 currency: currency, 116 commonAmounts: nil, 117 fractionalInputDigits: 0, 118 fractionalNormalDigits: 0, 119 fractionalTrailingZeroDigits: 0, 120 altUnitNames: [0 : "ヌ"]) // use `nu´ for Null 121 let formatter = CurrencyFormatter.formatter(currency: currency, specs: specs) 122 return CurrencyInfo(specs: specs, formatter: formatter) 123 } 124 125 public static func euro() -> CurrencyInfo { 126 let currency = EUR_4217 127 let specs = CurrencySpecification(name: "Euro", 128 currency: currency, 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 currency: currency, 143 commonAmounts: nil, 144 fractionalInputDigits: 2, 145 fractionalNormalDigits: 2, 146 fractionalTrailingZeroDigits: 2, 147 altUnitNames: [0 : " Fr."]) // ensure altUnitName0 148 let formatter = CurrencyFormatter.formatter(currency: currency, specs: specs) 149 print(formatter.name ?? formatter.altUnitSymbol ?? formatter.altUnitName0 ?? formatter.currency) 150 return CurrencyInfo(specs: specs, formatter: formatter) 151 } 152 153 154 var currency: String { formatter.currency } 155 private var altUnitName0: String? { formatter.altUnitName0 } 156 private var altUnitSymbol: String? { formatter.altUnitSymbol } 157 var name: String { specs.name } 158 var symbol: String { altUnitSymbol ?? name } // fall back to name if no symbol defined 159 var hasSymbol: Bool { 160 if symbol != name { 161 let count = symbol.count 162 return count > 0 && count <= 3 163 } 164 return false 165 } 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, Sendable { 279 enum CodingKeys: String, CodingKey { 280 case name, currency 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 currency: String? 290 let commonAmounts: [Amount]? 291 /// how much digits the user may enter after the decimal separator 292 let fractionalInputDigits: Int 293 /// €,$,£: 2; some arabic currencies: 3, ¥: 0 294 let fractionalNormalDigits: Int 295 /// usually same as numFractionalNormalDigits, but e.g. might be 2 for ¥ 296 let fractionalTrailingZeroDigits: Int 297 /// map of powers of 10 to alternative currency names / symbols 298 /// must always have an entry under "0" that defines the base name 299 /// e.g. "0 => €" or "3 => k€". For BTC, would be "0 => BTC, -3 => mBTC". 300 /// This way, we can also communicate the currency symbol to be used. 301 let altUnitNames: [Int : String]? 302 } 303 // MARK: - 304 public class CurrencyFormatter: NumberFormatter { 305 306 var name: String? 307 var altUnitName0: String? // specs.altUnitNames[0] should have either the name 308 var altUnitSymbol: String? // specs.altUnitNames[0] should have the Symbol ($,€,¥) 309 var currency: String 310 var leadingCurrencySymbol: Bool 311 /// factory 312 313 static func formatter(currency: String, specs: CurrencySpecification) -> CurrencyFormatter { 314 let formatter = CurrencyFormatter() 315 if let altUnitNameZero = specs.altUnitNames?[0] { 316 if altUnitNameZero.hasPrefix(SPACE) { 317 formatter.altUnitName0 = String(altUnitNameZero.dropFirst()) 318 } else { 319 formatter.altUnitSymbol = altUnitNameZero 320 } 321 } 322 formatter.name = specs.name 323 formatter.currency = currency 324 // formatter.setCode(to: EUR_4217) 325 // formatter.setSymbol(to: "€") 326 formatter.setMinimumFractionDigits(specs.fractionalTrailingZeroDigits) 327 return formatter 328 } 329 330 public override init() { 331 self.name = nil 332 self.altUnitName0 = nil 333 self.altUnitSymbol = nil 334 self.currency = EMPTYSTRING 335 self.leadingCurrencySymbol = false 336 super.init() 337 // self.currencyCode = EUR_4217 338 // self.currencySymbol = "€" 339 self.locale = Locale.autoupdatingCurrent // TODO: Yikes, might override my currency! Needs testing! 340 if #available(iOS 16.4, *) { 341 let currency = self.locale.currency 342 if let currencyCode = currency?.identifier { 343 self.name = self.locale.localizedString(forCurrencyCode: currencyCode) 344 } 345 } else { 346 if let currencyCode = self.locale.currencyCode { 347 self.name = self.locale.localizedString(forCurrencyCode: currencyCode) 348 } 349 } 350 self.usesGroupingSeparator = true 351 self.numberStyle = .currencyISOCode // .currency 352 self.maximumFractionDigits = 8 // ensure that formatter will not round 353 354 // self.currencyCode = code // EUR, USD, JPY, GBP 355 // self.currencySymbol = symbol 356 // self.internationalCurrencySymbol = 357 // self.minimumFractionDigits = fractionDigits 358 // self.maximumFractionDigits = fractionDigits 359 // self.groupingSize = 3 // thousands 360 // self.groupingSeparator = "," 361 // self.decimalSeparator = "." 362 let positiveFormat = self.positiveFormat as NSString 363 let currencySymbolLocation = positiveFormat.range(of: "¤").location 364 self.leadingCurrencySymbol = currencySymbolLocation == 0 365 } 366 367 func setUseISO(_ useISO: Bool) { 368 numberStyle = useISO ? .currencyISOCode : .currency // currencyPlural or currencyAccounting 369 } 370 371 // func setCode(to code:String) { 372 // currencyCode = code 373 // } 374 375 // func setSymbol(to symbol:String) { 376 // currencySymbol = symbol 377 // } 378 379 func setMinimumFractionDigits(_ digits: Int) { 380 minimumFractionDigits = digits 381 } 382 383 func setLocale(to newLocale: String) { 384 locale = Locale(identifier: newLocale) 385 maximumFractionDigits = 8 // ensure that formatter will not round 386 // NumberFormatter.RoundingMode 387 } 388 389 required init?(coder aDecoder: NSCoder) { 390 fatalError("init(coder:) has not been implemented") 391 } 392 } 393 // MARK: - 394 #if DEBUG 395 func PreviewCurrencyInfo(_ currency: String, digits: Int) -> CurrencyInfo { 396 let unitName = digits == 0 ? "テ" : "ク" // do not use real currency symbols like "¥" : "€" 397 let specs = CurrencySpecification(name: currency, 398 currency: currency, 399 commonAmounts: nil, 400 fractionalInputDigits: digits, 401 fractionalNormalDigits: digits, 402 fractionalTrailingZeroDigits: digits, 403 altUnitNames: [0 : unitName]) 404 let previewFormatter = CurrencyFormatter.formatter(currency: currency, specs: specs) 405 return CurrencyInfo(specs: specs, formatter: previewFormatter) 406 } 407 #endif