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 **
|
-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.**
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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")]),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
4
Skip.env
4
Skip.env
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
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"
|
"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 [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user