taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

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