9 Commits

18 changed files with 669 additions and 174 deletions

View File

@@ -1,4 +1,6 @@
-keeppackagenames ** -keeppackagenames **
-keep class skip.** { *; } -keep class skip.** { *; }
-keep class com.sun.jna.Pointer { *; } -keep class com.sun.jna.** { *; }
-keep class * implements com.sun.jna.** { *; }
-keep class sats.price.** { *; } -keep class sats.price.** { *; }
-dontwarn java.awt.**

View File

@@ -2,5 +2,9 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@@ -199,26 +199,30 @@
499CD4422AC5B799001AE8D8 /* Debug */ = { 499CD4422AC5B799001AE8D8 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_TEAM = S99A5B637C; DEVELOPMENT_TEAM = S99A5B637C;
ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
INFOPLIST_KEY_CFBundleDisplayName = SatsPrice; INFOPLIST_KEY_CFBundleDisplayName = SatsPrice;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.1.0;
}; };
name = Debug; name = Debug;
}; };
499CD4432AC5B799001AE8D8 /* Release */ = { 499CD4432AC5B799001AE8D8 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_TEAM = S99A5B637C; DEVELOPMENT_TEAM = S99A5B637C;
ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
INFOPLIST_KEY_CFBundleDisplayName = SatsPrice; INFOPLIST_KEY_CFBundleDisplayName = SatsPrice;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.1.0;
}; };
name = Release; name = Release;
}; };

View File

@@ -15,10 +15,23 @@ let package = Package(
], ],
dependencies: [ dependencies: [
.package(url: "https://source.skip.tools/skip.git", from: "1.0.7"), .package(url: "https://source.skip.tools/skip.git", from: "1.0.7"),
.package(url: "https://source.skip.tools/skip-ui.git", from: "1.0.0") .package(url: "https://source.skip.tools/skip-ui.git", from: "1.0.0"),
.package(url: "https://source.skip.tools/skip-foundation.git", from: "1.0.0"),
.package(url: "https://source.skip.tools/skip-model.git", from: "1.0.0"),
.package(url: "https://source.skip.tools/skip-sql.git", "0.0.0"..<"2.0.0")
], ],
targets: [ targets: [
.target(name: "SatsPrice", dependencies: [.product(name: "SkipUI", package: "skip-ui")], resources: [.process("Resources")], plugins: [.plugin(name: "skipstone", package: "skip")]), .target(
name: "SatsPrice",
dependencies: [
.product(name: "SkipUI", package: "skip-ui"),
.product(name: "SkipFoundation", package: "skip-foundation"),
.product(name: "SkipModel", package: "skip-model"),
.product(name: "SkipSQLPlus", package: "skip-sql")
],
resources: [.process("Resources")],
plugins: [.plugin(name: "skipstone", package: "skip")]
),
.testTarget(name: "SatsPriceTests", dependencies: ["SatsPrice", .product(name: "SkipTest", package: "skip")], resources: [.process("Resources")], plugins: [.plugin(name: "skipstone", package: "skip")]), .testTarget(name: "SatsPriceTests", dependencies: ["SatsPrice", .product(name: "SkipTest", package: "skip")], resources: [.process("Resources")], plugins: [.plugin(name: "skipstone", package: "skip")]),
] ]
) )

View File

@@ -11,10 +11,10 @@ PRODUCT_NAME = SatsPrice
PRODUCT_BUNDLE_IDENTIFIER = xyz.tyiu.SatsPrice PRODUCT_BUNDLE_IDENTIFIER = xyz.tyiu.SatsPrice
// The semantic version of the app // The semantic version of the app
MARKETING_VERSION = 1.0.0 MARKETING_VERSION = 1.1.0
// The build number specifying the internal app version // The build number specifying the internal app version
CURRENT_PROJECT_VERSION = 7 CURRENT_PROJECT_VERSION = 9
// The package name for the Android entry point, referenced by the AndroidManifest.xml // The package name for the Android entry point, referenced by the AndroidManifest.xml
ANDROID_PACKAGE_NAME = sats.price ANDROID_PACKAGE_NAME = sats.price

View File

@@ -6,71 +6,64 @@ import Combine
import SwiftUI import SwiftUI
public struct ContentView: View { public struct ContentView: View {
@ObservedObject private var satsViewModel = SatsViewModel() let model: SatsPriceModel
@State private var priceSource: PriceSource @StateObject private var satsViewModel: SatsViewModel
private let dateFormatter: DateFormatter private let dateFormatter: DateFormatter
private let priceFetcherDelegator: PriceFetcherDelegator init(model: SatsPriceModel) {
self.model = model
_satsViewModel = StateObject<SatsViewModel>(wrappedValue: SatsViewModel(model: model))
init(_ priceSource: PriceSource) {
dateFormatter = DateFormatter() dateFormatter = DateFormatter()
dateFormatter.dateStyle = .short dateFormatter.dateStyle = .short
dateFormatter.timeStyle = .short dateFormatter.timeStyle = .short
self.priceSource = priceSource
priceFetcherDelegator = PriceFetcherDelegator(priceSource)
} }
@MainActor public var addCurrencyView: some View {
func updatePrice() async { NavigationLink(
do { destination: {
guard let price = try await priceFetcherDelegator.convertBTC(toCurrency: satsViewModel.selectedCurrency) else { CurrencyPickerView(satsViewModel: satsViewModel)
satsViewModel.btcToCurrencyString = "" },
return label: {
Text("Change Currencies")
} }
)
}
satsViewModel.btcToCurrencyString = "\(price)" public func selectedCurrencyBinding(_ currency: Locale.Currency) -> Binding<String> {
} catch { Binding(
satsViewModel.btcToCurrencyString = "" get: {
} satsViewModel.currencyValueStrings[currency, default: ""]
satsViewModel.lastUpdated = Date.now },
set: { priceString in
satsViewModel.currencyValueStrings[currency] = priceString
}
)
} }
public var body: some View { public var body: some View {
NavigationStack { NavigationStack {
Form { Form {
Section { Section {
Picker("Price Source", selection: $priceSource) { Picker("Price Source", selection: $satsViewModel.priceSource) {
ForEach(PriceSource.allCases, id: \.self) { ForEach(PriceSource.allCases, id: \.self) {
Text($0.description) Text($0.description)
} }
} }
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 { 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) .disabled(satsViewModel.priceSource != .manual)
#if os(iOS) || SKIP #if os(iOS) || SKIP
.keyboardType(.decimalPad) .keyboardType(.decimalPad)
#endif #endif
if priceSource != .manual { if satsViewModel.priceSource != .manual {
Button(action: { Button(action: {
Task { Task {
await updatePrice() await satsViewModel.updatePrice()
} }
}) { }) {
Image(systemName: "arrow.clockwise.circle") Image(systemName: "arrow.clockwise.circle")
@@ -78,33 +71,29 @@ public struct ContentView: View {
} }
} }
} header: { } header: {
Text("1 BTC to \(satsViewModel.selectedCurrency.identifier)") Text("1 BTC to \(satsViewModel.currentCurrency.identifier)")
} footer: { } footer: {
if priceSource != .manual, let lastUpdated = satsViewModel.lastUpdated { if satsViewModel.priceSource != .manual, let lastUpdated = satsViewModel.lastUpdated {
Text("Last updated: \(dateFormatter.string(from: lastUpdated))") Text("Last updated: \(dateFormatter.string(from: lastUpdated))")
} }
} }
Section { Section {
TextField("Sats", text: $satsViewModel.satsString) HStack {
Text("Sats")
TextField("Sats", text: $satsViewModel.satsString)
#if os(iOS) || SKIP #if os(iOS) || SKIP
.keyboardType(.numberPad) .keyboardType(.numberPad)
#endif #endif
} header: {
Text("Sats")
} footer: {
if satsViewModel.exceedsMaximum {
Text("2100000000000000 sats is the maximum.")
} }
}
Section { HStack {
TextField("BTC", text: $satsViewModel.btcString) Text("BTC")
TextField("BTC", text: $satsViewModel.btcString)
#if os(iOS) || SKIP #if os(iOS) || SKIP
.keyboardType(.decimalPad) .keyboardType(.decimalPad)
#endif #endif
} header: { }
Text("BTC")
} footer: { } footer: {
if satsViewModel.exceedsMaximum { if satsViewModel.exceedsMaximum {
Text("21000000 BTC is the maximum.") Text("21000000 BTC is the maximum.")
@@ -112,28 +101,40 @@ public struct ContentView: View {
} }
Section { Section {
TextField(satsViewModel.selectedCurrency.identifier, text: $satsViewModel.currencyValueString) HStack {
Text(satsViewModel.currentCurrency.identifier)
TextField(satsViewModel.currentCurrency.identifier, text: satsViewModel.currencyValueString(for: satsViewModel.currentCurrency))
#if os(iOS) || SKIP #if os(iOS) || SKIP
.keyboardType(.decimalPad) .keyboardType(.decimalPad)
#endif #endif
} header: { }
Text(satsViewModel.selectedCurrency.identifier)
if satsViewModel.priceSource != .manual {
ForEach(satsViewModel.selectedCurrencies.sorted { $0.identifier < $1.identifier }.filter { $0 != satsViewModel.currentCurrency }, id: \.identifier) { currency in
HStack {
Text(currency.identifier)
TextField(currency.identifier, text: satsViewModel.currencyValueString(for: currency))
#if os(iOS) || SKIP
.keyboardType(.decimalPad)
#endif
}
.tag(currency.identifier)
}
}
}
if satsViewModel.priceSource != .manual {
addCurrencyView
} }
} }
.task { .task {
await updatePrice() await satsViewModel.pullSelectedCurrenciesFromDB()
await satsViewModel.updatePrice()
} }
.onChange(of: satsViewModel.selectedCurrency) { newCurrency in .onChange(of: satsViewModel.priceSource) { newPriceSource in
satsViewModel.lastUpdated = nil satsViewModel.lastUpdated = nil
Task { Task {
await updatePrice() await satsViewModel.updatePrice()
}
}
.onChange(of: priceSource) { newPriceSource in
satsViewModel.lastUpdated = nil
priceFetcherDelegator.priceSource = newPriceSource
Task {
await updatePrice()
} }
} }
#if os(macOS) #if os(macOS)
@@ -144,9 +145,6 @@ public struct ContentView: View {
} }
#Preview { #Preview {
#if DEBUG let satsPriceModel = try! SatsPriceModel(url: nil)
ContentView(.fake) ContentView(model: satsPriceModel)
#else
ContentView(.coinbase)
#endif
} }

View File

@@ -0,0 +1,85 @@
// This is free software: you can redistribute and/or modify it
// under the terms of the GNU General Public License 3.0
// as published by the Free Software Foundation https://fsf.org
//
// CurrencyPickerView.swift
// sats-price
//
// Created by Terry Yiu on 11/10/24.
//
import SwiftUI
struct CurrencyPickerView: View {
@ObservedObject var satsViewModel: SatsViewModel
var body: some View {
let currentCurrency = satsViewModel.currentCurrency
List {
Section("Current Currency") {
let currentCurrency = satsViewModel.currentCurrency
if let localizedCurrency = Locale.current.localizedString(forCurrencyCode: currentCurrency.identifier) {
Text("\(currentCurrency.identifier) - \(localizedCurrency)")
} else {
Text(currentCurrency.identifier)
}
}
if !satsViewModel.selectedCurrencies.isEmpty {
Section("Selected Currencies") {
ForEach(satsViewModel.selectedCurrencies.filter { $0 != currentCurrency }.sorted { $0.identifier < $1.identifier }, id: \.identifier) { currency in
Button(
action: {
satsViewModel.removeSelectedCurrency(currency)
},
label: {
HStack {
Group {
if let localizedCurrency = Locale.current.localizedString(forCurrencyCode: currency.identifier) {
Text("\(currency.identifier) - \(localizedCurrency)")
} else {
Text(currency.identifier)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "checkmark")
}
}
)
.buttonStyle(.plain)
}
}
}
Section("Currencies") {
ForEach(satsViewModel.currencies.filter { $0 != currentCurrency && !satsViewModel.selectedCurrencies.contains($0) }, id: \.identifier) { currency in
Button(
action: {
satsViewModel.addSelectedCurrency(currency)
},
label: {
if let localizedCurrency = Locale.current.localizedString(forCurrencyCode: currency.identifier) {
Text("\(currency.identifier) - \(localizedCurrency)")
} else {
Text(currency.identifier)
}
}
)
.buttonStyle(.plain)
}
}
}
.onDisappear(perform: {
Task {
await satsViewModel.updatePrice()
}
})
}
}
#Preview {
let satsPriceModel = try! SatsPriceModel(url: nil)
CurrencyPickerView(satsViewModel: SatsViewModel(model: satsPriceModel))
}

View File

@@ -0,0 +1,156 @@
// This is free software: you can redistribute and/or modify it
// under the terms of the GNU General Public License 3.0
// as published by the Free Software Foundation https://fsf.org
//
// SatsPriceModel.swift
// sats-price
//
// Created by Terry Yiu on 11/15/24.
//
import Foundation
import OSLog
import Observation
import SkipSQL
public struct SelectedCurrency: Identifiable {
public var id: String {
currencyCode
}
public var currencyCode: String
}
/// Notification posted by the model when selected currencies change.
extension Notification.Name {
public static var selectedCurrenciesDidChange: Notification.Name {
return Notification.Name("selectedCurrenciesChange")
}
}
/// Payload of `selectedCurrenciesDidChange` notifications.
public struct SelectedCurrenciesChange {
public let inserts: [SelectedCurrency]
/// Nil set means all records were deleted.
public let deletes: Set<String>?
public init(inserts: [SelectedCurrency] = [], deletes: [String]? = []) {
self.inserts = inserts
self.deletes = deletes == nil ? nil : Set(deletes!)
}
}
public actor SatsPriceModel {
private let ctx: SQLContext
private var schemaInitializationResult: Result<Void, Error>?
public init(url: URL?) throws {
ctx = try SQLContext(path: url?.path ?? ":memory:", flags: [.readWrite, .create], logLevel: .info, configuration: .platform)
}
public func selectedCurrencies() throws -> [SelectedCurrency] {
do {
try initializeSchema()
let statement = try ctx.prepare(sql: "SELECT currencyCode FROM SelectedCurrency")
defer {
do {
try statement.close()
} catch {
logger.warning("Failed to close statement: \(error)")
}
}
var selectedCurrencies: [SelectedCurrency] = []
while try statement.next() {
let currencyCode = statement.string(at: 0) ?? ""
selectedCurrencies.append(SelectedCurrency(currencyCode: currencyCode))
}
return selectedCurrencies
} catch {
logger.error("Failed to get selected currencies from DB. Error: \(error)")
throw error
}
}
@discardableResult
public func insert(_ selectedCurrency: SelectedCurrency) throws -> [SelectedCurrency] {
try initializeSchema()
let statement = try ctx.prepare(sql: "INSERT INTO SelectedCurrency (currencyCode) VALUES (?)")
defer {
do {
try statement.close()
} catch {
logger.warning("Failed to close statement: \(error)")
}
}
var insertedItems: [SelectedCurrency] = []
try ctx.transaction {
statement.reset()
let values = Self.bindingValues(for: selectedCurrency)
try statement.update(parameters: values)
insertedItems.append(selectedCurrency)
}
NotificationCenter.default.post(name: .selectedCurrenciesDidChange, object: SelectedCurrenciesChange(inserts: insertedItems))
return insertedItems
}
private static func bindingValues(for selectedCurrency: SelectedCurrency) -> [SQLValue] {
return [
.text(selectedCurrency.currencyCode)
]
}
@discardableResult
public func deleteSelectedCurrency(currencyCode: String) throws -> Int {
try initializeSchema()
try ctx.exec(sql: "DELETE FROM SelectedCurrency WHERE currencyCode = ?", parameters: [.text(currencyCode)])
NotificationCenter.default.post(name: .selectedCurrenciesDidChange, object: SelectedCurrenciesChange(deletes: [currencyCode]))
return Int(ctx.changes)
}
private func initializeSchema() throws {
switch schemaInitializationResult {
case .success:
return
case .failure(let failure):
throw failure
case nil:
break
}
do {
var currentVersion = try currentSchemaVersion()
currentVersion = try migrateSchema(v: Int64(1), current: currentVersion, ddl: """
CREATE TABLE SelectedCurrency (currencyCode TEXT PRIMARY KEY NOT NULL)
""")
// Future column additions, etc here...
schemaInitializationResult = .success(())
} catch {
schemaInitializationResult = .failure(error)
throw error
}
}
private func currentSchemaVersion() throws -> Int64 {
try ctx.exec(sql: "CREATE TABLE IF NOT EXISTS SchemaVersion (id INTEGER PRIMARY KEY, version INTEGER)")
try ctx.exec(sql: "INSERT OR IGNORE INTO SchemaVersion (id, version) VALUES (0, 0)")
return try ctx.query(sql: "SELECT version FROM SchemaVersion").first?.first?.integerValue ?? Int64(0)
}
private func migrateSchema(v version: Int64, current: Int64, ddl: String) throws -> Int64 {
guard current < version else {
return current
}
let startTime = Date.now
try ctx.transaction {
try ctx.exec(sql: ddl)
try ctx.exec(sql: "UPDATE SchemaVersion SET version = ?", parameters: [.integer(version)])
}
logger.log("Updated database schema to \(version) in \(Date.now.timeIntervalSince1970 - startTime.timeIntervalSince1970)")
return version
}
}

View File

@@ -23,6 +23,11 @@ class CoinGeckoPriceFetcher : PriceFetcher {
"https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=\(currency.identifier.lowercased())&precision=18" "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=\(currency.identifier.lowercased())&precision=18"
} }
func urlString(toCurrencies currencies: [Locale.Currency]) -> String {
let currenciesString = currencies.map { $0.identifier.lowercased() }.joined(separator: ",")
return "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=\(currenciesString)&precision=18"
}
func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? { func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? {
do { do {
guard let urlComponents = URLComponents(string: urlString(toCurrency: currency)), let url = urlComponents.url else { guard let urlComponents = URLComponents(string: urlString(toCurrency: currency)), let url = urlComponents.url else {
@@ -45,4 +50,43 @@ class CoinGeckoPriceFetcher : PriceFetcher {
return nil return nil
} }
} }
func convertBTC(toCurrencies currencies: [Locale.Currency]) async throws -> [Locale.Currency : Decimal] {
do {
guard !currencies.isEmpty else {
return [:]
}
if currencies.count == 1, let currency = currencies.first {
guard let price = try await convertBTC(toCurrency: currency) else {
return [:]
}
return [currency: price]
}
guard let urlComponents = URLComponents(string: urlString(toCurrencies: currencies)), let url = urlComponents.url else {
return [:]
}
let (data, _) = try await URLSession.shared.data(from: url, delegate: nil)
let priceResponse = try JSONDecoder().decode(CoinGeckoPriceResponse.self, from: data)
var results: [Locale.Currency : Decimal] = [:]
for currency in currencies {
if let price = priceResponse.bitcoin[currency.identifier.lowercased()] {
#if !SKIP
results[currency] = price
#else
results[currency] = Decimal(price)
#endif
}
}
return results
} catch {
return [:]
}
}
} }

View File

@@ -20,11 +20,22 @@ private struct CoinbasePrice: Codable {
let currency: String let currency: String
} }
private struct CoinbaseExchangeRatesResponse: Codable {
let data: CoinbaseExchangeRatesResponseData
}
private struct CoinbaseExchangeRatesResponseData: Codable {
let currency: String
let rates: [String: String]
}
class CoinbasePriceFetcher : PriceFetcher { class CoinbasePriceFetcher : PriceFetcher {
func urlString(toCurrency currency: Locale.Currency) -> String { func urlString(toCurrency currency: Locale.Currency) -> String {
"https://api.coinbase.com/v2/prices/BTC-\(currency.identifier)/spot" "https://api.coinbase.com/v2/prices/BTC-\(currency.identifier)/spot"
} }
private static let urlStringForAllCurrencies: String = "https://api.coinbase.com/v2/exchange-rates?currency=BTC"
func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? { func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? {
do { do {
guard let urlComponents = URLComponents(string: urlString(toCurrency: currency)), let url = urlComponents.url else { guard let urlComponents = URLComponents(string: urlString(toCurrency: currency)), let url = urlComponents.url else {
@@ -49,4 +60,48 @@ class CoinbasePriceFetcher : PriceFetcher {
return nil return nil
} }
} }
func convertBTC(toCurrencies currencies: [Locale.Currency]) async throws -> [Locale.Currency : Decimal] {
do {
guard !currencies.isEmpty else {
return [:]
}
if currencies.count == 1, let currency = currencies.first {
guard let price = try await convertBTC(toCurrency: currency) else {
return [:]
}
return [currency: price]
}
guard let urlComponents = URLComponents(string: CoinbasePriceFetcher.urlStringForAllCurrencies), let url = urlComponents.url else {
return [:]
}
let (data, _) = try await URLSession.shared.data(from: url, delegate: nil)
let coinbaseExchangeRatesResponse = try JSONDecoder().decode(CoinbaseExchangeRatesResponse.self, from: data)
let rates = coinbaseExchangeRatesResponse.data.rates
guard coinbaseExchangeRatesResponse.data.currency == "BTC" else {
return [:]
}
var results: [Locale.Currency : Decimal] = [:]
for currency in currencies {
if let price = rates[currency.identifier] {
#if !SKIP
results[currency] = Decimal(string: price)
#else
results[currency] = Decimal(price)
#endif
}
}
return results
} catch {
return [:]
}
}
} }

View File

@@ -14,6 +14,19 @@ import Foundation
/// Fake price fetcher that returns a randomized price. Useful for development testing without requiring a network call. /// Fake price fetcher that returns a randomized price. Useful for development testing without requiring a network call.
class FakePriceFetcher: PriceFetcher { class FakePriceFetcher: PriceFetcher {
func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? { func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? {
randomPrice()
}
func convertBTC(toCurrencies currencies: [Locale.Currency]) async throws -> [Locale.Currency : Decimal] {
guard !currencies.isEmpty else {
return [:]
}
let prices = currencies.map { _ in randomPrice() }
return Dictionary(uniqueKeysWithValues: zip(currencies, prices))
}
private func randomPrice() -> Decimal {
Decimal(Double.random(in: 10000...100000)) Decimal(Double.random(in: 10000...100000))
} }
} }

View File

@@ -12,9 +12,19 @@ import Foundation
/// Fake price fetcher that returns a randomized price. Useful for development testing without requiring a network call. /// Fake price fetcher that returns a randomized price. Useful for development testing without requiring a network call.
class ManualPriceFetcher: PriceFetcher { class ManualPriceFetcher: PriceFetcher {
var price: Decimal = Decimal(1) var prices: [Locale.Currency: Decimal] = [:]
func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? { func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? {
return price prices[currency]
}
func convertBTC(toCurrencies currencies: [Locale.Currency]) async throws -> [Locale.Currency : Decimal] {
guard !currencies.isEmpty else {
return [:]
}
let filteredCurrencies = currencies.filter { prices.keys.contains($0) }
let priceValues = filteredCurrencies.map { prices[$0, default: Decimal(0)] }
return Dictionary(uniqueKeysWithValues: zip(filteredCurrencies, priceValues))
} }
} }

View File

@@ -12,4 +12,5 @@ import Foundation
protocol PriceFetcher { protocol PriceFetcher {
func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal?
func convertBTC(toCurrencies currencies: [Locale.Currency]) async throws -> [Locale.Currency: Decimal]
} }

View File

@@ -42,4 +42,8 @@ class PriceFetcherDelegator: PriceFetcher {
func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? { func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? {
return try await delegate.convertBTC(toCurrency: currency) return try await delegate.convertBTC(toCurrency: currency)
} }
func convertBTC(toCurrencies currencies: [Locale.Currency]) async throws -> [Locale.Currency : Decimal] {
return try await delegate.convertBTC(toCurrencies: currencies)
}
} }

View File

@@ -19,14 +19,17 @@
}, },
"21000000 BTC is the maximum." : { "21000000 BTC is the maximum." : {
},
"2100000000000000 sats is the maximum." : {
}, },
"BTC" : { "BTC" : {
}, },
"Currency" : { "Change Currencies" : {
},
"Currencies" : {
},
"Current Currency" : {
}, },
"Last updated: %@" : { "Last updated: %@" : {
@@ -37,6 +40,9 @@
}, },
"Sats" : { "Sats" : {
},
"Selected Currencies" : {
} }
}, },
"version" : "1.0" "version" : "1.0"

View File

@@ -11,6 +11,9 @@ let logger: Logger = Logger(subsystem: "xyz.tyiu.SatsPrice", category: "SatsPric
/// The Android SDK number we are running against, or `nil` if not running on Android /// The Android SDK number we are running against, or `nil` if not running on Android
let androidSDK = ProcessInfo.processInfo.environment["android.os.Build.VERSION.SDK_INT"].flatMap({ Int($0) }) let androidSDK = ProcessInfo.processInfo.environment["android.os.Build.VERSION.SDK_INT"].flatMap({ Int($0) })
/// The shared data model.
private let model = try! SatsPriceModel(url: URL.documentsDirectory.appendingPathComponent("satsprice.sqlite"))
/// The shared top-level view for the app, loaded from the platform-specific App delegates below. /// The shared top-level view for the app, loaded from the platform-specific App delegates below.
/// ///
/// The default implementation merely loads the `ContentView` for the app and logs a message. /// The default implementation merely loads the `ContentView` for the app and logs a message.
@@ -19,7 +22,7 @@ public struct RootView : View {
} }
public var body: some View { public var body: some View {
ContentView(.coinbase) ContentView(model: model)
.task { .task {
logger.log("Welcome to Skip on \(androidSDK != nil ? "Android" : "Darwin")!") logger.log("Welcome to Skip on \(androidSDK != nil ? "Android" : "Darwin")!")
logger.warning("Skip app logs are viewable in the Xcode console for iOS; Android logs can be viewed in Studio or using adb logcat") logger.warning("Skip app logs are viewable in the Xcode console for iOS; Android logs can be viewed in Studio or using adb logcat")

View File

@@ -13,17 +13,28 @@ import Foundation
import SwiftUI import SwiftUI
class SatsViewModel: ObservableObject { class SatsViewModel: ObservableObject {
let model: SatsPriceModel
@Published var lastUpdated: Date? @Published var lastUpdated: Date?
@Published var btcToCurrencyStringInternal: String = "" @Published var priceSourceInternal: PriceSource = .coinbase
let priceFetcherDelegator = PriceFetcherDelegator(.coinbase)
@Published var satsStringInternal: String = "" @Published var satsStringInternal: String = ""
@Published var btcStringInternal: String = "" @Published var btcStringInternal: String = ""
@Published var currencyValueStringInternal: String = "" @Published var selectedCurrencies = Set<Locale.Currency>()
@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")
init(model: SatsPriceModel) {
self.model = model
}
var currencies: [Locale.Currency] { var currencies: [Locale.Currency] {
let commonISOCurrencyCodes = Set(Locale.commonISOCurrencyCodes) let commonISOCurrencyCodes = Set(Locale.commonISOCurrencyCodes)
let currentCurrency = Locale.current.currency ?? Locale.Currency("USD")
if commonISOCurrencyCodes.contains(currentCurrency.identifier) { if commonISOCurrencyCodes.contains(currentCurrency.identifier) {
return Locale.commonISOCurrencyCodes.map { Locale.Currency($0) } return Locale.commonISOCurrencyCodes.map { Locale.Currency($0) }
} else { } else {
@@ -34,25 +45,58 @@ class SatsViewModel: ObservableObject {
} }
} }
var btcToCurrencyString: String { @MainActor
func pullSelectedCurrenciesFromDB() async {
do {
let selectedCurrencies = Set(try await model.selectedCurrencies().compactMap { Locale.Currency($0.currencyCode) })
let currenciesToAdd = selectedCurrencies.subtracting(self.selectedCurrencies)
let currenciesToRemove = self.selectedCurrencies.subtracting(selectedCurrencies)
self.selectedCurrencies.subtract(currenciesToRemove)
self.selectedCurrencies.formUnion(currenciesToAdd)
} catch {
logger.error("Unable to pull selected currencies from DB. Error: \(error)")
}
}
func addSelectedCurrency(_ currency: Locale.Currency) {
selectedCurrencies.insert(currency)
Task {
try await model.insert(SelectedCurrency(currencyCode: currency.identifier))
}
}
func removeSelectedCurrency(_ currency: Locale.Currency) {
selectedCurrencies.remove(currency)
Task {
try await model.deleteSelectedCurrency(currencyCode: currency.identifier)
}
}
var priceSource: PriceSource {
get { get {
btcToCurrencyStringInternal priceSourceInternal
} }
set { set {
guard btcToCurrencyStringInternal != newValue else { priceSourceInternal = newValue
return priceFetcherDelegator.priceSource = newValue
}
btcToCurrencyStringInternal = newValue
if let btc, let btcToCurrency {
currencyValueStringInternal = (btc * btcToCurrency).formatString()
} else {
currencyValueStringInternal = ""
}
} }
} }
@MainActor
func updatePrice() async {
do {
let currencies = Set([currentCurrency] + selectedCurrencies)
let prices = try await priceFetcherDelegator.convertBTC(toCurrencies: Array(currencies))
currencyPrices = prices
updateCurrencyValueStrings()
} catch {
clearCurrencyValueStrings()
}
lastUpdated = Date.now
}
var satsString: String { var satsString: String {
get { get {
satsStringInternal satsStringInternal
@@ -71,14 +115,11 @@ class SatsViewModel: ObservableObject {
let btc = sats.divide(Decimal(100000000), 20, java.math.RoundingMode.DOWN) let btc = sats.divide(Decimal(100000000), 20, java.math.RoundingMode.DOWN)
#endif #endif
btcStringInternal = btc.formatString() btcStringInternal = btc.formatString()
if let btcToCurrency {
currencyValueStringInternal = (btc * btcToCurrency).formatString() updateCurrencyValueStrings()
} else {
currencyValueStringInternal = ""
}
} else { } else {
btcStringInternal = "" btcStringInternal = ""
currencyValueStringInternal = "" clearCurrencyValueStrings()
} }
} }
} }
@@ -98,65 +139,131 @@ class SatsViewModel: ObservableObject {
let sats = btc * Decimal(100000000) let sats = btc * Decimal(100000000)
satsStringInternal = sats.formatString() satsStringInternal = sats.formatString()
if let btcToCurrency { updateCurrencyValueStrings()
currencyValueStringInternal = (btc * btcToCurrency).formatString()
} else {
currencyValueStringInternal = ""
}
} else { } else {
satsStringInternal = "" satsStringInternal = ""
currencyValueStringInternal = "" clearCurrencyValueStrings()
} }
} }
} }
var currencyValueString: String { func updateCurrencyValueStrings(excludedCurrency: Locale.Currency? = nil) {
get { if let btc {
currencyValueStringInternal let currencies = Set([currentCurrency] + selectedCurrencies)
} .filter { $0 != excludedCurrency }
set {
guard currencyValueStringInternal != newValue else {
return
}
currencyValueStringInternal = newValue for currency in currencies {
if let btcToCurrency = btcToCurrency(for: currency) {
if let currencyValue { currencyValueStrings[currency] = (btc * btcToCurrency).formatString()
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()
} else { } else {
satsStringInternal = "" currencyValueStrings[currency] = ""
btcStringInternal = ""
currencyValueStringInternal = ""
} }
} 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<String> {
Binding<String>(
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 #if !SKIP
return Decimal(string: btcToCurrencyStringInternal) return Decimal(string: currencyValueString)
#else #else
do { do {
return Decimal(btcToCurrencyStringInternal) return Decimal(currencyValueString)
} catch { } catch {
return nil return nil
} }
#endif #endif
} }
func btcToCurrency(for currency: Locale.Currency) -> Decimal? {
currencyPrices[currency]
}
func btcToCurrencyString(for currency: Locale.Currency) -> Binding<String> {
Binding<String>(
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? { var sats: Decimal? {
#if !SKIP #if !SKIP
return Decimal(string: satsStringInternal) return Decimal(string: satsStringInternal)
@@ -181,18 +288,6 @@ class SatsViewModel: ObservableObject {
#endif #endif
} }
var currencyValue: Decimal? {
#if !SKIP
return Decimal(string: currencyValueStringInternal)
#else
do {
return Decimal(currencyValueStringInternal)
} catch {
return nil
}
#endif
}
var exceedsMaximum: Bool { var exceedsMaximum: Bool {
if let btc, btc > Decimal(21000000) { if let btc, btc > Decimal(21000000) {
return true return true

View File

@@ -14,82 +14,84 @@ import XCTest
final class SatsViewModelTests: XCTestCase { final class SatsViewModelTests: XCTestCase {
let currency = Locale.Currency("USD")
func testSatsViewModel() { func testSatsViewModel() {
let satsViewModel = SatsViewModel() let satsViewModel = SatsViewModel()
satsViewModel.btcToCurrencyString = "54321" satsViewModel.btcToCurrencyString(for: currency).wrappedValue = "54321"
// Test BTC updates. // Test BTC updates.
satsViewModel.btcString = "1" satsViewModel.btcString = "1"
#if !SKIP #if !SKIP
XCTAssertEqual(satsViewModel.btc, Decimal(string: "1")) XCTAssertEqual(satsViewModel.btc, Decimal(string: "1"))
XCTAssertEqual(satsViewModel.sats, Decimal(string: "100000000")) XCTAssertEqual(satsViewModel.sats, Decimal(string: "100000000"))
XCTAssertEqual(satsViewModel.currencyValue, Decimal(string: "54321")) XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "54321"))
#else #else
XCTAssertEqual(satsViewModel.btc, Decimal("1")) XCTAssertEqual(satsViewModel.btc, Decimal("1"))
XCTAssertEqual(satsViewModel.sats, Decimal("100000000")) XCTAssertEqual(satsViewModel.sats, Decimal("100000000"))
XCTAssertEqual(satsViewModel.currencyValue, Decimal("54321")) XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal("54321"))
#endif #endif
XCTAssertEqual(satsViewModel.btcString, "1") XCTAssertEqual(satsViewModel.btcString, "1")
XCTAssertEqual(satsViewModel.satsString, "100000000") XCTAssertEqual(satsViewModel.satsString, "100000000")
XCTAssertEqual(satsViewModel.currencyValueString, "54321") XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "54321")
// Test Sats updates. // Test Sats updates.
satsViewModel.satsString = "200000000" satsViewModel.satsString = "200000000"
#if !SKIP #if !SKIP
XCTAssertEqual(satsViewModel.btc, Decimal(string: "2")) XCTAssertEqual(satsViewModel.btc, Decimal(string: "2"))
XCTAssertEqual(satsViewModel.sats, Decimal(string: "200000000")) XCTAssertEqual(satsViewModel.sats, Decimal(string: "200000000"))
XCTAssertEqual(satsViewModel.currencyValue, Decimal(string: "108642")) XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "108642"))
#else #else
XCTAssertEqual(satsViewModel.btc, Decimal("2")) XCTAssertEqual(satsViewModel.btc, Decimal("2"))
XCTAssertEqual(satsViewModel.sats, Decimal("200000000")) XCTAssertEqual(satsViewModel.sats, Decimal("200000000"))
XCTAssertEqual(satsViewModel.currencyValue, Decimal("108642")) XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal("108642"))
#endif #endif
XCTAssertEqual(satsViewModel.btcString, "2") XCTAssertEqual(satsViewModel.btcString, "2")
XCTAssertEqual(satsViewModel.satsString, "200000000") XCTAssertEqual(satsViewModel.satsString, "200000000")
XCTAssertEqual(satsViewModel.currencyValueString, "108642") XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "108642")
// Test currency value updates. // Test currency value updates.
satsViewModel.currencyValueString = "162963" satsViewModel.currencyValueString(for: currency).wrappedValue = "162963"
#if !SKIP #if !SKIP
XCTAssertEqual(satsViewModel.btc, Decimal(string: "3")) XCTAssertEqual(satsViewModel.btc, Decimal(string: "3"))
XCTAssertEqual(satsViewModel.sats, Decimal(string: "300000000")) XCTAssertEqual(satsViewModel.sats, Decimal(string: "300000000"))
XCTAssertEqual(satsViewModel.currencyValue, Decimal(string: "162963")) XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "162963"))
#else #else
XCTAssertEqual(satsViewModel.btc, Decimal("3")) XCTAssertEqual(satsViewModel.btc, Decimal("3"))
XCTAssertEqual(satsViewModel.sats, Decimal("300000000")) XCTAssertEqual(satsViewModel.sats, Decimal("300000000"))
XCTAssertEqual(satsViewModel.currencyValue, Decimal("162963")) XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal("162963"))
#endif #endif
XCTAssertEqual(satsViewModel.btcString, "3") XCTAssertEqual(satsViewModel.btcString, "3")
XCTAssertEqual(satsViewModel.satsString, "300000000") XCTAssertEqual(satsViewModel.satsString, "300000000")
XCTAssertEqual(satsViewModel.currencyValueString, "162963") XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "162963")
// Test fractional amounts. // Test fractional amounts.
// Precision between platforms on this calculation is different so we have different assertions for each. // 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 #if !SKIP
XCTAssertEqual(satsViewModel.btc, Decimal(string: "0.00001840908672520756245282671526665562")) XCTAssertEqual(satsViewModel.btc, Decimal(string: "0.00001840908672520756245282671526665562"))
XCTAssertEqual(satsViewModel.btcString, "0.00001840908672520756245282671526665562") XCTAssertEqual(satsViewModel.btcString, "0.00001840908672520756245282671526665562")
XCTAssertEqual(satsViewModel.sats, Decimal(string: "1840.908672520756245282671526665562")) XCTAssertEqual(satsViewModel.sats, Decimal(string: "1840.908672520756245282671526665562"))
XCTAssertEqual(satsViewModel.satsString, "1840.908672520756245282671526665562") XCTAssertEqual(satsViewModel.satsString, "1840.908672520756245282671526665562")
XCTAssertEqual(satsViewModel.currencyValue, Decimal(string: "1")) XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "1"))
#else #else
XCTAssertEqual(satsViewModel.btc, Decimal("0.00001840908672520756")) XCTAssertEqual(satsViewModel.btc, Decimal("0.00001840908672520756"))
XCTAssertEqual(satsViewModel.btcString, "0.00001840908672520756") XCTAssertEqual(satsViewModel.btcString, "0.00001840908672520756")
XCTAssertEqual(satsViewModel.sats, Decimal("1840.908672520756")) XCTAssertEqual(satsViewModel.sats, Decimal("1840.908672520756"))
XCTAssertEqual(satsViewModel.satsString, "1840.908672520756") XCTAssertEqual(satsViewModel.satsString, "1840.908672520756")
XCTAssertEqual(satsViewModel.currencyValue, Decimal("1")) XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal("1"))
#endif #endif
XCTAssertEqual(satsViewModel.currencyValueString, "1") XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "1")
// Test large amounts that exceed the cap of 21M BTC. // 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. // 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 #if !SKIP
XCTAssertEqual(satsViewModel.btc, Decimal(string: "210000184.09084884298889932070469983984")) XCTAssertEqual(satsViewModel.btc, Decimal(string: "210000184.09084884298889932070469983984"))
XCTAssertEqual(satsViewModel.btcString, "210000184.09084884298889932070469983984") XCTAssertEqual(satsViewModel.btcString, "210000184.09084884298889932070469983984")
XCTAssertEqual(satsViewModel.sats, Decimal(string: "21000018409084884.298889932070469983984")) XCTAssertEqual(satsViewModel.sats, Decimal(string: "21000018409084884.298889932070469983984"))
XCTAssertEqual(satsViewModel.satsString, "21000018409084884.298889932070469983984") XCTAssertEqual(satsViewModel.satsString, "21000018409084884.298889932070469983984")
XCTAssertEqual(satsViewModel.currencyValue, Decimal(string: "11407419999999")) XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "11407419999999"))
#else #else
XCTAssertEqual(satsViewModel.btc, Decimal("210000184.0908488429888993207")) XCTAssertEqual(satsViewModel.btc, Decimal("210000184.0908488429888993207"))
XCTAssertEqual(satsViewModel.btcString, "210000184.0908488429888993207") XCTAssertEqual(satsViewModel.btcString, "210000184.0908488429888993207")
@@ -97,7 +99,7 @@ final class SatsViewModelTests: XCTestCase {
XCTAssertEqual(satsViewModel.satsString, "21000018409084884.29888993207") XCTAssertEqual(satsViewModel.satsString, "21000018409084884.29888993207")
XCTAssertEqual(satsViewModel.currencyValue, Decimal("11407419999999")) XCTAssertEqual(satsViewModel.currencyValue, Decimal("11407419999999"))
#endif #endif
XCTAssertEqual(satsViewModel.currencyValueString, "11407419999999") XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "11407419999999")
} }
} }