From 9321f920b41e4f576f80443ff8141dea6b49120a Mon Sep 17 00:00:00 2001 From: Terry Yiu <963907+tyiu@users.noreply.github.com> Date: Sun, 10 Nov 2024 23:10:33 +0100 Subject: [PATCH] Add support for multiple currencies --- Sources/SatsPrice/ContentView.swift | 102 +++++++--- .../Network/CoinGeckoPriceFetcher.swift | 2 +- .../SatsPrice/Resources/Localizable.xcstrings | 9 + Sources/SatsPrice/SatsViewModel.swift | 187 +++++++++++------- Tests/SatsPriceTests/SatsViewModelTests.swift | 38 ++-- 5 files changed, 214 insertions(+), 124 deletions(-) diff --git a/Sources/SatsPrice/ContentView.swift b/Sources/SatsPrice/ContentView.swift index a40f085..118f220 100644 --- a/Sources/SatsPrice/ContentView.swift +++ b/Sources/SatsPrice/ContentView.swift @@ -10,6 +10,8 @@ public struct ContentView: View { @State private var priceSource: PriceSource + @State private var expandAddCurrencySection: Bool = false + private let dateFormatter: DateFormatter private let priceFetcherDelegator: PriceFetcherDelegator @@ -26,18 +28,63 @@ public struct ContentView: View { @MainActor func updatePrice() async { do { - guard let price = try await priceFetcherDelegator.convertBTC(toCurrency: satsViewModel.selectedCurrency) else { - satsViewModel.btcToCurrencyString = "" - return - } + let currencies = Set([satsViewModel.currentCurrency] + satsViewModel.currencyValueStrings.keys) + let prices = try await priceFetcherDelegator.convertBTC(toCurrencies: Array(currencies)) - satsViewModel.btcToCurrencyString = "\(price)" + satsViewModel.currencyPrices = prices + satsViewModel.updateCurrencyValueStrings() } catch { - satsViewModel.btcToCurrencyString = "" + satsViewModel.clearCurrencyValueStrings() } satsViewModel.lastUpdated = Date.now } + public var addCurrencyView: some View { + DisclosureGroup("Add Currency", isExpanded: $expandAddCurrencySection) { + Picker("Currency", selection: $satsViewModel.selectedCurrency) { + ForEach(satsViewModel.currencies, id: \.self) { currency in + Group { + if let localizedCurrency = Locale.current.localizedString(forCurrencyCode: currency.identifier) { + Text("\(currency.identifier) - \(localizedCurrency)") + } else { + Text(currency.identifier) + } + } + .tag(currency.identifier) + } + } +#if os(iOS) || SKIP + .pickerStyle(.navigationLink) +#endif + + let selectedCurrency = satsViewModel.selectedCurrency + if selectedCurrency == satsViewModel.currentCurrency || satsViewModel.currencyValueStrings.keys.contains(selectedCurrency) { + Text("\(selectedCurrency.identifier) has already been added") + .foregroundStyle(.secondary) + } else { + Button("Add \(selectedCurrency.identifier)") { + satsViewModel.currencyValueStrings[selectedCurrency] = "" + expandAddCurrencySection = false + + Task { + await updatePrice() + } + } + } + } + } + + public func selectedCurrencyBinding(_ currency: Locale.Currency) -> Binding { + Binding( + get: { + satsViewModel.currencyValueStrings[currency, default: ""] + }, + set: { priceString in + satsViewModel.currencyValueStrings[currency] = priceString + } + ) + } + public var body: some View { NavigationStack { Form { @@ -48,21 +95,8 @@ public struct ContentView: View { } } - Picker("Currency", selection: $satsViewModel.selectedCurrency) { - ForEach(satsViewModel.currencies, id: \.self) { - if let localizedCurrency = Locale.current.localizedString(forCurrencyCode: $0.identifier) { - Text("\($0.identifier) - \(localizedCurrency)") - } else { - Text($0.identifier) - } - } - } -#if os(iOS) || SKIP - .pickerStyle(.navigationLink) -#endif - HStack { - TextField("1 BTC to \(satsViewModel.selectedCurrency.identifier)", text: $satsViewModel.btcToCurrencyString) + TextField("1 BTC to \(satsViewModel.currentCurrency.identifier)", text: satsViewModel.btcToCurrencyString(for: satsViewModel.currentCurrency)) .disabled(priceSource != .manual) #if os(iOS) || SKIP .keyboardType(.decimalPad) @@ -78,7 +112,7 @@ public struct ContentView: View { } } } header: { - Text("1 BTC to \(satsViewModel.selectedCurrency.identifier)") + Text("1 BTC to \(satsViewModel.currentCurrency.identifier)") } footer: { if priceSource != .manual, let lastUpdated = satsViewModel.lastUpdated { Text("Last updated: \(dateFormatter.string(from: lastUpdated))") @@ -112,23 +146,33 @@ public struct ContentView: View { } Section { - TextField(satsViewModel.selectedCurrency.identifier, text: $satsViewModel.currencyValueString) + TextField(satsViewModel.currentCurrency.identifier, text: satsViewModel.currencyValueString(for: satsViewModel.currentCurrency)) #if os(iOS) || SKIP .keyboardType(.decimalPad) #endif } header: { - Text(satsViewModel.selectedCurrency.identifier) + Text(satsViewModel.currentCurrency.identifier) + } + + if priceSource != .manual { + ForEach(satsViewModel.currencyValueStrings.sorted { $0.key.identifier < $1.key.identifier }.filter { $0.key != satsViewModel.currentCurrency }, id: \.key.identifier) { currencyAndPrice in + Section { + TextField(currencyAndPrice.key.identifier, text: satsViewModel.currencyValueString(for: currencyAndPrice.key)) +#if os(iOS) || SKIP + .keyboardType(.decimalPad) +#endif + } header: { + Text(currencyAndPrice.key.identifier) + } + .tag(currencyAndPrice.key.identifier) + } + + addCurrencyView } } .task { await updatePrice() } - .onChange(of: satsViewModel.selectedCurrency) { newCurrency in - satsViewModel.lastUpdated = nil - Task { - await updatePrice() - } - } .onChange(of: priceSource) { newPriceSource in satsViewModel.lastUpdated = nil priceFetcherDelegator.priceSource = newPriceSource diff --git a/Sources/SatsPrice/Network/CoinGeckoPriceFetcher.swift b/Sources/SatsPrice/Network/CoinGeckoPriceFetcher.swift index 3829297..2a2ab96 100644 --- a/Sources/SatsPrice/Network/CoinGeckoPriceFetcher.swift +++ b/Sources/SatsPrice/Network/CoinGeckoPriceFetcher.swift @@ -75,7 +75,7 @@ class CoinGeckoPriceFetcher : PriceFetcher { var results: [Locale.Currency : Decimal] = [:] for currency in currencies { - if let price = priceResponse.bitcoin[currency.identifier] { + if let price = priceResponse.bitcoin[currency.identifier.lowercased()] { #if !SKIP results[currency] = price #else diff --git a/Sources/SatsPrice/Resources/Localizable.xcstrings b/Sources/SatsPrice/Resources/Localizable.xcstrings index 3e62340..ce3f616 100644 --- a/Sources/SatsPrice/Resources/Localizable.xcstrings +++ b/Sources/SatsPrice/Resources/Localizable.xcstrings @@ -13,6 +13,9 @@ } } } + }, + "%@ has already been added" : { + }, "1 BTC to %@" : { @@ -22,6 +25,12 @@ }, "2100000000000000 sats is the maximum." : { + }, + "Add %@" : { + + }, + "Add Currency" : { + }, "BTC" : { diff --git a/Sources/SatsPrice/SatsViewModel.swift b/Sources/SatsPrice/SatsViewModel.swift index 2fd0d6f..13ab3b7 100644 --- a/Sources/SatsPrice/SatsViewModel.swift +++ b/Sources/SatsPrice/SatsViewModel.swift @@ -15,11 +15,14 @@ import SwiftUI class SatsViewModel: ObservableObject { @Published var lastUpdated: Date? - @Published var btcToCurrencyStringInternal: String = "" @Published var satsStringInternal: String = "" @Published var btcStringInternal: String = "" - @Published var currencyValueStringInternal: String = "" @Published var selectedCurrency: Locale.Currency = Locale.current.currency ?? Locale.Currency("USD") + @Published var currencyValueStrings: [Locale.Currency: String] = [:] + + var currencyPrices: [Locale.Currency: Decimal] = [:] + + let currentCurrency: Locale.Currency = Locale.current.currency ?? Locale.Currency("USD") var currencies: [Locale.Currency] { let commonISOCurrencyCodes = Set(Locale.commonISOCurrencyCodes) @@ -34,25 +37,6 @@ class SatsViewModel: ObservableObject { } } - var btcToCurrencyString: String { - get { - btcToCurrencyStringInternal - } - set { - guard btcToCurrencyStringInternal != newValue else { - return - } - - btcToCurrencyStringInternal = newValue - - if let btc, let btcToCurrency { - currencyValueStringInternal = (btc * btcToCurrency).formatString() - } else { - currencyValueStringInternal = "" - } - } - } - var satsString: String { get { satsStringInternal @@ -71,14 +55,11 @@ class SatsViewModel: ObservableObject { let btc = sats.divide(Decimal(100000000), 20, java.math.RoundingMode.DOWN) #endif btcStringInternal = btc.formatString() - if let btcToCurrency { - currencyValueStringInternal = (btc * btcToCurrency).formatString() - } else { - currencyValueStringInternal = "" - } + + updateCurrencyValueStrings() } else { btcStringInternal = "" - currencyValueStringInternal = "" + clearCurrencyValueStrings() } } } @@ -98,65 +79,131 @@ class SatsViewModel: ObservableObject { let sats = btc * Decimal(100000000) satsStringInternal = sats.formatString() - if let btcToCurrency { - currencyValueStringInternal = (btc * btcToCurrency).formatString() - } else { - currencyValueStringInternal = "" - } + updateCurrencyValueStrings() } else { satsStringInternal = "" - currencyValueStringInternal = "" + clearCurrencyValueStrings() } } } - var currencyValueString: String { - get { - currencyValueStringInternal - } - set { - guard currencyValueStringInternal != newValue else { - return - } + func updateCurrencyValueStrings(excludedCurrency: Locale.Currency? = nil) { + if let btc { + let currencies = Set([currentCurrency] + currencyValueStrings.keys) + .filter { $0 != excludedCurrency } - currencyValueStringInternal = newValue - - if let currencyValue { - if let btcToCurrency { -#if !SKIP - let btc = currencyValue / btcToCurrency -#else - let btc = currencyValue.divide(btcToCurrency, 20, java.math.RoundingMode.DOWN) -#endif - btcStringInternal = btc.formatString() - - let sats = btc * Decimal(100000000) - satsStringInternal = sats.formatString() + for currency in currencies { + if let btcToCurrency = btcToCurrency(for: currency) { + currencyValueStrings[currency] = (btc * btcToCurrency).formatString() } else { - satsStringInternal = "" - btcStringInternal = "" - currencyValueStringInternal = "" + currencyValueStrings[currency] = "" } - } else { - satsStringInternal = "" - btcStringInternal = "" - currencyValueStringInternal = "" } + } else { + clearCurrencyValueStrings() } } - var btcToCurrency: Decimal? { + func clearCurrencyValueStrings() { + for currency in currencyValueStrings.keys { + currencyValueStrings[currency] = "" + } + } + + func currencyValueString(for currency: Locale.Currency) -> Binding { + Binding( + get: { + self.currencyValueStrings[currency, default: ""] + }, + set: { newValue in + guard self.currencyValueStrings[currency] != newValue else { + return + } + + self.currencyValueStrings[currency] = newValue + + if let currencyValue = self.currencyValue(for: currency) { + if let btcToCurrency = self.currencyPrices[currency] { + #if !SKIP + let btc = currencyValue / btcToCurrency + #else + let btc = currencyValue.divide(btcToCurrency, 20, java.math.RoundingMode.DOWN) + #endif + self.btcStringInternal = btc.formatString() + + let sats = btc * Decimal(100000000) + self.satsStringInternal = sats.formatString() + + self.updateCurrencyValueStrings(excludedCurrency: currency) + } else { + self.satsStringInternal = "" + self.btcStringInternal = "" + self.clearCurrencyValueStrings() + } + } else { + self.satsStringInternal = "" + self.btcStringInternal = "" + self.clearCurrencyValueStrings() + } + } + ) + } + + func currencyValue(for currency: Locale.Currency) -> Decimal? { + guard let currencyValueString = currencyValueStrings[currency] else { + return nil + } + #if !SKIP - return Decimal(string: btcToCurrencyStringInternal) + return Decimal(string: currencyValueString) #else do { - return Decimal(btcToCurrencyStringInternal) + return Decimal(currencyValueString) } catch { return nil } #endif } + func btcToCurrency(for currency: Locale.Currency) -> Decimal? { + currencyPrices[currency] + } + + func btcToCurrencyString(for currency: Locale.Currency) -> Binding { + Binding( + get: { + self.currencyPrices[currency]?.formatString() ?? "" + }, + set: { newValue in +#if !SKIP + if let newPrice = Decimal(string: newValue), self.currencyPrices[currency] != newPrice { + self.currencyPrices[currency] = Decimal(string: newValue) + + if let btc = self.btc { + self.currencyValueStrings[currency] = (btc * newPrice).formatString() + } else { + self.currencyValueStrings[currency] = "" + } + } +#else + do { + if let newPrice = Decimal(newValue), self.currencyPrices[currency] != newPrice { + self.currencyPrices[currency] = Decimal(newValue) + + if let btc = self.btc { + self.currencyValueStrings[currency] = (btc * newPrice).formatString() + } else { + self.currencyValueStrings[currency] = "" + } + } + } catch { + self.currencyPrices.removeValue(forKey: currency) + } +#endif + } + ) + } + var sats: Decimal? { #if !SKIP return Decimal(string: satsStringInternal) @@ -181,18 +228,6 @@ class SatsViewModel: ObservableObject { #endif } - var currencyValue: Decimal? { -#if !SKIP - return Decimal(string: currencyValueStringInternal) -#else - do { - return Decimal(currencyValueStringInternal) - } catch { - return nil - } -#endif - } - var exceedsMaximum: Bool { if let btc, btc > Decimal(21000000) { return true diff --git a/Tests/SatsPriceTests/SatsViewModelTests.swift b/Tests/SatsPriceTests/SatsViewModelTests.swift index b36b920..d05cb08 100644 --- a/Tests/SatsPriceTests/SatsViewModelTests.swift +++ b/Tests/SatsPriceTests/SatsViewModelTests.swift @@ -14,82 +14,84 @@ import XCTest final class SatsViewModelTests: XCTestCase { + let currency = Locale.Currency("USD") + func testSatsViewModel() { let satsViewModel = SatsViewModel() - satsViewModel.btcToCurrencyString = "54321" + satsViewModel.btcToCurrencyString(for: currency).wrappedValue = "54321" // Test BTC updates. satsViewModel.btcString = "1" #if !SKIP XCTAssertEqual(satsViewModel.btc, Decimal(string: "1")) XCTAssertEqual(satsViewModel.sats, Decimal(string: "100000000")) - XCTAssertEqual(satsViewModel.currencyValue, Decimal(string: "54321")) + XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "54321")) #else XCTAssertEqual(satsViewModel.btc, Decimal("1")) XCTAssertEqual(satsViewModel.sats, Decimal("100000000")) - XCTAssertEqual(satsViewModel.currencyValue, Decimal("54321")) + XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal("54321")) #endif XCTAssertEqual(satsViewModel.btcString, "1") XCTAssertEqual(satsViewModel.satsString, "100000000") - XCTAssertEqual(satsViewModel.currencyValueString, "54321") + XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "54321") // Test Sats updates. satsViewModel.satsString = "200000000" #if !SKIP XCTAssertEqual(satsViewModel.btc, Decimal(string: "2")) XCTAssertEqual(satsViewModel.sats, Decimal(string: "200000000")) - XCTAssertEqual(satsViewModel.currencyValue, Decimal(string: "108642")) + XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "108642")) #else XCTAssertEqual(satsViewModel.btc, Decimal("2")) XCTAssertEqual(satsViewModel.sats, Decimal("200000000")) - XCTAssertEqual(satsViewModel.currencyValue, Decimal("108642")) + XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal("108642")) #endif XCTAssertEqual(satsViewModel.btcString, "2") XCTAssertEqual(satsViewModel.satsString, "200000000") - XCTAssertEqual(satsViewModel.currencyValueString, "108642") + XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "108642") // Test currency value updates. - satsViewModel.currencyValueString = "162963" + satsViewModel.currencyValueString(for: currency).wrappedValue = "162963" #if !SKIP XCTAssertEqual(satsViewModel.btc, Decimal(string: "3")) XCTAssertEqual(satsViewModel.sats, Decimal(string: "300000000")) - XCTAssertEqual(satsViewModel.currencyValue, Decimal(string: "162963")) + XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "162963")) #else XCTAssertEqual(satsViewModel.btc, Decimal("3")) XCTAssertEqual(satsViewModel.sats, Decimal("300000000")) - XCTAssertEqual(satsViewModel.currencyValue, Decimal("162963")) + XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal("162963")) #endif XCTAssertEqual(satsViewModel.btcString, "3") XCTAssertEqual(satsViewModel.satsString, "300000000") - XCTAssertEqual(satsViewModel.currencyValueString, "162963") + XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "162963") // Test fractional amounts. // Precision between platforms on this calculation is different so we have different assertions for each. - satsViewModel.currencyValueString = "1" + satsViewModel.currencyValueString(for: currency).wrappedValue = "1" #if !SKIP XCTAssertEqual(satsViewModel.btc, Decimal(string: "0.00001840908672520756245282671526665562")) XCTAssertEqual(satsViewModel.btcString, "0.00001840908672520756245282671526665562") XCTAssertEqual(satsViewModel.sats, Decimal(string: "1840.908672520756245282671526665562")) XCTAssertEqual(satsViewModel.satsString, "1840.908672520756245282671526665562") - XCTAssertEqual(satsViewModel.currencyValue, Decimal(string: "1")) + XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "1")) #else XCTAssertEqual(satsViewModel.btc, Decimal("0.00001840908672520756")) XCTAssertEqual(satsViewModel.btcString, "0.00001840908672520756") XCTAssertEqual(satsViewModel.sats, Decimal("1840.908672520756")) XCTAssertEqual(satsViewModel.satsString, "1840.908672520756") - XCTAssertEqual(satsViewModel.currencyValue, Decimal("1")) + XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal("1")) #endif - XCTAssertEqual(satsViewModel.currencyValueString, "1") + XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "1") // Test large amounts that exceed the cap of 21M BTC. // Precision between platforms on this calculation is different so we have different assertions for each. - satsViewModel.currencyValueString = "11407419999999" + satsViewModel.currencyValueString(for: currency).wrappedValue = "11407419999999" #if !SKIP XCTAssertEqual(satsViewModel.btc, Decimal(string: "210000184.09084884298889932070469983984")) XCTAssertEqual(satsViewModel.btcString, "210000184.09084884298889932070469983984") XCTAssertEqual(satsViewModel.sats, Decimal(string: "21000018409084884.298889932070469983984")) XCTAssertEqual(satsViewModel.satsString, "21000018409084884.298889932070469983984") - XCTAssertEqual(satsViewModel.currencyValue, Decimal(string: "11407419999999")) + XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "11407419999999")) #else XCTAssertEqual(satsViewModel.btc, Decimal("210000184.0908488429888993207")) XCTAssertEqual(satsViewModel.btcString, "210000184.0908488429888993207") @@ -97,7 +99,7 @@ final class SatsViewModelTests: XCTestCase { XCTAssertEqual(satsViewModel.satsString, "21000018409084884.29888993207") XCTAssertEqual(satsViewModel.currencyValue, Decimal("11407419999999")) #endif - XCTAssertEqual(satsViewModel.currencyValueString, "11407419999999") + XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "11407419999999") } }