14 Commits

25 changed files with 1155 additions and 226 deletions

View File

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

View File

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

View File

@@ -199,26 +199,30 @@
499CD4422AC5B799001AE8D8 /* Debug */ = { 499CD4422AC5B799001AE8D8 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CURRENT_PROJECT_VERSION = 10;
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.2.0;
}; };
name = Debug; name = Debug;
}; };
499CD4432AC5B799001AE8D8 /* Release */ = { 499CD4432AC5B799001AE8D8 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CURRENT_PROJECT_VERSION = 10;
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.2.0;
}; };
name = Release; name = Release;
}; };

View File

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

View File

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

@@ -6,71 +6,53 @@ 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)"
} catch {
satsViewModel.btcToCurrencyString = ""
}
satsViewModel.lastUpdated = Date.now
} }
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,62 +60,70 @@ 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")
HStack {
Text("BTC")
TextField("BTC", text: $satsViewModel.btcString)
#if os(iOS) || SKIP
.keyboardType(.decimalPad)
#endif
}
} footer: { } footer: {
if satsViewModel.exceedsMaximum { if satsViewModel.exceedsMaximum {
Text("2100000000000000 sats is the maximum.") Text("\(SatsViewModel.MAXIMUM_BTC.formatBTCString()) BTC is the maximum.")
} }
} }
Section { Section {
TextField("BTC", text: $satsViewModel.btcString) 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("BTC")
} footer: { if satsViewModel.priceSource != .manual {
if satsViewModel.exceedsMaximum { ForEach(satsViewModel.selectedCurrencies.sorted { $0.identifier < $1.identifier }.filter { $0 != satsViewModel.currentCurrency }, id: \.identifier) { currency in
Text("21000000 BTC is the maximum.") HStack {
Text(currency.identifier)
TextField(currency.identifier, text: satsViewModel.currencyValueString(for: currency))
#if os(iOS) || SKIP
.keyboardType(.decimalPad)
#endif
}
.tag(currency.identifier)
}
} }
} }
Section { if satsViewModel.priceSource != .manual {
TextField(satsViewModel.selectedCurrency.identifier, text: $satsViewModel.currencyValueString) addCurrencyView
#if os(iOS) || SKIP
.keyboardType(.decimalPad)
#endif
} header: {
Text(satsViewModel.selectedCurrency.identifier)
} }
} }
.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 +134,6 @@ public struct ContentView: View {
} }
#Preview { #Preview {
#if DEBUG let satsPriceModel = try! SatsPriceModel(url: nil)
ContentView(.fake) ContentView(model: satsPriceModel)
#else
ContentView(.coinbase)
#endif
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,20 +13,23 @@
} }
} }
} }
},
"%@ BTC is the maximum." : {
}, },
"1 BTC to %@" : { "1 BTC to %@" : {
},
"21000000 BTC is the maximum." : {
},
"2100000000000000 sats is the maximum." : {
}, },
"BTC" : { "BTC" : {
}, },
"Currency" : { "Change Currencies" : {
},
"Currencies" : {
},
"Current Currency" : {
}, },
"Last updated: %@" : { "Last updated: %@" : {
@@ -37,6 +40,9 @@
}, },
"Sats" : { "Sats" : {
},
"Selected Currencies" : {
} }
}, },
"version" : "1.0" "version" : "1.0"

View File

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

View File

@@ -13,17 +13,33 @@ 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
@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] = [:]
@Published var currencyPrices: [Locale.Currency: Decimal] = [:]
@Published var currencyPriceStrings: [Locale.Currency: String] = [:]
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,22 +50,72 @@ 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 @MainActor
func updatePrice() async {
do {
let currencies = Set([currentCurrency] + selectedCurrencies)
let prices = try await priceFetcherDelegator.convertBTC(toCurrencies: Array(currencies))
if let btc, let btcToCurrency { currencyPrices = prices
currencyValueStringInternal = (btc * btcToCurrency).formatString() updateCurrencyPriceStrings()
} else { updateCurrencyValueStrings()
currencyValueStringInternal = "" } catch {
} clearCurrencyValueStrings()
}
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
} }
} }
@@ -58,27 +124,33 @@ class SatsViewModel: ObservableObject {
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()
if let btcToCurrency {
currencyValueStringInternal = (btc * btcToCurrency).formatString() updateCurrencyValueStrings()
} else {
currencyValueStringInternal = ""
}
} else { } else {
btcStringInternal = "" btcStringInternal = ""
currencyValueStringInternal = "" clearCurrencyValueStrings()
} }
} }
} }
@@ -88,81 +160,185 @@ 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)
satsStringInternal = sats.formatString()
if let btcToCurrency {
currencyValueStringInternal = (btc * btcToCurrency).formatString()
} else {
currencyValueStringInternal = ""
}
} else {
satsStringInternal = ""
currencyValueStringInternal = ""
}
}
}
var currencyValueString: String {
get {
currencyValueStringInternal
}
set {
guard currencyValueStringInternal != newValue else {
return
}
currencyValueStringInternal = newValue
if let currencyValue {
if let btcToCurrency {
#if !SKIP #if !SKIP
let btc = currencyValue / btcToCurrency // Formatting the internal string after modifying it only if the platform is Apple.
#else // Apple does not seem to call get after set until after focus is moved to a different component.
let btc = currencyValue.divide(btcToCurrency, 20, java.math.RoundingMode.DOWN) // 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 #endif
btcStringInternal = btc.formatString()
let sats = btc * Decimal(100000000) let sats = btc * SatsViewModel.SATS_IN_BTC
satsStringInternal = sats.formatString() satsStringInternal = sats.formatSatsString()
} else {
satsStringInternal = "" updateCurrencyValueStrings()
btcStringInternal = ""
currencyValueStringInternal = ""
}
} else { } else {
satsStringInternal = "" satsStringInternal = ""
btcStringInternal = "" clearCurrencyValueStrings()
currencyValueStringInternal = ""
} }
} }
} }
var btcToCurrency: Decimal? { func updateCurrencyValueStrings(excludedCurrency: Locale.Currency? = nil) {
if let btc {
let currencies = Set([currentCurrency] + selectedCurrencies)
.filter { $0 != excludedCurrency }
for currency in currencies {
if let btcToCurrency = btcToCurrency(for: currency) {
currencyValueStrings[currency] = (btc * btcToCurrency).formatString(currency: currency)
} else {
currencyValueStrings[currency] = ""
}
}
} else {
clearCurrencyValueStrings()
}
}
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
let oldPriceWithoutGroupingSeparator = self.priceWithoutGroupingSeparator(self.currencyValueStrings[currency] ?? "")
let newPriceWithoutGroupingSeparator = self.priceWithoutGroupingSeparator(newValue)
guard oldPriceWithoutGroupingSeparator != newPriceWithoutGroupingSeparator else {
return
}
self.currencyValueStrings[currency] = newPriceWithoutGroupingSeparator
if let currencyValue = self.currencyValue(for: currency) {
if let btcToCurrency = self.currencyPrices[currency] {
#if !SKIP #if !SKIP
return Decimal(string: btcToCurrencyStringInternal) let btc = currencyValue / btcToCurrency
#else
let btc = currencyValue.divide(btcToCurrency, 20, java.math.RoundingMode.DOWN)
#endif
self.btcStringInternal = btc.formatBTCString()
let sats = btc * SatsViewModel.SATS_IN_BTC
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)
#endif
} else {
self.satsStringInternal = ""
self.btcStringInternal = ""
self.clearCurrencyValueStrings()
}
} else {
self.satsStringInternal = ""
self.btcStringInternal = ""
self.clearCurrencyValueStrings()
}
}
)
}
func currencyValue(for currency: Locale.Currency) -> Decimal? {
guard let currencyValueString = currencyValueStrings[currency] else {
return nil
}
#if !SKIP
return Decimal(string: priceWithoutGroupingSeparator(currencyValueString))
#else #else
do { do {
return Decimal(btcToCurrencyStringInternal) return Decimal(currencyValueString)
} catch { } catch {
return nil return nil
} }
#endif #endif
} }
var sats: Decimal? { func btcToCurrency(for currency: Locale.Currency) -> Decimal? {
currencyPrices[currency]
}
func btcToCurrencyString(for currency: Locale.Currency) -> Binding<String> {
Binding<String>(
get: {
self.currencyPriceStrings[currency, default: ""]
},
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
return Decimal(string: satsStringInternal) if let newPrice = Decimal(string: newPriceWithoutGroupingSeparator), self.currencyPrices[currency] != newPrice {
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 {
self.currencyValueStrings[currency] = (btc * newPrice).formatString(currency: currency)
} else {
self.currencyValueStrings[currency] = ""
}
}
#else
do {
if let newPrice = Decimal(newPriceWithoutGroupingSeparator), self.currencyPrices[currency] != newPrice {
self.currencyPrices[currency] = newPrice
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? {
let priceWithoutGroupingSeparator = priceWithoutGroupingSeparator(satsStringInternal)
#if !SKIP
return Decimal(string: priceWithoutGroupingSeparator)
#else #else
do { do {
return Decimal(satsStringInternal) return Decimal(priceWithoutGroupingSeparator)
} catch { } catch {
return nil return nil
} }
@@ -170,23 +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 {
return nil
}
#endif
}
var currencyValue: Decimal? {
#if !SKIP
return Decimal(string: currencyValueStringInternal)
#else
do {
return Decimal(currencyValueStringInternal)
} catch { } catch {
return nil return nil
} }
@@ -194,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
@@ -207,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

@@ -14,90 +14,92 @@ import XCTest
final class SatsViewModelTests: XCTestCase { final class SatsViewModelTests: XCTestCase {
func testSatsViewModel() { let currency = Locale.Currency("USD")
let satsViewModel = SatsViewModel()
satsViewModel.btcToCurrencyString = "54321" func testSatsViewModel() throws {
let satsPriceModel = try XCTUnwrap(SatsPriceModel(url: nil))
let satsViewModel = SatsViewModel(model: satsPriceModel)
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, "100,000,000")
XCTAssertEqual(satsViewModel.currencyValueString, "54321") XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "54,321.00")
// 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, "200,000,000")
XCTAssertEqual(satsViewModel.currencyValueString, "108642") XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "108,642.00")
// 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, "300,000,000")
XCTAssertEqual(satsViewModel.currencyValueString, "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 = "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, 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, Decimal("1")) XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal("1"))
#endif #endif
XCTAssertEqual(satsViewModel.currencyValueString, "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 = "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, 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, "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

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 ISO currencies from multiple sources, and converts inputted amounts between Sats, BTC, and the selected ISO currency.
repository: https://github.com/tyiu/sats-price
artifacts:
- SatsPrice-v%v.apk