7 Commits
v1.1.0 ... main

15 changed files with 518 additions and 84 deletions

View File

@@ -199,7 +199,7 @@
499CD4422AC5B799001AE8D8 /* Debug */ = { 499CD4422AC5B799001AE8D8 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CURRENT_PROJECT_VERSION = 9; CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = S99A5B637C; DEVELOPMENT_TEAM = S99A5B637C;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -207,14 +207,14 @@
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.1.0; MARKETING_VERSION = 1.2.0;
}; };
name = Debug; name = Debug;
}; };
499CD4432AC5B799001AE8D8 /* Release */ = { 499CD4432AC5B799001AE8D8 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CURRENT_PROJECT_VERSION = 9; CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = S99A5B637C; DEVELOPMENT_TEAM = S99A5B637C;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -222,7 +222,7 @@
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.1.0; MARKETING_VERSION = 1.2.0;
}; };
name = Release; name = Release;
}; };

View File

@@ -1,12 +1,41 @@
<div align="center">
<img src="./docs/assets/satsprice-logo.png" alt="SatsPrice Logo" title="SatsPrice logo" width="256"/>
# SatsPrice # SatsPrice
This app fetches the price of Bitcoin relative to common ISO currencies from multiple sources, and converts inputted amounts between Sats, BTC, and the selected ISO currency. This app fetches the price of Bitcoin relative to common fiat currencies from multiple sources, and converts inputted amounts between Sats, BTC, and the selected fiat currency.
[![GitHub downloads](https://img.shields.io/github/downloads/tyiu/sats-price/total?label=Downloads&labelColor=27303D&color=0D1117&logo=github&logoColor=FFFFFF&style=flat)](https://github.com/tyiu/sats-price/releases)
[![Last Version](https://img.shields.io/github/release/tyiu/sats-price?maxAge=3600&label=Stable&labelColor=06599d&color=043b69)](https://github.com/tyiu/sats-price)
[![License: GPL-3.0](https://img.shields.io/github/license/tyiu/sats-price?labelColor=27303D&color=0877d2)](/LICENSE)
## Download and Install
[<img src="./docs/assets/download_on_apple.svg"
alt="Download on the Apple App Store"
height="70">](https://apps.apple.com/app/satsprice/id6478230475)
[<img src="./docs/assets/download_on_zapstore.svg"
alt="Get it on Zap Store"
height="70">](https://github.com/zapstore/zapstore/releases)
[<img src="./docs/assets/download_on_obtainium.png"
alt="Get it on Obtaininum"
height="70">](https://github.com/ImranR98/Obtainium)
[<img src="./docs/assets/download_on_github.svg" alt="Get it on GitHub"
height="70">](https://github.com/tyiu/sats-price/releases)
## Supported Platforms
iOS 16.0+ • macOS 13.0+ • Android 10.0+
</div>
## Building
This is a free [Skip](https://skip.tools) dual-platform app project. This is a free [Skip](https://skip.tools) dual-platform app project.
It builds a native app for both iOS and Android. It builds a native app for both iOS and Android.
## Building
This project is both a stand-alone Swift Package Manager module, This project is both a stand-alone Swift Package Manager module,
as well as an Xcode project that builds and transpiles the project as well as an Xcode project that builds and transpiles the project
into a Kotlin Gradle project for Android using the Skip plugin. into a Kotlin Gradle project for Android using the Skip plugin.
@@ -48,3 +77,10 @@ Android Studio's logcat tab for the transpiled Kotlin app.
This project depends on [Skip](https://skip.tools) to build as a multi-platform app. This project depends on [Skip](https://skip.tools) to build as a multi-platform app.
The [Bitcoin Calculator](https://www.flaticon.com/free-icons/bitcoin-calculator) icon was created by Icon home and licensed as free for personal and commercial use with attribution. The [Bitcoin Calculator](https://www.flaticon.com/free-icons/bitcoin-calculator) icon was created by Icon home and licensed as free for personal and commercial use with attribution.
The following free APIs are used:
- Coinbase
- [Get Exchange Rates](https://docs.cdp.coinbase.com/coinbase-app/docs/api-exchange-rates#get-exchange-rates)
- [Get Spot Price](https://docs.cdp.coinbase.com/coinbase-app/docs/api-prices#get-spot-price)
- CoinGecko
- [Coin Price by IDs](https://docs.coingecko.com/reference/simple-price)

View File

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

View File

@@ -33,17 +33,6 @@ public struct ContentView: View {
) )
} }
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 { public var body: some View {
NavigationStack { NavigationStack {
Form { Form {
@@ -96,7 +85,7 @@ public struct ContentView: View {
} }
} footer: { } footer: {
if satsViewModel.exceedsMaximum { if satsViewModel.exceedsMaximum {
Text("21000000 BTC is the maximum.") Text("\(SatsViewModel.MAXIMUM_BTC.formatBTCString()) BTC is the maximum.")
} }
} }

View File

@@ -51,11 +51,11 @@ class CoinbasePriceFetcher : PriceFetcher {
return nil return nil
} }
#if !SKIP #if !SKIP
return Decimal(string: coinbasePrice.amount) return Decimal(string: coinbasePrice.amount)
#else #else
return Decimal(coinbasePrice.amount) return Decimal(coinbasePrice.amount)
#endif #endif
} catch { } catch {
return nil return nil
} }

View File

@@ -14,10 +14,10 @@
} }
} }
}, },
"1 BTC to %@" : { "%@ BTC is the maximum." : {
}, },
"21000000 BTC is the maximum." : { "1 BTC to %@" : {
}, },
"BTC" : { "BTC" : {

View File

@@ -13,6 +13,10 @@ import Foundation
import SwiftUI import SwiftUI
class SatsViewModel: ObservableObject { class SatsViewModel: ObservableObject {
static let MAXIMUM_BTC = Decimal(21000000)
private static let SATS_IN_BTC = Decimal(100000000)
let model: SatsPriceModel let model: SatsPriceModel
@Published var lastUpdated: Date? @Published var lastUpdated: Date?
@@ -25,7 +29,8 @@ class SatsViewModel: ObservableObject {
@Published var selectedCurrencies = Set<Locale.Currency>() @Published var selectedCurrencies = Set<Locale.Currency>()
@Published var currencyValueStrings: [Locale.Currency: String] = [:] @Published var currencyValueStrings: [Locale.Currency: String] = [:]
var currencyPrices: [Locale.Currency: Decimal] = [:] @Published var currencyPrices: [Locale.Currency: Decimal] = [:]
@Published var currencyPriceStrings: [Locale.Currency: String] = [:]
let currentCurrency: Locale.Currency = Locale.current.currency ?? Locale.Currency("USD") let currentCurrency: Locale.Currency = Locale.current.currency ?? Locale.Currency("USD")
@@ -90,6 +95,7 @@ class SatsViewModel: ObservableObject {
let prices = try await priceFetcherDelegator.convertBTC(toCurrencies: Array(currencies)) let prices = try await priceFetcherDelegator.convertBTC(toCurrencies: Array(currencies))
currencyPrices = prices currencyPrices = prices
updateCurrencyPriceStrings()
updateCurrencyValueStrings() updateCurrencyValueStrings()
} catch { } catch {
clearCurrencyValueStrings() clearCurrencyValueStrings()
@@ -97,24 +103,49 @@ class SatsViewModel: ObservableObject {
lastUpdated = Date.now lastUpdated = Date.now
} }
func updateCurrencyPriceStrings() {
currencyPriceStrings = Dictionary(
uniqueKeysWithValues: currencyPrices.map { ($0.key, $0.value.formatString(currency: $0.key)) }
)
}
private func priceWithoutGroupingSeparator(_ priceString: String) -> String {
let numberFormatter = NumberFormatter()
numberFormatter.numberStyle = .decimal
let decimalSeparator = numberFormatter.decimalSeparator
return priceString.filter {
$0.isDigit || String($0) == decimalSeparator
}
}
var satsString: String { var satsString: String {
get { get {
satsStringInternal satsStringInternal
} }
set { set {
guard satsStringInternal != newValue else { let oldPriceWithoutGroupingSeparator = priceWithoutGroupingSeparator(satsStringInternal)
let newPriceWithoutGroupingSeparator = priceWithoutGroupingSeparator(newValue)
guard oldPriceWithoutGroupingSeparator != newPriceWithoutGroupingSeparator else {
return return
} }
satsStringInternal = newValue satsStringInternal = newPriceWithoutGroupingSeparator
if let sats { if let sats {
#if !SKIP #if !SKIP
let btc = sats / Decimal(100000000) // Formatting the internal string after modifying it only if the platform is Apple.
// Apple does not seem to call get after set until after focus is moved to a different component.
// Android, on the other hand, does call get immediately after set,
// which causes text entry issues if the user keeps on entering input.
satsStringInternal = sats.formatSatsString()
let btc = sats / SatsViewModel.SATS_IN_BTC
#else #else
let btc = sats.divide(Decimal(100000000), 20, java.math.RoundingMode.DOWN) let btc = sats.divide(SatsViewModel.SATS_IN_BTC, 20, java.math.RoundingMode.DOWN)
#endif #endif
btcStringInternal = btc.formatString() btcStringInternal = btc.formatBTCString()
updateCurrencyValueStrings() updateCurrencyValueStrings()
} else { } else {
@@ -129,15 +160,26 @@ class SatsViewModel: ObservableObject {
btcStringInternal btcStringInternal
} }
set { set {
guard btcStringInternal != newValue else { let oldPriceWithoutGroupingSeparator = priceWithoutGroupingSeparator(btcStringInternal)
let newPriceWithoutGroupingSeparator = priceWithoutGroupingSeparator(newValue)
guard oldPriceWithoutGroupingSeparator != newPriceWithoutGroupingSeparator else {
return return
} }
btcStringInternal = newValue btcStringInternal = newPriceWithoutGroupingSeparator
if let btc { if let btc {
let sats = btc * Decimal(100000000) #if !SKIP
satsStringInternal = sats.formatString() // Formatting the internal string after modifying it only if the platform is Apple.
// Apple does not seem to call get after set until after focus is moved to a different component.
// Android, on the other hand, does call get immediately after set,
// which causes text entry issues if the user keeps on entering input.
btcStringInternal = btc.formatBTCString()
#endif
let sats = btc * SatsViewModel.SATS_IN_BTC
satsStringInternal = sats.formatSatsString()
updateCurrencyValueStrings() updateCurrencyValueStrings()
} else { } else {
@@ -154,7 +196,7 @@ class SatsViewModel: ObservableObject {
for currency in currencies { for currency in currencies {
if let btcToCurrency = btcToCurrency(for: currency) { if let btcToCurrency = btcToCurrency(for: currency) {
currencyValueStrings[currency] = (btc * btcToCurrency).formatString() currencyValueStrings[currency] = (btc * btcToCurrency).formatString(currency: currency)
} else { } else {
currencyValueStrings[currency] = "" currencyValueStrings[currency] = ""
} }
@@ -176,25 +218,36 @@ class SatsViewModel: ObservableObject {
self.currencyValueStrings[currency, default: ""] self.currencyValueStrings[currency, default: ""]
}, },
set: { newValue in set: { newValue in
guard self.currencyValueStrings[currency] != newValue else { let oldPriceWithoutGroupingSeparator = self.priceWithoutGroupingSeparator(self.currencyValueStrings[currency] ?? "")
let newPriceWithoutGroupingSeparator = self.priceWithoutGroupingSeparator(newValue)
guard oldPriceWithoutGroupingSeparator != newPriceWithoutGroupingSeparator else {
return return
} }
self.currencyValueStrings[currency] = newValue self.currencyValueStrings[currency] = newPriceWithoutGroupingSeparator
if let currencyValue = self.currencyValue(for: currency) { if let currencyValue = self.currencyValue(for: currency) {
if let btcToCurrency = self.currencyPrices[currency] { if let btcToCurrency = self.currencyPrices[currency] {
#if !SKIP #if !SKIP
let btc = currencyValue / btcToCurrency let btc = currencyValue / btcToCurrency
#else #else
let btc = currencyValue.divide(btcToCurrency, 20, java.math.RoundingMode.DOWN) let btc = currencyValue.divide(btcToCurrency, 20, java.math.RoundingMode.DOWN)
#endif #endif
self.btcStringInternal = btc.formatString() self.btcStringInternal = btc.formatBTCString()
let sats = btc * Decimal(100000000) let sats = btc * SatsViewModel.SATS_IN_BTC
self.satsStringInternal = sats.formatString() self.satsStringInternal = sats.formatSatsString()
#if !SKIP
// Formatting the internal string after modifying it only if the platform is Apple.
// Apple does not seem to call get after set until after focus is moved to a different component.
// Android, on the other hand, does call get immediately after set,
// which causes text entry issues if the user keeps on entering input.
self.updateCurrencyValueStrings(excludedCurrency: nil)
#else
self.updateCurrencyValueStrings(excludedCurrency: currency) self.updateCurrencyValueStrings(excludedCurrency: currency)
#endif
} else { } else {
self.satsStringInternal = "" self.satsStringInternal = ""
self.btcStringInternal = "" self.btcStringInternal = ""
@@ -215,7 +268,7 @@ class SatsViewModel: ObservableObject {
} }
#if !SKIP #if !SKIP
return Decimal(string: currencyValueString) return Decimal(string: priceWithoutGroupingSeparator(currencyValueString))
#else #else
do { do {
return Decimal(currencyValueString) return Decimal(currencyValueString)
@@ -232,23 +285,38 @@ class SatsViewModel: ObservableObject {
func btcToCurrencyString(for currency: Locale.Currency) -> Binding<String> { func btcToCurrencyString(for currency: Locale.Currency) -> Binding<String> {
Binding<String>( Binding<String>(
get: { get: {
self.currencyPrices[currency]?.formatString() ?? "" self.currencyPriceStrings[currency, default: ""]
}, },
set: { newValue in set: { newValue in
let oldPriceWithoutGroupingSeparator = self.priceWithoutGroupingSeparator(self.currencyPriceStrings[currency, default: ""])
let newPriceWithoutGroupingSeparator = self.priceWithoutGroupingSeparator(newValue)
guard oldPriceWithoutGroupingSeparator != newPriceWithoutGroupingSeparator else {
return
}
self.currencyPriceStrings[currency] = newPriceWithoutGroupingSeparator
#if !SKIP #if !SKIP
if let newPrice = Decimal(string: newValue), self.currencyPrices[currency] != newPrice { if let newPrice = Decimal(string: newPriceWithoutGroupingSeparator), self.currencyPrices[currency] != newPrice {
self.currencyPrices[currency] = Decimal(string: newValue) self.currencyPrices[currency] = newPrice
// Formatting the internal string after modifying it only if the platform is Apple.
// Apple does not seem to call get after set until after focus is moved to a different
// component. Android, on the other hand, does call get immediately after set,
// which causes text entry issues if the user keeps on entering input.
self.currencyPriceStrings[currency] = newPrice.formatString(currency: currency)
if let btc = self.btc { if let btc = self.btc {
self.currencyValueStrings[currency] = (btc * newPrice).formatString() self.currencyValueStrings[currency] = (btc * newPrice).formatString(currency: currency)
} else { } else {
self.currencyValueStrings[currency] = "" self.currencyValueStrings[currency] = ""
} }
} }
#else #else
do { do {
if let newPrice = Decimal(newValue), self.currencyPrices[currency] != newPrice { if let newPrice = Decimal(newPriceWithoutGroupingSeparator), self.currencyPrices[currency] != newPrice {
self.currencyPrices[currency] = Decimal(newValue) self.currencyPrices[currency] = newPrice
if let btc = self.btc { if let btc = self.btc {
self.currencyValueStrings[currency] = (btc * newPrice).formatString() self.currencyValueStrings[currency] = (btc * newPrice).formatString()
@@ -265,11 +333,12 @@ class SatsViewModel: ObservableObject {
} }
var sats: Decimal? { var sats: Decimal? {
let priceWithoutGroupingSeparator = priceWithoutGroupingSeparator(satsStringInternal)
#if !SKIP #if !SKIP
return Decimal(string: satsStringInternal) return Decimal(string: priceWithoutGroupingSeparator)
#else #else
do { do {
return Decimal(satsStringInternal) return Decimal(priceWithoutGroupingSeparator)
} catch { } catch {
return nil return nil
} }
@@ -277,11 +346,12 @@ class SatsViewModel: ObservableObject {
} }
var btc: Decimal? { var btc: Decimal? {
let priceWithoutGroupingSeparator = priceWithoutGroupingSeparator(btcStringInternal)
#if !SKIP #if !SKIP
return Decimal(string: btcStringInternal) return Decimal(string: priceWithoutGroupingSeparator)
#else #else
do { do {
return Decimal(btcStringInternal) return Decimal(priceWithoutGroupingSeparator)
} catch { } catch {
return nil return nil
} }
@@ -289,7 +359,7 @@ class SatsViewModel: ObservableObject {
} }
var exceedsMaximum: Bool { var exceedsMaximum: Bool {
if let btc, btc > Decimal(21000000) { if let btc, btc > SatsViewModel.MAXIMUM_BTC {
return true return true
} }
return false return false
@@ -302,6 +372,54 @@ extension Decimal {
return String(describing: self) return String(describing: self)
#else #else
return stripTrailingZeros().toPlainString() return stripTrailingZeros().toPlainString()
#endif
}
func formatString(minimumFractionDigits: Int, maximumFractionDigits: Int, usesGroupingSeparator: Bool) -> String {
let numberFormatter = NumberFormatter()
numberFormatter.numberStyle = .decimal
numberFormatter.minimumFractionDigits = minimumFractionDigits
numberFormatter.maximumFractionDigits = maximumFractionDigits
numberFormatter.usesGroupingSeparator = usesGroupingSeparator
#if !SKIP
return numberFormatter.string(from: NSDecimalNumber(decimal: self)) ?? String(describing: self)
#else
return numberFormatter.string(from: android.icu.math.BigDecimal(self as java.math.BigDecimal) as NSNumber) ?? stripTrailingZeros().toPlainString()
#endif
}
func formatSatsString() -> String {
formatString(minimumFractionDigits: 0, maximumFractionDigits: 0, usesGroupingSeparator: true)
}
func formatBTCString() -> String {
formatString(minimumFractionDigits: 0, maximumFractionDigits: 8, usesGroupingSeparator: true)
}
func formatString(currency: Locale.Currency) -> String {
#if !SKIP
let currencyFormatter = NumberFormatter()
currencyFormatter.numberStyle = .currency
currencyFormatter.currencyCode = currency.identifier
return formatString(
minimumFractionDigits: currencyFormatter.minimumFractionDigits,
maximumFractionDigits: currencyFormatter.maximumFractionDigits,
usesGroupingSeparator: currencyFormatter.usesGroupingSeparator
)
#else
let javaCurrency = java.util.Currency.getInstance(currency.identifier)
return formatString(
minimumFractionDigits: javaCurrency.getDefaultFractionDigits(),
maximumFractionDigits: javaCurrency.getDefaultFractionDigits(),
usesGroupingSeparator: true
)
#endif #endif
} }
} }
private extension Character {
var isDigit: Bool {
self >= "0" && self <= "9"
}
}

View File

@@ -16,8 +16,9 @@ final class SatsViewModelTests: XCTestCase {
let currency = Locale.Currency("USD") let currency = Locale.Currency("USD")
func testSatsViewModel() { func testSatsViewModel() throws {
let satsViewModel = SatsViewModel() let satsPriceModel = try XCTUnwrap(SatsPriceModel(url: nil))
let satsViewModel = SatsViewModel(model: satsPriceModel)
satsViewModel.btcToCurrencyString(for: currency).wrappedValue = "54321" satsViewModel.btcToCurrencyString(for: currency).wrappedValue = "54321"
// Test BTC updates. // Test BTC updates.
@@ -32,8 +33,8 @@ final class SatsViewModelTests: XCTestCase {
XCTAssertEqual(satsViewModel.currencyValue(for: currency), 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, "100,000,000")
XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "54321") XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "54,321.00")
// Test Sats updates. // Test Sats updates.
satsViewModel.satsString = "200000000" satsViewModel.satsString = "200000000"
@@ -47,8 +48,8 @@ final class SatsViewModelTests: XCTestCase {
XCTAssertEqual(satsViewModel.currencyValue(for: currency), 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, "200,000,000")
XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "108642") XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "108,642.00")
// Test currency value updates. // Test currency value updates.
satsViewModel.currencyValueString(for: currency).wrappedValue = "162963" satsViewModel.currencyValueString(for: currency).wrappedValue = "162963"
@@ -62,44 +63,43 @@ final class SatsViewModelTests: XCTestCase {
XCTAssertEqual(satsViewModel.currencyValue(for: currency), 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, "300,000,000")
XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "162963") XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "162,963.00")
// 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(for: currency).wrappedValue = "1" satsViewModel.currencyValueString(for: currency).wrappedValue = "1"
#if !SKIP #if !SKIP
XCTAssertEqual(satsViewModel.btc, Decimal(string: "0.00001840908672520756245282671526665562")) XCTAssertEqual(satsViewModel.btc, Decimal(string: "0.00001841"))
XCTAssertEqual(satsViewModel.btcString, "0.00001840908672520756245282671526665562") XCTAssertEqual(satsViewModel.btcString, "0.00001841")
XCTAssertEqual(satsViewModel.sats, Decimal(string: "1840.908672520756245282671526665562")) XCTAssertEqual(satsViewModel.sats, Decimal(string: "1841"))
XCTAssertEqual(satsViewModel.satsString, "1840.908672520756245282671526665562") XCTAssertEqual(satsViewModel.satsString, "1,841")
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "1")) XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "1"))
#else #else
XCTAssertEqual(satsViewModel.btc, Decimal("0.00001840908672520756")) XCTAssertEqual(satsViewModel.btc, Decimal("0.00001841"))
XCTAssertEqual(satsViewModel.btcString, "0.00001840908672520756") XCTAssertEqual(satsViewModel.btcString, "0.00001841")
XCTAssertEqual(satsViewModel.sats, Decimal("1840.908672520756")) XCTAssertEqual(satsViewModel.sats, Decimal("1841"))
XCTAssertEqual(satsViewModel.satsString, "1840.908672520756") XCTAssertEqual(satsViewModel.satsString, "1,841")
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal("1")) XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal("1"))
#endif #endif
XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "1") XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "1.00")
// 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.
satsViewModel.currencyValueString(for: currency).wrappedValue = "11407419999999" satsViewModel.currencyValueString(for: currency).wrappedValue = "11407419999999"
#if !SKIP #if !SKIP
XCTAssertEqual(satsViewModel.btc, Decimal(string: "210000184.09084884298889932070469983984")) XCTAssertEqual(satsViewModel.btc, Decimal(string: "210000184.09084884"))
XCTAssertEqual(satsViewModel.btcString, "210000184.09084884298889932070469983984") XCTAssertEqual(satsViewModel.btcString, "210,000,184.09084884")
XCTAssertEqual(satsViewModel.sats, Decimal(string: "21000018409084884.298889932070469983984")) XCTAssertEqual(satsViewModel.sats, Decimal(string: "21000018409084884"))
XCTAssertEqual(satsViewModel.satsString, "21000018409084884.298889932070469983984") XCTAssertEqual(satsViewModel.satsString, "21,000,018,409,084,884")
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "11407419999999")) XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "11407419999999"))
#else #else
XCTAssertEqual(satsViewModel.btc, Decimal("210000184.0908488429888993207")) XCTAssertEqual(satsViewModel.btc, Decimal("210000184.09084884"))
XCTAssertEqual(satsViewModel.btcString, "210000184.0908488429888993207") XCTAssertEqual(satsViewModel.btcString, "210000184.09084884")
XCTAssertEqual(satsViewModel.sats, Decimal("21000018409084884.29888993207")) XCTAssertEqual(satsViewModel.sats, Decimal("21000018409084884"))
XCTAssertEqual(satsViewModel.satsString, "21000018409084884.29888993207") XCTAssertEqual(satsViewModel.satsString, "21000018409084884")
XCTAssertEqual(satsViewModel.currencyValue, Decimal("11407419999999")) XCTAssertEqual(satsViewModel.currencyValue, Decimal("11407419999999"))
#endif #endif
XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "11407419999999") XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "11,407,419,999,999.00")
} }
} }

View File

@@ -0,0 +1,46 @@
<svg id="livetype" xmlns="http://www.w3.org/2000/svg" width="119.66407" height="40" viewBox="0 0 119.66407 40">
<title>Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917</title>
<g>
<g>
<g>
<path d="M110.13477,0H9.53468c-.3667,0-.729,0-1.09473.002-.30615.002-.60986.00781-.91895.0127A13.21476,13.21476,0,0,0,5.5171.19141a6.66509,6.66509,0,0,0-1.90088.627A6.43779,6.43779,0,0,0,1.99757,1.99707,6.25844,6.25844,0,0,0,.81935,3.61816a6.60119,6.60119,0,0,0-.625,1.90332,12.993,12.993,0,0,0-.1792,2.002C.00587,7.83008.00489,8.1377,0,8.44434V31.5586c.00489.3105.00587.6113.01515.9219a12.99232,12.99232,0,0,0,.1792,2.0019,6.58756,6.58756,0,0,0,.625,1.9043A6.20778,6.20778,0,0,0,1.99757,38.001a6.27445,6.27445,0,0,0,1.61865,1.1787,6.70082,6.70082,0,0,0,1.90088.6308,13.45514,13.45514,0,0,0,2.0039.1768c.30909.0068.6128.0107.91895.0107C8.80567,40,9.168,40,9.53468,40H110.13477c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107a13.279,13.279,0,0,0,2-.1768,6.80432,6.80432,0,0,0,1.9082-.6308,6.27742,6.27742,0,0,0,1.6172-1.1787,6.39482,6.39482,0,0,0,1.1816-1.6143,6.60413,6.60413,0,0,0,.6191-1.9043,13.50643,13.50643,0,0,0,.1856-2.0019c.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V9.53613c0-.36621,0-.72949-.0078-1.09179,0-.30664,0-.61426-.0039-.9209a13.5071,13.5071,0,0,0-.1856-2.002,6.6177,6.6177,0,0,0-.6191-1.90332,6.46619,6.46619,0,0,0-2.7988-2.7998,6.76754,6.76754,0,0,0-1.9082-.627,13.04394,13.04394,0,0,0-2-.17676c-.3047-.00488-.6172-.01074-.9219-.01269-.3594-.002-.7246-.002-1.084-.002Z" style="fill: #a6a6a6"/>
<path d="M8.44483,39.125c-.30468,0-.602-.0039-.90429-.0107a12.68714,12.68714,0,0,1-1.86914-.1631,5.88381,5.88381,0,0,1-1.65674-.5479,5.40573,5.40573,0,0,1-1.397-1.0166,5.32082,5.32082,0,0,1-1.02051-1.3965,5.72186,5.72186,0,0,1-.543-1.6572,12.41351,12.41351,0,0,1-.1665-1.875c-.00634-.2109-.01464-.9131-.01464-.9131V8.44434S.88185,7.75293.8877,7.5498a12.37039,12.37039,0,0,1,.16553-1.87207,5.7555,5.7555,0,0,1,.54346-1.6621A5.37349,5.37349,0,0,1,2.61183,2.61768,5.56543,5.56543,0,0,1,4.01417,1.59521a5.82309,5.82309,0,0,1,1.65332-.54394A12.58589,12.58589,0,0,1,7.543.88721L8.44532.875H111.21387l.9131.0127a12.38493,12.38493,0,0,1,1.8584.16259,5.93833,5.93833,0,0,1,1.6709.54785,5.59374,5.59374,0,0,1,2.415,2.41993,5.76267,5.76267,0,0,1,.5352,1.64892,12.995,12.995,0,0,1,.1738,1.88721c.0029.2832.0029.5874.0029.89014.0079.375.0079.73193.0079,1.09179V30.4648c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297a12.73126,12.73126,0,0,1-.1709,1.8535,5.739,5.739,0,0,1-.54,1.67,5.48029,5.48029,0,0,1-1.0156,1.3857,5.4129,5.4129,0,0,1-1.3994,1.0225,5.86168,5.86168,0,0,1-1.668.5498,12.54218,12.54218,0,0,1-1.8692.1631c-.2929.0068-.5996.0107-.8974.0107l-1.084.002Z"/>
</g>
<g id="_Group_" data-name="&lt;Group&gt;">
<g id="_Group_2" data-name="&lt;Group&gt;">
<g id="_Group_3" data-name="&lt;Group&gt;">
<path id="_Path_" data-name="&lt;Path&gt;" d="M24.76888,20.30068a4.94881,4.94881,0,0,1,2.35656-4.15206,5.06566,5.06566,0,0,0-3.99116-2.15768c-1.67924-.17626-3.30719,1.00483-4.1629,1.00483-.87227,0-2.18977-.98733-3.6085-.95814a5.31529,5.31529,0,0,0-4.47292,2.72787c-1.934,3.34842-.49141,8.26947,1.3612,10.97608.9269,1.32535,2.01018,2.8058,3.42763,2.7533,1.38706-.05753,1.9051-.88448,3.5794-.88448,1.65876,0,2.14479.88448,3.591.8511,1.48838-.02416,2.42613-1.33124,3.32051-2.66914a10.962,10.962,0,0,0,1.51842-3.09251A4.78205,4.78205,0,0,1,24.76888,20.30068Z" style="fill: #fff"/>
<path id="_Path_2" data-name="&lt;Path&gt;" d="M22.03725,12.21089a4.87248,4.87248,0,0,0,1.11452-3.49062,4.95746,4.95746,0,0,0-3.20758,1.65961,4.63634,4.63634,0,0,0-1.14371,3.36139A4.09905,4.09905,0,0,0,22.03725,12.21089Z" style="fill: #fff"/>
</g>
</g>
<g>
<path d="M42.30227,27.13965h-4.7334l-1.13672,3.35645H34.42727l4.4834-12.418h2.083l4.4834,12.418H43.438ZM38.0591,25.59082h3.752l-1.84961-5.44727h-.05176Z" style="fill: #fff"/>
<path d="M55.15969,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H48.4302v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C53.645,21.34766,55.15969,23.16406,55.15969,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C52.30227,29.01563,53.24953,27.81934,53.24953,25.96973Z" style="fill: #fff"/>
<path d="M65.12453,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H58.395v1.50586h.03418A3.21162,3.21162,0,0,1,61.312,21.34766C63.60988,21.34766,65.12453,23.16406,65.12453,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C62.26711,29.01563,63.21438,27.81934,63.21438,25.96973Z" style="fill: #fff"/>
<path d="M71.71047,27.03613c.1377,1.23145,1.334,2.04,2.96875,2.04,1.56641,0,2.69336-.80859,2.69336-1.91895,0-.96387-.67969-1.541-2.28906-1.93652l-1.60937-.3877c-2.28027-.55078-3.33887-1.61719-3.33887-3.34766,0-2.14258,1.86719-3.61426,4.51855-3.61426,2.624,0,4.42285,1.47168,4.4834,3.61426h-1.876c-.1123-1.23926-1.13672-1.9873-2.63379-1.9873s-2.52148.75684-2.52148,1.8584c0,.87793.6543,1.39453,2.25488,1.79l1.36816.33594c2.54785.60254,3.60645,1.626,3.60645,3.44238,0,2.32324-1.85059,3.77832-4.79395,3.77832-2.75391,0-4.61328-1.4209-4.7334-3.667Z" style="fill: #fff"/>
<path d="M83.34621,19.2998v2.14258h1.72168v1.47168H83.34621v4.99121c0,.77539.34473,1.13672,1.10156,1.13672a5.80752,5.80752,0,0,0,.61133-.043v1.46289a5.10351,5.10351,0,0,1-1.03223.08594c-1.833,0-2.54785-.68848-2.54785-2.44434V22.91406H80.16262V21.44238H81.479V19.2998Z" style="fill: #fff"/>
<path d="M86.065,25.96973c0-2.84863,1.67773-4.63867,4.29395-4.63867,2.625,0,4.29492,1.79,4.29492,4.63867,0,2.85645-1.66113,4.63867-4.29492,4.63867C87.72609,30.6084,86.065,28.82617,86.065,25.96973Zm6.69531,0c0-1.9541-.89551-3.10742-2.40137-3.10742s-2.40039,1.16211-2.40039,3.10742c0,1.96191.89453,3.10645,2.40039,3.10645S92.76027,27.93164,92.76027,25.96973Z" style="fill: #fff"/>
<path d="M96.18606,21.44238h1.77246v1.541h.043a2.1594,2.1594,0,0,1,2.17773-1.63574,2.86616,2.86616,0,0,1,.63672.06934v1.73828a2.59794,2.59794,0,0,0-.835-.1123,1.87264,1.87264,0,0,0-1.93652,2.083v5.37012h-1.8584Z" style="fill: #fff"/>
<path d="M109.3843,27.83691c-.25,1.64355-1.85059,2.77148-3.89844,2.77148-2.63379,0-4.26855-1.76465-4.26855-4.5957,0-2.83984,1.64355-4.68164,4.19043-4.68164,2.50488,0,4.08008,1.7207,4.08008,4.46582v.63672h-6.39453v.1123a2.358,2.358,0,0,0,2.43555,2.56445,2.04834,2.04834,0,0,0,2.09082-1.27344Zm-6.28223-2.70215h4.52637a2.1773,2.1773,0,0,0-2.2207-2.29785A2.292,2.292,0,0,0,103.10207,25.13477Z" style="fill: #fff"/>
</g>
</g>
</g>
<g id="_Group_4" data-name="&lt;Group&gt;">
<g>
<path d="M37.82619,8.731a2.63964,2.63964,0,0,1,2.80762,2.96484c0,1.90625-1.03027,3.002-2.80762,3.002H35.67092V8.731Zm-1.22852,5.123h1.125a1.87588,1.87588,0,0,0,1.96777-2.146,1.881,1.881,0,0,0-1.96777-2.13379h-1.125Z" style="fill: #fff"/>
<path d="M41.68068,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C44.57522,13.99463,45.01369,13.42432,45.01369,12.44434Z" style="fill: #fff"/>
<path d="M51.57326,14.69775h-.92187l-.93066-3.31641h-.07031l-.92676,3.31641h-.91309l-1.24121-4.50293h.90137l.80664,3.436h.06641l.92578-3.436h.85254l.92578,3.436h.07031l.80273-3.436h.88867Z" style="fill: #fff"/>
<path d="M53.85354,10.19482H54.709v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915h-.88867V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M59.09377,8.437h.88867v6.26074h-.88867Z" style="fill: #fff"/>
<path d="M61.21779,12.44434a2.13346,2.13346,0,1,1,4.24756,0,2.1338,2.1338,0,1,1-4.24756,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C64.11232,13.99463,64.5508,13.42432,64.5508,12.44434Z" style="fill: #fff"/>
<path d="M66.4009,13.42432c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031v-.38867c0-.47559-.31445-.74414-.92187-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76563.562,1.76563,1.51318v3.07666h-.85547v-.63281h-.07031a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,66.4009,13.42432Zm2.89453-.38477v-.37646l-1.09961.07031c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,69.29543,13.03955Z" style="fill: #fff"/>
<path d="M71.34816,12.44434c0-1.42285.73145-2.32422,1.86914-2.32422a1.484,1.484,0,0,1,1.38086.79h.06641V8.437h.88867v6.26074h-.85156v-.71143h-.07031a1.56284,1.56284,0,0,1-1.41406.78564C72.0718,14.772,71.34816,13.87061,71.34816,12.44434Zm.918,0c0,.95508.4502,1.52979,1.20313,1.52979.749,0,1.21191-.583,1.21191-1.52588,0-.93848-.46777-1.52979-1.21191-1.52979C72.72121,10.91846,72.26613,11.49707,72.26613,12.44434Z" style="fill: #fff"/>
<path d="M79.23,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C82.12453,13.99463,82.563,13.42432,82.563,12.44434Z" style="fill: #fff"/>
<path d="M84.66945,10.19482h.85547v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915H87.605V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M93.51516,9.07373v1.1416h.97559v.74854h-.97559V13.2793c0,.47168.19434.67822.63672.67822a2.96657,2.96657,0,0,0,.33887-.02051v.74023a2.9155,2.9155,0,0,1-.4834.04541c-.98828,0-1.38184-.34766-1.38184-1.21582v-2.543h-.71484v-.74854h.71484V9.07373Z" style="fill: #fff"/>
<path d="M95.70461,8.437h.88086v2.48145h.07031a1.3856,1.3856,0,0,1,1.373-.80664,1.48339,1.48339,0,0,1,1.55078,1.67871v2.90723H98.69v-2.688c0-.71924-.335-1.0835-.96289-1.0835a1.05194,1.05194,0,0,0-1.13379,1.1416v2.62988h-.88867Z" style="fill: #fff"/>
<path d="M104.76125,13.48193a1.828,1.828,0,0,1-1.95117,1.30273A2.04531,2.04531,0,0,1,100.73,12.46045a2.07685,2.07685,0,0,1,2.07617-2.35254c1.25293,0,2.00879.856,2.00879,2.27V12.688h-3.17969v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,101.63527,12.03076Z" style="fill: #fff"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1 @@
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg>

After

Width:  |  Height:  |  Size: 963 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

7
zapstore.yaml Normal file
View File

@@ -0,0 +1,7 @@
satsprice:
android:
name: SatsPrice
description: SatsPrice fetches the price of Bitcoin relative to common fiat currencies from multiple sources, and converts inputted amounts between Sats, BTC, and the selected fiat currency.
repository: https://github.com/tyiu/sats-price
artifacts:
- SatsPrice-v%v.apk