Add support for currency selection
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
import Foundation
|
||||
|
||||
protocol PriceFetcher {
|
||||
func btcToUsd() async throws -> Decimal?
|
||||
func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal?
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user