Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
38310d922f
|
|||
|
b13a472b10
|
|||
|
d7cbd96e09
|
|||
|
da21b78588
|
|||
|
303cad1076
|
|||
|
9321f920b4
|
|||
|
9faaa24e8d
|
|||
|
6e8ef69136
|
|||
|
e6eb78faa2
|
4
Android/app/proguard-rules.pro
vendored
4
Android/app/proguard-rules.pro
vendored
@@ -1,4 +1,6 @@
|
||||
-keeppackagenames **
|
||||
-keep class skip.** { *; }
|
||||
-keep class com.sun.jna.Pointer { *; }
|
||||
-keep class com.sun.jna.** { *; }
|
||||
-keep class * implements com.sun.jna.** { *; }
|
||||
-keep class sats.price.** { *; }
|
||||
-dontwarn java.awt.**
|
||||
|
||||
@@ -2,5 +2,9 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -199,26 +199,30 @@
|
||||
499CD4422AC5B799001AE8D8 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
DEVELOPMENT_TEAM = S99A5B637C;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = SatsPrice;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
499CD4432AC5B799001AE8D8 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
DEVELOPMENT_TEAM = S99A5B637C;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = SatsPrice;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
|
||||
@@ -15,10 +15,23 @@ let package = Package(
|
||||
],
|
||||
dependencies: [
|
||||
.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: [
|
||||
.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")]),
|
||||
]
|
||||
)
|
||||
|
||||
4
Skip.env
4
Skip.env
@@ -11,10 +11,10 @@ PRODUCT_NAME = SatsPrice
|
||||
PRODUCT_BUNDLE_IDENTIFIER = xyz.tyiu.SatsPrice
|
||||
|
||||
// The semantic version of the app
|
||||
MARKETING_VERSION = 1.0.0
|
||||
MARKETING_VERSION = 1.1.0
|
||||
|
||||
// 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
|
||||
ANDROID_PACKAGE_NAME = sats.price
|
||||
|
||||
@@ -6,71 +6,64 @@ import Combine
|
||||
import SwiftUI
|
||||
|
||||
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 priceFetcherDelegator: PriceFetcherDelegator
|
||||
init(model: SatsPriceModel) {
|
||||
self.model = model
|
||||
|
||||
_satsViewModel = StateObject<SatsViewModel>(wrappedValue: SatsViewModel(model: model))
|
||||
|
||||
init(_ priceSource: PriceSource) {
|
||||
dateFormatter = DateFormatter()
|
||||
dateFormatter.dateStyle = .short
|
||||
dateFormatter.timeStyle = .short
|
||||
|
||||
self.priceSource = priceSource
|
||||
priceFetcherDelegator = PriceFetcherDelegator(priceSource)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func updatePrice() async {
|
||||
do {
|
||||
guard let price = try await priceFetcherDelegator.convertBTC(toCurrency: satsViewModel.selectedCurrency) else {
|
||||
satsViewModel.btcToCurrencyString = ""
|
||||
return
|
||||
public var addCurrencyView: some View {
|
||||
NavigationLink(
|
||||
destination: {
|
||||
CurrencyPickerView(satsViewModel: satsViewModel)
|
||||
},
|
||||
label: {
|
||||
Text("Change Currencies")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
satsViewModel.btcToCurrencyString = "\(price)"
|
||||
} catch {
|
||||
satsViewModel.btcToCurrencyString = ""
|
||||
}
|
||||
satsViewModel.lastUpdated = Date.now
|
||||
public func selectedCurrencyBinding(_ currency: Locale.Currency) -> Binding<String> {
|
||||
Binding(
|
||||
get: {
|
||||
satsViewModel.currencyValueStrings[currency, default: ""]
|
||||
},
|
||||
set: { priceString in
|
||||
satsViewModel.currencyValueStrings[currency] = priceString
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Picker("Price Source", selection: $priceSource) {
|
||||
Picker("Price Source", selection: $satsViewModel.priceSource) {
|
||||
ForEach(PriceSource.allCases, id: \.self) {
|
||||
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 {
|
||||
TextField("1 BTC to \(satsViewModel.selectedCurrency.identifier)", text: $satsViewModel.btcToCurrencyString)
|
||||
.disabled(priceSource != .manual)
|
||||
TextField("1 BTC to \(satsViewModel.currentCurrency.identifier)", text: satsViewModel.btcToCurrencyString(for: satsViewModel.currentCurrency))
|
||||
.disabled(satsViewModel.priceSource != .manual)
|
||||
#if os(iOS) || SKIP
|
||||
.keyboardType(.decimalPad)
|
||||
#endif
|
||||
if priceSource != .manual {
|
||||
if satsViewModel.priceSource != .manual {
|
||||
Button(action: {
|
||||
Task {
|
||||
await updatePrice()
|
||||
await satsViewModel.updatePrice()
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "arrow.clockwise.circle")
|
||||
@@ -78,33 +71,29 @@ public struct ContentView: View {
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("1 BTC to \(satsViewModel.selectedCurrency.identifier)")
|
||||
Text("1 BTC to \(satsViewModel.currentCurrency.identifier)")
|
||||
} footer: {
|
||||
if priceSource != .manual, let lastUpdated = satsViewModel.lastUpdated {
|
||||
if satsViewModel.priceSource != .manual, let lastUpdated = satsViewModel.lastUpdated {
|
||||
Text("Last updated: \(dateFormatter.string(from: lastUpdated))")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Sats", text: $satsViewModel.satsString)
|
||||
HStack {
|
||||
Text("Sats")
|
||||
TextField("Sats", text: $satsViewModel.satsString)
|
||||
#if os(iOS) || SKIP
|
||||
.keyboardType(.numberPad)
|
||||
.keyboardType(.numberPad)
|
||||
#endif
|
||||
} header: {
|
||||
Text("Sats")
|
||||
} footer: {
|
||||
if satsViewModel.exceedsMaximum {
|
||||
Text("2100000000000000 sats is the maximum.")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("BTC", text: $satsViewModel.btcString)
|
||||
HStack {
|
||||
Text("BTC")
|
||||
TextField("BTC", text: $satsViewModel.btcString)
|
||||
#if os(iOS) || SKIP
|
||||
.keyboardType(.decimalPad)
|
||||
.keyboardType(.decimalPad)
|
||||
#endif
|
||||
} header: {
|
||||
Text("BTC")
|
||||
}
|
||||
} footer: {
|
||||
if satsViewModel.exceedsMaximum {
|
||||
Text("21000000 BTC is the maximum.")
|
||||
@@ -112,28 +101,40 @@ public struct ContentView: View {
|
||||
}
|
||||
|
||||
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
|
||||
.keyboardType(.decimalPad)
|
||||
.keyboardType(.decimalPad)
|
||||
#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 {
|
||||
await updatePrice()
|
||||
await satsViewModel.pullSelectedCurrenciesFromDB()
|
||||
await satsViewModel.updatePrice()
|
||||
}
|
||||
.onChange(of: satsViewModel.selectedCurrency) { newCurrency in
|
||||
.onChange(of: satsViewModel.priceSource) { newPriceSource in
|
||||
satsViewModel.lastUpdated = nil
|
||||
Task {
|
||||
await updatePrice()
|
||||
}
|
||||
}
|
||||
.onChange(of: priceSource) { newPriceSource in
|
||||
satsViewModel.lastUpdated = nil
|
||||
priceFetcherDelegator.priceSource = newPriceSource
|
||||
Task {
|
||||
await updatePrice()
|
||||
await satsViewModel.updatePrice()
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
@@ -144,9 +145,6 @@ public struct ContentView: View {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
#if DEBUG
|
||||
ContentView(.fake)
|
||||
#else
|
||||
ContentView(.coinbase)
|
||||
#endif
|
||||
let satsPriceModel = try! SatsPriceModel(url: nil)
|
||||
ContentView(model: satsPriceModel)
|
||||
}
|
||||
|
||||
85
Sources/SatsPrice/CurrencyPickerView.swift
Normal file
85
Sources/SatsPrice/CurrencyPickerView.swift
Normal 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))
|
||||
}
|
||||
156
Sources/SatsPrice/Model/SatsPriceModel.swift
Normal file
156
Sources/SatsPrice/Model/SatsPriceModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,11 @@ class CoinGeckoPriceFetcher : PriceFetcher {
|
||||
"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? {
|
||||
do {
|
||||
guard let urlComponents = URLComponents(string: urlString(toCurrency: currency)), let url = urlComponents.url else {
|
||||
@@ -45,4 +50,43 @@ class CoinGeckoPriceFetcher : PriceFetcher {
|
||||
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 [:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,11 +20,22 @@ private struct CoinbasePrice: Codable {
|
||||
let currency: String
|
||||
}
|
||||
|
||||
private struct CoinbaseExchangeRatesResponse: Codable {
|
||||
let data: CoinbaseExchangeRatesResponseData
|
||||
}
|
||||
|
||||
private struct CoinbaseExchangeRatesResponseData: Codable {
|
||||
let currency: String
|
||||
let rates: [String: String]
|
||||
}
|
||||
|
||||
class CoinbasePriceFetcher : PriceFetcher {
|
||||
func urlString(toCurrency currency: Locale.Currency) -> String {
|
||||
"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? {
|
||||
do {
|
||||
guard let urlComponents = URLComponents(string: urlString(toCurrency: currency)), let url = urlComponents.url else {
|
||||
@@ -49,4 +60,48 @@ class CoinbasePriceFetcher : PriceFetcher {
|
||||
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 [:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,19 @@ import Foundation
|
||||
/// Fake price fetcher that returns a randomized price. Useful for development testing without requiring a network call.
|
||||
class FakePriceFetcher: PriceFetcher {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,19 @@ import Foundation
|
||||
|
||||
/// Fake price fetcher that returns a randomized price. Useful for development testing without requiring a network call.
|
||||
class ManualPriceFetcher: PriceFetcher {
|
||||
var price: Decimal = Decimal(1)
|
||||
var prices: [Locale.Currency: 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,4 +12,5 @@ import Foundation
|
||||
|
||||
protocol PriceFetcher {
|
||||
func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal?
|
||||
func convertBTC(toCurrencies currencies: [Locale.Currency]) async throws -> [Locale.Currency: Decimal]
|
||||
}
|
||||
|
||||
@@ -42,4 +42,8 @@ class PriceFetcherDelegator: PriceFetcher {
|
||||
func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,14 +19,17 @@
|
||||
},
|
||||
"21000000 BTC is the maximum." : {
|
||||
|
||||
},
|
||||
"2100000000000000 sats is the maximum." : {
|
||||
|
||||
},
|
||||
"BTC" : {
|
||||
|
||||
},
|
||||
"Currency" : {
|
||||
"Change Currencies" : {
|
||||
|
||||
},
|
||||
"Currencies" : {
|
||||
|
||||
},
|
||||
"Current Currency" : {
|
||||
|
||||
},
|
||||
"Last updated: %@" : {
|
||||
@@ -37,6 +40,9 @@
|
||||
},
|
||||
"Sats" : {
|
||||
|
||||
},
|
||||
"Selected Currencies" : {
|
||||
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
|
||||
@@ -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
|
||||
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 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 {
|
||||
ContentView(.coinbase)
|
||||
ContentView(model: model)
|
||||
.task {
|
||||
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")
|
||||
|
||||
@@ -13,17 +13,28 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
class SatsViewModel: ObservableObject {
|
||||
let model: SatsPriceModel
|
||||
|
||||
@Published var lastUpdated: Date?
|
||||
|
||||
@Published var btcToCurrencyStringInternal: String = ""
|
||||
@Published var priceSourceInternal: PriceSource = .coinbase
|
||||
let priceFetcherDelegator = PriceFetcherDelegator(.coinbase)
|
||||
|
||||
@Published var satsStringInternal: String = ""
|
||||
@Published var btcStringInternal: String = ""
|
||||
@Published var currencyValueStringInternal: String = ""
|
||||
@Published var selectedCurrency: Locale.Currency = Locale.current.currency ?? Locale.Currency("USD")
|
||||
@Published var selectedCurrencies = Set<Locale.Currency>()
|
||||
@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] {
|
||||
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 {
|
||||
@@ -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 {
|
||||
btcToCurrencyStringInternal
|
||||
priceSourceInternal
|
||||
}
|
||||
set {
|
||||
guard btcToCurrencyStringInternal != newValue else {
|
||||
return
|
||||
}
|
||||
|
||||
btcToCurrencyStringInternal = newValue
|
||||
|
||||
if let btc, let btcToCurrency {
|
||||
currencyValueStringInternal = (btc * btcToCurrency).formatString()
|
||||
} else {
|
||||
currencyValueStringInternal = ""
|
||||
}
|
||||
priceSourceInternal = newValue
|
||||
priceFetcherDelegator.priceSource = newValue
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
get {
|
||||
satsStringInternal
|
||||
@@ -71,14 +115,11 @@ class SatsViewModel: ObservableObject {
|
||||
let btc = sats.divide(Decimal(100000000), 20, java.math.RoundingMode.DOWN)
|
||||
#endif
|
||||
btcStringInternal = btc.formatString()
|
||||
if let btcToCurrency {
|
||||
currencyValueStringInternal = (btc * btcToCurrency).formatString()
|
||||
} else {
|
||||
currencyValueStringInternal = ""
|
||||
}
|
||||
|
||||
updateCurrencyValueStrings()
|
||||
} else {
|
||||
btcStringInternal = ""
|
||||
currencyValueStringInternal = ""
|
||||
clearCurrencyValueStrings()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,65 +139,131 @@ class SatsViewModel: ObservableObject {
|
||||
let sats = btc * Decimal(100000000)
|
||||
satsStringInternal = sats.formatString()
|
||||
|
||||
if let btcToCurrency {
|
||||
currencyValueStringInternal = (btc * btcToCurrency).formatString()
|
||||
} else {
|
||||
currencyValueStringInternal = ""
|
||||
}
|
||||
updateCurrencyValueStrings()
|
||||
} else {
|
||||
satsStringInternal = ""
|
||||
currencyValueStringInternal = ""
|
||||
clearCurrencyValueStrings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var currencyValueString: String {
|
||||
get {
|
||||
currencyValueStringInternal
|
||||
}
|
||||
set {
|
||||
guard currencyValueStringInternal != newValue else {
|
||||
return
|
||||
}
|
||||
func updateCurrencyValueStrings(excludedCurrency: Locale.Currency? = nil) {
|
||||
if let btc {
|
||||
let currencies = Set([currentCurrency] + selectedCurrencies)
|
||||
.filter { $0 != excludedCurrency }
|
||||
|
||||
currencyValueStringInternal = newValue
|
||||
|
||||
if let currencyValue {
|
||||
if let btcToCurrency {
|
||||
#if !SKIP
|
||||
let btc = currencyValue / btcToCurrency
|
||||
#else
|
||||
let btc = currencyValue.divide(btcToCurrency, 20, java.math.RoundingMode.DOWN)
|
||||
#endif
|
||||
btcStringInternal = btc.formatString()
|
||||
|
||||
let sats = btc * Decimal(100000000)
|
||||
satsStringInternal = sats.formatString()
|
||||
for currency in currencies {
|
||||
if let btcToCurrency = btcToCurrency(for: currency) {
|
||||
currencyValueStrings[currency] = (btc * btcToCurrency).formatString()
|
||||
} else {
|
||||
satsStringInternal = ""
|
||||
btcStringInternal = ""
|
||||
currencyValueStringInternal = ""
|
||||
currencyValueStrings[currency] = ""
|
||||
}
|
||||
} else {
|
||||
satsStringInternal = ""
|
||||
btcStringInternal = ""
|
||||
currencyValueStringInternal = ""
|
||||
}
|
||||
} else {
|
||||
clearCurrencyValueStrings()
|
||||
}
|
||||
}
|
||||
|
||||
var btcToCurrency: Decimal? {
|
||||
func clearCurrencyValueStrings() {
|
||||
for currency in currencyValueStrings.keys {
|
||||
currencyValueStrings[currency] = ""
|
||||
}
|
||||
}
|
||||
|
||||
func currencyValueString(for currency: Locale.Currency) -> Binding<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
|
||||
return Decimal(string: btcToCurrencyStringInternal)
|
||||
return Decimal(string: currencyValueString)
|
||||
#else
|
||||
do {
|
||||
return Decimal(btcToCurrencyStringInternal)
|
||||
return Decimal(currencyValueString)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func btcToCurrency(for currency: Locale.Currency) -> Decimal? {
|
||||
currencyPrices[currency]
|
||||
}
|
||||
|
||||
func btcToCurrencyString(for currency: Locale.Currency) -> Binding<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? {
|
||||
#if !SKIP
|
||||
return Decimal(string: satsStringInternal)
|
||||
@@ -181,18 +288,6 @@ class SatsViewModel: ObservableObject {
|
||||
#endif
|
||||
}
|
||||
|
||||
var currencyValue: Decimal? {
|
||||
#if !SKIP
|
||||
return Decimal(string: currencyValueStringInternal)
|
||||
#else
|
||||
do {
|
||||
return Decimal(currencyValueStringInternal)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var exceedsMaximum: Bool {
|
||||
if let btc, btc > Decimal(21000000) {
|
||||
return true
|
||||
|
||||
@@ -14,82 +14,84 @@ import XCTest
|
||||
|
||||
final class SatsViewModelTests: XCTestCase {
|
||||
|
||||
let currency = Locale.Currency("USD")
|
||||
|
||||
func testSatsViewModel() {
|
||||
let satsViewModel = SatsViewModel()
|
||||
satsViewModel.btcToCurrencyString = "54321"
|
||||
satsViewModel.btcToCurrencyString(for: currency).wrappedValue = "54321"
|
||||
|
||||
// Test BTC updates.
|
||||
satsViewModel.btcString = "1"
|
||||
#if !SKIP
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal(string: "1"))
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal(string: "100000000"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue, Decimal(string: "54321"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "54321"))
|
||||
#else
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal("1"))
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal("100000000"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue, Decimal("54321"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal("54321"))
|
||||
#endif
|
||||
XCTAssertEqual(satsViewModel.btcString, "1")
|
||||
XCTAssertEqual(satsViewModel.satsString, "100000000")
|
||||
XCTAssertEqual(satsViewModel.currencyValueString, "54321")
|
||||
XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "54321")
|
||||
|
||||
// Test Sats updates.
|
||||
satsViewModel.satsString = "200000000"
|
||||
#if !SKIP
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal(string: "2"))
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal(string: "200000000"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue, Decimal(string: "108642"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "108642"))
|
||||
#else
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal("2"))
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal("200000000"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue, Decimal("108642"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal("108642"))
|
||||
#endif
|
||||
XCTAssertEqual(satsViewModel.btcString, "2")
|
||||
XCTAssertEqual(satsViewModel.satsString, "200000000")
|
||||
XCTAssertEqual(satsViewModel.currencyValueString, "108642")
|
||||
XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "108642")
|
||||
|
||||
// Test currency value updates.
|
||||
satsViewModel.currencyValueString = "162963"
|
||||
satsViewModel.currencyValueString(for: currency).wrappedValue = "162963"
|
||||
#if !SKIP
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal(string: "3"))
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal(string: "300000000"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue, Decimal(string: "162963"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "162963"))
|
||||
#else
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal("3"))
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal("300000000"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue, Decimal("162963"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal("162963"))
|
||||
#endif
|
||||
XCTAssertEqual(satsViewModel.btcString, "3")
|
||||
XCTAssertEqual(satsViewModel.satsString, "300000000")
|
||||
XCTAssertEqual(satsViewModel.currencyValueString, "162963")
|
||||
XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "162963")
|
||||
|
||||
// Test fractional amounts.
|
||||
// Precision between platforms on this calculation is different so we have different assertions for each.
|
||||
satsViewModel.currencyValueString = "1"
|
||||
satsViewModel.currencyValueString(for: currency).wrappedValue = "1"
|
||||
#if !SKIP
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal(string: "0.00001840908672520756245282671526665562"))
|
||||
XCTAssertEqual(satsViewModel.btcString, "0.00001840908672520756245282671526665562")
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal(string: "1840.908672520756245282671526665562"))
|
||||
XCTAssertEqual(satsViewModel.satsString, "1840.908672520756245282671526665562")
|
||||
XCTAssertEqual(satsViewModel.currencyValue, Decimal(string: "1"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "1"))
|
||||
#else
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal("0.00001840908672520756"))
|
||||
XCTAssertEqual(satsViewModel.btcString, "0.00001840908672520756")
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal("1840.908672520756"))
|
||||
XCTAssertEqual(satsViewModel.satsString, "1840.908672520756")
|
||||
XCTAssertEqual(satsViewModel.currencyValue, Decimal("1"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal("1"))
|
||||
#endif
|
||||
XCTAssertEqual(satsViewModel.currencyValueString, "1")
|
||||
XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "1")
|
||||
|
||||
// Test large amounts that exceed the cap of 21M BTC.
|
||||
// Precision between platforms on this calculation is different so we have different assertions for each.
|
||||
satsViewModel.currencyValueString = "11407419999999"
|
||||
satsViewModel.currencyValueString(for: currency).wrappedValue = "11407419999999"
|
||||
#if !SKIP
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal(string: "210000184.09084884298889932070469983984"))
|
||||
XCTAssertEqual(satsViewModel.btcString, "210000184.09084884298889932070469983984")
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal(string: "21000018409084884.298889932070469983984"))
|
||||
XCTAssertEqual(satsViewModel.satsString, "21000018409084884.298889932070469983984")
|
||||
XCTAssertEqual(satsViewModel.currencyValue, Decimal(string: "11407419999999"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "11407419999999"))
|
||||
#else
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal("210000184.0908488429888993207"))
|
||||
XCTAssertEqual(satsViewModel.btcString, "210000184.0908488429888993207")
|
||||
@@ -97,7 +99,7 @@ final class SatsViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(satsViewModel.satsString, "21000018409084884.29888993207")
|
||||
XCTAssertEqual(satsViewModel.currencyValue, Decimal("11407419999999"))
|
||||
#endif
|
||||
XCTAssertEqual(satsViewModel.currencyValueString, "11407419999999")
|
||||
XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "11407419999999")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user