Add support for currency selection

This commit is contained in:
2024-09-07 05:55:34 +03:00
parent cb0397ba98
commit edb10c48e6
11 changed files with 164 additions and 87 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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))
}
}

View File

@@ -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
}
}

View File

@@ -11,5 +11,5 @@
import Foundation
protocol PriceFetcher {
func btcToUsd() async throws -> Decimal?
func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal?
}

View File

@@ -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)
}
}

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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")
}
}