diff --git a/README.md b/README.md index ccf5f4b..cfb55a7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # SatsPrice -This app fetches the price of Bitcoin relative to the United States Dollar from multiple sources, and converts inputted amounts between Sats, BTC, and USD. +This app fetches the price of Bitcoin relative to common ISO currencies from multiple sources, and converts inputted amounts between Sats, BTC, and the selected ISO currency. This is a free [Skip](https://skip.tools) dual-platform app project. It builds a native app for both iOS and Android. diff --git a/Sources/SatsPrice/ContentView.swift b/Sources/SatsPrice/ContentView.swift index fe047c0..755feae 100644 --- a/Sources/SatsPrice/ContentView.swift +++ b/Sources/SatsPrice/ContentView.swift @@ -26,14 +26,14 @@ public struct ContentView: View { @MainActor func updatePrice() async { do { - guard let price = try await priceFetcherDelegator.btcToUsd() else { - satsViewModel.btcToUsdString = "" + guard let price = try await priceFetcherDelegator.convertBTC(toCurrency: satsViewModel.selectedCurrency) else { + satsViewModel.btcToCurrencyString = "" return } - satsViewModel.btcToUsdString = "\(price)" + satsViewModel.btcToCurrencyString = "\(price)" } catch { - satsViewModel.btcToUsdString = "" + satsViewModel.btcToCurrencyString = "" } satsViewModel.lastUpdated = Date.now } @@ -47,8 +47,18 @@ 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) + } + } + } + HStack { - TextField("", text: $satsViewModel.btcToUsdString) + TextField("", text: $satsViewModel.btcToCurrencyString) .disabled(priceSource != .manual) #if os(iOS) || SKIP .keyboardType(.decimalPad) @@ -64,7 +74,7 @@ public struct ContentView: View { } } } header: { - Text("1 BTC to USD") + Text("1 BTC to \(satsViewModel.selectedCurrency.identifier)") } footer: { if priceSource != .manual, let lastUpdated = satsViewModel.lastUpdated { Text("Last updated: \(dateFormatter.string(from: lastUpdated))") @@ -90,17 +100,23 @@ public struct ContentView: View { } Section { - TextField("", text: $satsViewModel.usdString) + TextField("", text: $satsViewModel.currencyValueString) #if os(iOS) || SKIP .keyboardType(.decimalPad) #endif } header: { - Text("USD") + Text(satsViewModel.selectedCurrency.identifier) } } .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 8a4658a..3740e4e 100644 --- a/Sources/SatsPrice/Network/CoinGeckoPriceFetcher.swift +++ b/Sources/SatsPrice/Network/CoinGeckoPriceFetcher.swift @@ -11,35 +11,35 @@ import Foundation private struct CoinGeckoPriceResponse: Codable { - let bitcoin: CoinGeckoPrice -} - -private struct CoinGeckoPrice: Codable { #if !SKIP - let usd: Decimal + let bitcoin: [String: Decimal] #else - let usd: String + let bitcoin: [String: String] #endif } class CoinGeckoPriceFetcher : PriceFetcher { - private static let urlString = "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd&precision=18" + func urlString(toCurrency currency: Locale.Currency) -> String { + "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=\(currency.identifier.lowercased())&precision=18" + } - func btcToUsd() async throws -> Decimal? { + func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? { do { - guard let urlComponents = URLComponents(string: CoinGeckoPriceFetcher.urlString), let url = urlComponents.url else { + guard let urlComponents = URLComponents(string: urlString(toCurrency: currency)), let url = urlComponents.url else { return nil } let (data, _) = try await URLSession.shared.data(from: url, delegate: nil) let priceResponse = try JSONDecoder().decode(CoinGeckoPriceResponse.self, from: data) - let price = priceResponse.bitcoin + guard let price = priceResponse.bitcoin[currency.identifier.lowercased()] else { + return nil + } #if !SKIP - return price.usd + return price #else - return Decimal(price.usd) + return Decimal(price) #endif } catch { return nil diff --git a/Sources/SatsPrice/Network/CoinbasePriceFetcher.swift b/Sources/SatsPrice/Network/CoinbasePriceFetcher.swift index b3add15..5e8a019 100644 --- a/Sources/SatsPrice/Network/CoinbasePriceFetcher.swift +++ b/Sources/SatsPrice/Network/CoinbasePriceFetcher.swift @@ -21,13 +21,13 @@ private struct CoinbasePrice: Codable { } class CoinbasePriceFetcher : PriceFetcher { - private static let urlString = "https://api.coinbase.com/v2/prices/BTC-USD/spot" - private static let btc = "BTC" - private static let usd = "USD" + func urlString(toCurrency currency: Locale.Currency) -> String { + "https://api.coinbase.com/v2/prices/BTC-\(currency.identifier)/spot" + } - func btcToUsd() async throws -> Decimal? { + func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? { do { - guard let urlComponents = URLComponents(string: CoinbasePriceFetcher.urlString), let url = urlComponents.url else { + guard let urlComponents = URLComponents(string: urlString(toCurrency: currency)), let url = urlComponents.url else { return nil } @@ -36,7 +36,7 @@ class CoinbasePriceFetcher : PriceFetcher { let coinbasePriceResponse = try JSONDecoder().decode(CoinbasePriceResponse.self, from: data) let coinbasePrice = coinbasePriceResponse.data - guard coinbasePrice.base == CoinbasePriceFetcher.btc && coinbasePrice.currency == CoinbasePriceFetcher.usd else { + guard coinbasePrice.base == "BTC" && coinbasePrice.currency == currency.identifier else { return nil } diff --git a/Sources/SatsPrice/Network/FakePriceFetcher.swift b/Sources/SatsPrice/Network/FakePriceFetcher.swift index 400d02a..57637ff 100644 --- a/Sources/SatsPrice/Network/FakePriceFetcher.swift +++ b/Sources/SatsPrice/Network/FakePriceFetcher.swift @@ -13,7 +13,7 @@ import Foundation /// Fake price fetcher that returns a randomized price. Useful for development testing without requiring a network call. class FakePriceFetcher: PriceFetcher { - func btcToUsd() async throws -> Decimal? { + func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? { Decimal(Double.random(in: 10000...100000)) } } diff --git a/Sources/SatsPrice/Network/ManualPriceFetcher.swift b/Sources/SatsPrice/Network/ManualPriceFetcher.swift index 6dc9cb9..f49c2ee 100644 --- a/Sources/SatsPrice/Network/ManualPriceFetcher.swift +++ b/Sources/SatsPrice/Network/ManualPriceFetcher.swift @@ -14,7 +14,7 @@ import Foundation class ManualPriceFetcher: PriceFetcher { var price: Decimal = Decimal(1) - func btcToUsd() async throws -> Decimal? { + func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? { return price } } diff --git a/Sources/SatsPrice/Network/PriceFetcher.swift b/Sources/SatsPrice/Network/PriceFetcher.swift index 2a5e14d..083030f 100644 --- a/Sources/SatsPrice/Network/PriceFetcher.swift +++ b/Sources/SatsPrice/Network/PriceFetcher.swift @@ -11,5 +11,5 @@ import Foundation protocol PriceFetcher { - func btcToUsd() async throws -> Decimal? + func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? } diff --git a/Sources/SatsPrice/Network/PriceFetcherDelegator.swift b/Sources/SatsPrice/Network/PriceFetcherDelegator.swift index b944c14..1489f12 100644 --- a/Sources/SatsPrice/Network/PriceFetcherDelegator.swift +++ b/Sources/SatsPrice/Network/PriceFetcherDelegator.swift @@ -39,7 +39,7 @@ class PriceFetcherDelegator: PriceFetcher { } } - func btcToUsd() async throws -> Decimal? { - return try await delegate.btcToUsd() + func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? { + return try await delegate.convertBTC(toCurrency: currency) } } diff --git a/Sources/SatsPrice/Resources/Localizable.xcstrings b/Sources/SatsPrice/Resources/Localizable.xcstrings index e866dfc..54f5947 100644 --- a/Sources/SatsPrice/Resources/Localizable.xcstrings +++ b/Sources/SatsPrice/Resources/Localizable.xcstrings @@ -4,11 +4,24 @@ "" : { }, - "1 BTC to USD" : { + "%@ - %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ - %2$@" + } + } + } + }, + "1 BTC to %@" : { }, "BTC" : { + }, + "Currency" : { + }, "Last updated: %@" : { @@ -18,9 +31,6 @@ }, "Sats" : { - }, - "USD" : { - } }, "version" : "1.0" diff --git a/Sources/SatsPrice/SatsViewModel.swift b/Sources/SatsPrice/SatsViewModel.swift index be6977a..1be80a8 100644 --- a/Sources/SatsPrice/SatsViewModel.swift +++ b/Sources/SatsPrice/SatsViewModel.swift @@ -15,26 +15,40 @@ import SwiftUI class SatsViewModel: ObservableObject { @Published var lastUpdated: Date? - @Published var btcToUsdStringInternal: String = "" + @Published var btcToCurrencyStringInternal: String = "" @Published var satsStringInternal: String = "" @Published var btcStringInternal: String = "" - @Published var usdStringInternal: String = "" + @Published var currencyValueStringInternal: String = "" + @Published var selectedCurrency: Locale.Currency = Locale.current.currency ?? Locale.Currency("USD") - var btcToUsdString: String { + var currencies: [Locale.Currency] { + let commonISOCurrencyCodes = Set(Locale.commonISOCurrencyCodes) + let currentCurrency = Locale.current.currency ?? Locale.Currency("USD") + if commonISOCurrencyCodes.contains(currentCurrency.identifier) { + return Locale.commonISOCurrencyCodes.map { Locale.Currency($0) } + } else { + var commonAndCurrentCurrencies = Locale.commonISOCurrencyCodes + commonAndCurrentCurrencies.append(currentCurrency.identifier) + commonAndCurrentCurrencies.sort() + return commonAndCurrentCurrencies.map { Locale.Currency($0) } + } + } + + var btcToCurrencyString: String { get { - btcToUsdStringInternal + btcToCurrencyStringInternal } set { - guard btcToUsdStringInternal != newValue else { + guard btcToCurrencyStringInternal != newValue else { return } - btcToUsdStringInternal = newValue + btcToCurrencyStringInternal = newValue - if let btc, let btcToUsd { - usdStringInternal = (btc * btcToUsd).formatString() + if let btc, let btcToCurrency { + currencyValueStringInternal = (btc * btcToCurrency).formatString() } else { - usdStringInternal = "" + currencyValueStringInternal = "" } } } @@ -57,14 +71,14 @@ class SatsViewModel: ObservableObject { let btc = sats.divide(Decimal(100000000), 20, java.math.RoundingMode.DOWN) #endif btcStringInternal = btc.formatString() - if let btcToUsd { - usdStringInternal = (btc * btcToUsd).formatString() + if let btcToCurrency { + currencyValueStringInternal = (btc * btcToCurrency).formatString() } else { - usdStringInternal = "" + currencyValueStringInternal = "" } } else { btcStringInternal = "" - usdStringInternal = "" + currencyValueStringInternal = "" } } } @@ -84,35 +98,35 @@ class SatsViewModel: ObservableObject { let sats = btc * Decimal(100000000) satsStringInternal = sats.formatString() - if let btcToUsd { - usdStringInternal = (btc * btcToUsd).formatString() + if let btcToCurrency { + currencyValueStringInternal = (btc * btcToCurrency).formatString() } else { - usdStringInternal = "" + currencyValueStringInternal = "" } } else { satsStringInternal = "" - usdStringInternal = "" + currencyValueStringInternal = "" } } } - var usdString: String { + var currencyValueString: String { get { - usdStringInternal + currencyValueStringInternal } set { - guard usdStringInternal != newValue else { + guard currencyValueStringInternal != newValue else { return } - usdStringInternal = newValue + currencyValueStringInternal = newValue - if let usd { - if let btcToUsd { + if let currencyValue { + if let btcToCurrency { #if !SKIP - let btc = usd / btcToUsd + let btc = currencyValue / btcToCurrency #else - let btc = usd.divide(btcToUsd, 20, java.math.RoundingMode.DOWN) + let btc = currencyValue.divide(btcToCurrency, 20, java.math.RoundingMode.DOWN) #endif btcStringInternal = btc.formatString() @@ -120,21 +134,21 @@ class SatsViewModel: ObservableObject { satsStringInternal = sats.formatString() } else { satsStringInternal = "" - usdStringInternal = "" + currencyValueStringInternal = "" } } else { satsStringInternal = "" - usdStringInternal = "" + currencyValueStringInternal = "" } } } - var btcToUsd: Decimal? { + var btcToCurrency: Decimal? { #if !SKIP - return Decimal(string: btcToUsdStringInternal) + return Decimal(string: btcToCurrencyStringInternal) #else do { - return Decimal(btcToUsdStringInternal) + return Decimal(btcToCurrencyStringInternal) } catch { return nil } @@ -165,12 +179,12 @@ class SatsViewModel: ObservableObject { #endif } - var usd: Decimal? { + var currencyValue: Decimal? { #if !SKIP - return Decimal(string: usdStringInternal) + return Decimal(string: currencyValueStringInternal) #else do { - return Decimal(usdStringInternal) + return Decimal(currencyValueStringInternal) } catch { return nil } diff --git a/Tests/SatsPriceTests/SatsViewModelTests.swift b/Tests/SatsPriceTests/SatsViewModelTests.swift index d32562a..b36b920 100644 --- a/Tests/SatsPriceTests/SatsViewModelTests.swift +++ b/Tests/SatsPriceTests/SatsViewModelTests.swift @@ -8,6 +8,7 @@ // Created by Terry Yiu on 2/19/24. // +import Foundation import XCTest @testable import SatsPrice @@ -15,52 +16,88 @@ final class SatsViewModelTests: XCTestCase { func testSatsViewModel() { let satsViewModel = SatsViewModel() - satsViewModel.btcToUsdString = "54321" + satsViewModel.btcToCurrencyString = "54321" // Test BTC updates. satsViewModel.btcString = "1" +#if !SKIP XCTAssertEqual(satsViewModel.btc, Decimal(string: "1")) - XCTAssertEqual(satsViewModel.btcString, "1") XCTAssertEqual(satsViewModel.sats, Decimal(string: "100000000")) + XCTAssertEqual(satsViewModel.currencyValue, Decimal(string: "54321")) +#else + XCTAssertEqual(satsViewModel.btc, Decimal("1")) + XCTAssertEqual(satsViewModel.sats, Decimal("100000000")) + XCTAssertEqual(satsViewModel.currencyValue, Decimal("54321")) +#endif + XCTAssertEqual(satsViewModel.btcString, "1") XCTAssertEqual(satsViewModel.satsString, "100000000") - XCTAssertEqual(satsViewModel.usd, Decimal(string: "54321")) - XCTAssertEqual(satsViewModel.usdString, "54321") + XCTAssertEqual(satsViewModel.currencyValueString, "54321") // Test Sats updates. satsViewModel.satsString = "200000000" +#if !SKIP XCTAssertEqual(satsViewModel.btc, Decimal(string: "2")) - XCTAssertEqual(satsViewModel.btcString, "2") XCTAssertEqual(satsViewModel.sats, Decimal(string: "200000000")) + XCTAssertEqual(satsViewModel.currencyValue, Decimal(string: "108642")) +#else + XCTAssertEqual(satsViewModel.btc, Decimal("2")) + XCTAssertEqual(satsViewModel.sats, Decimal("200000000")) + XCTAssertEqual(satsViewModel.currencyValue, Decimal("108642")) +#endif + XCTAssertEqual(satsViewModel.btcString, "2") XCTAssertEqual(satsViewModel.satsString, "200000000") - XCTAssertEqual(satsViewModel.usd, Decimal(string: "108642")) - XCTAssertEqual(satsViewModel.usdString, "108642") + XCTAssertEqual(satsViewModel.currencyValueString, "108642") - // Test USD updates. - satsViewModel.usdString = "162963" + // Test currency value updates. + satsViewModel.currencyValueString = "162963" +#if !SKIP XCTAssertEqual(satsViewModel.btc, Decimal(string: "3")) - XCTAssertEqual(satsViewModel.btcString, "3") XCTAssertEqual(satsViewModel.sats, Decimal(string: "300000000")) + XCTAssertEqual(satsViewModel.currencyValue, Decimal(string: "162963")) +#else + XCTAssertEqual(satsViewModel.btc, Decimal("3")) + XCTAssertEqual(satsViewModel.sats, Decimal("300000000")) + XCTAssertEqual(satsViewModel.currencyValue, Decimal("162963")) +#endif + XCTAssertEqual(satsViewModel.btcString, "3") XCTAssertEqual(satsViewModel.satsString, "300000000") - XCTAssertEqual(satsViewModel.usd, Decimal(string: "162963")) - XCTAssertEqual(satsViewModel.usdString, "162963") + XCTAssertEqual(satsViewModel.currencyValueString, "162963") // Test fractional amounts. - satsViewModel.usdString = "1" + // Precision between platforms on this calculation is different so we have different assertions for each. + satsViewModel.currencyValueString = "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.usd, Decimal(string: "1")) - XCTAssertEqual(satsViewModel.usdString, "1") + XCTAssertEqual(satsViewModel.currencyValue, 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")) +#endif + XCTAssertEqual(satsViewModel.currencyValueString, "1") // Test large amounts that exceed the cap of 21M BTC. - satsViewModel.usdString = "11407419999999" + // Precision between platforms on this calculation is different so we have different assertions for each. + satsViewModel.currencyValueString = "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.usd, Decimal(string: "11407419999999")) - XCTAssertEqual(satsViewModel.usdString, "11407419999999") + XCTAssertEqual(satsViewModel.currencyValue, Decimal(string: "11407419999999")) +#else + XCTAssertEqual(satsViewModel.btc, Decimal("210000184.0908488429888993207")) + XCTAssertEqual(satsViewModel.btcString, "210000184.0908488429888993207") + XCTAssertEqual(satsViewModel.sats, Decimal("21000018409084884.29888993207")) + XCTAssertEqual(satsViewModel.satsString, "21000018409084884.29888993207") + XCTAssertEqual(satsViewModel.currencyValue, Decimal("11407419999999")) +#endif + XCTAssertEqual(satsViewModel.currencyValueString, "11407419999999") } }