Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
482e50b6ba
|
|||
| 0d9debf7ca | |||
|
82dbf16466
|
|||
|
5cd4d9189b
|
|||
|
44251a85a6
|
|||
|
38310d922f
|
|||
|
b13a472b10
|
|||
|
d7cbd96e09
|
|||
|
da21b78588
|
|||
|
303cad1076
|
|||
|
9321f920b4
|
|||
|
9faaa24e8d
|
|||
|
6e8ef69136
|
|||
|
e6eb78faa2
|
4
Android/app/proguard-rules.pro
vendored
4
Android/app/proguard-rules.pro
vendored
@@ -1,4 +1,6 @@
|
||||
-keeppackagenames **
|
||||
-keep class skip.** { *; }
|
||||
-keep class com.sun.jna.Pointer { *; }
|
||||
-keep class com.sun.jna.** { *; }
|
||||
-keep class * implements com.sun.jna.** { *; }
|
||||
-keep class sats.price.** { *; }
|
||||
-dontwarn java.awt.**
|
||||
|
||||
@@ -2,5 +2,9 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -199,26 +199,30 @@
|
||||
499CD4422AC5B799001AE8D8 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
DEVELOPMENT_TEAM = S99A5B637C;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = SatsPrice;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
499CD4432AC5B799001AE8D8 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
DEVELOPMENT_TEAM = S99A5B637C;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = SatsPrice;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
|
||||
@@ -15,10 +15,23 @@ let package = Package(
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://source.skip.tools/skip.git", from: "1.0.7"),
|
||||
.package(url: "https://source.skip.tools/skip-ui.git", from: "1.0.0")
|
||||
.package(url: "https://source.skip.tools/skip-ui.git", from: "1.0.0"),
|
||||
.package(url: "https://source.skip.tools/skip-foundation.git", from: "1.0.0"),
|
||||
.package(url: "https://source.skip.tools/skip-model.git", from: "1.0.0"),
|
||||
.package(url: "https://source.skip.tools/skip-sql.git", "0.0.0"..<"2.0.0")
|
||||
],
|
||||
targets: [
|
||||
.target(name: "SatsPrice", dependencies: [.product(name: "SkipUI", package: "skip-ui")], resources: [.process("Resources")], plugins: [.plugin(name: "skipstone", package: "skip")]),
|
||||
.target(
|
||||
name: "SatsPrice",
|
||||
dependencies: [
|
||||
.product(name: "SkipUI", package: "skip-ui"),
|
||||
.product(name: "SkipFoundation", package: "skip-foundation"),
|
||||
.product(name: "SkipModel", package: "skip-model"),
|
||||
.product(name: "SkipSQLPlus", package: "skip-sql")
|
||||
],
|
||||
resources: [.process("Resources")],
|
||||
plugins: [.plugin(name: "skipstone", package: "skip")]
|
||||
),
|
||||
.testTarget(name: "SatsPriceTests", dependencies: ["SatsPrice", .product(name: "SkipTest", package: "skip")], resources: [.process("Resources")], plugins: [.plugin(name: "skipstone", package: "skip")]),
|
||||
]
|
||||
)
|
||||
|
||||
42
README.md
42
README.md
@@ -1,12 +1,41 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="./docs/assets/satsprice-logo.png" alt="SatsPrice Logo" title="SatsPrice logo" width="256"/>
|
||||
|
||||
# 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.
|
||||
|
||||
[](https://github.com/tyiu/sats-price/releases)
|
||||
|
||||
[](https://github.com/tyiu/sats-price)
|
||||
[](/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.
|
||||
It builds a native app for both iOS and Android.
|
||||
|
||||
## Building
|
||||
|
||||
This project is both a stand-alone Swift Package Manager module,
|
||||
as well as an Xcode project that builds and transpiles the project
|
||||
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.
|
||||
|
||||
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)
|
||||
4
Skip.env
4
Skip.env
@@ -11,10 +11,10 @@ PRODUCT_NAME = SatsPrice
|
||||
PRODUCT_BUNDLE_IDENTIFIER = xyz.tyiu.SatsPrice
|
||||
|
||||
// The semantic version of the app
|
||||
MARKETING_VERSION = 1.0.0
|
||||
MARKETING_VERSION = 1.2.0
|
||||
|
||||
// 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
|
||||
ANDROID_PACKAGE_NAME = sats.price
|
||||
|
||||
@@ -6,71 +6,53 @@ import Combine
|
||||
import SwiftUI
|
||||
|
||||
public struct ContentView: View {
|
||||
@ObservedObject private var satsViewModel = SatsViewModel()
|
||||
let model: SatsPriceModel
|
||||
|
||||
@State private var priceSource: PriceSource
|
||||
@StateObject private var satsViewModel: SatsViewModel
|
||||
|
||||
private let dateFormatter: DateFormatter
|
||||
|
||||
private let priceFetcherDelegator: PriceFetcherDelegator
|
||||
init(model: SatsPriceModel) {
|
||||
self.model = model
|
||||
|
||||
_satsViewModel = StateObject<SatsViewModel>(wrappedValue: SatsViewModel(model: model))
|
||||
|
||||
init(_ priceSource: PriceSource) {
|
||||
dateFormatter = DateFormatter()
|
||||
dateFormatter.dateStyle = .short
|
||||
dateFormatter.timeStyle = .short
|
||||
|
||||
self.priceSource = priceSource
|
||||
priceFetcherDelegator = PriceFetcherDelegator(priceSource)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func updatePrice() async {
|
||||
do {
|
||||
guard let price = try await priceFetcherDelegator.convertBTC(toCurrency: satsViewModel.selectedCurrency) else {
|
||||
satsViewModel.btcToCurrencyString = ""
|
||||
return
|
||||
public var addCurrencyView: some View {
|
||||
NavigationLink(
|
||||
destination: {
|
||||
CurrencyPickerView(satsViewModel: satsViewModel)
|
||||
},
|
||||
label: {
|
||||
Text("Change Currencies")
|
||||
}
|
||||
|
||||
satsViewModel.btcToCurrencyString = "\(price)"
|
||||
} catch {
|
||||
satsViewModel.btcToCurrencyString = ""
|
||||
}
|
||||
satsViewModel.lastUpdated = Date.now
|
||||
)
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Picker("Price Source", selection: $priceSource) {
|
||||
Picker("Price Source", selection: $satsViewModel.priceSource) {
|
||||
ForEach(PriceSource.allCases, id: \.self) {
|
||||
Text($0.description)
|
||||
}
|
||||
}
|
||||
|
||||
Picker("Currency", selection: $satsViewModel.selectedCurrency) {
|
||||
ForEach(satsViewModel.currencies, id: \.self) {
|
||||
if let localizedCurrency = Locale.current.localizedString(forCurrencyCode: $0.identifier) {
|
||||
Text("\($0.identifier) - \(localizedCurrency)")
|
||||
} else {
|
||||
Text($0.identifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS) || SKIP
|
||||
.pickerStyle(.navigationLink)
|
||||
#endif
|
||||
|
||||
HStack {
|
||||
TextField("1 BTC to \(satsViewModel.selectedCurrency.identifier)", text: $satsViewModel.btcToCurrencyString)
|
||||
.disabled(priceSource != .manual)
|
||||
TextField("1 BTC to \(satsViewModel.currentCurrency.identifier)", text: satsViewModel.btcToCurrencyString(for: satsViewModel.currentCurrency))
|
||||
.disabled(satsViewModel.priceSource != .manual)
|
||||
#if os(iOS) || SKIP
|
||||
.keyboardType(.decimalPad)
|
||||
#endif
|
||||
if priceSource != .manual {
|
||||
if satsViewModel.priceSource != .manual {
|
||||
Button(action: {
|
||||
Task {
|
||||
await updatePrice()
|
||||
await satsViewModel.updatePrice()
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "arrow.clockwise.circle")
|
||||
@@ -78,62 +60,70 @@ public struct ContentView: View {
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("1 BTC to \(satsViewModel.selectedCurrency.identifier)")
|
||||
Text("1 BTC to \(satsViewModel.currentCurrency.identifier)")
|
||||
} footer: {
|
||||
if priceSource != .manual, let lastUpdated = satsViewModel.lastUpdated {
|
||||
if satsViewModel.priceSource != .manual, let lastUpdated = satsViewModel.lastUpdated {
|
||||
Text("Last updated: \(dateFormatter.string(from: lastUpdated))")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Sats", text: $satsViewModel.satsString)
|
||||
HStack {
|
||||
Text("Sats")
|
||||
TextField("Sats", text: $satsViewModel.satsString)
|
||||
#if os(iOS) || SKIP
|
||||
.keyboardType(.numberPad)
|
||||
.keyboardType(.numberPad)
|
||||
#endif
|
||||
} header: {
|
||||
Text("Sats")
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("BTC")
|
||||
TextField("BTC", text: $satsViewModel.btcString)
|
||||
#if os(iOS) || SKIP
|
||||
.keyboardType(.decimalPad)
|
||||
#endif
|
||||
}
|
||||
} footer: {
|
||||
if satsViewModel.exceedsMaximum {
|
||||
Text("2100000000000000 sats is the maximum.")
|
||||
Text("\(SatsViewModel.MAXIMUM_BTC.formatBTCString()) BTC is the maximum.")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
.keyboardType(.decimalPad)
|
||||
.keyboardType(.decimalPad)
|
||||
#endif
|
||||
} header: {
|
||||
Text("BTC")
|
||||
} footer: {
|
||||
if satsViewModel.exceedsMaximum {
|
||||
Text("21000000 BTC is the maximum.")
|
||||
}
|
||||
|
||||
if satsViewModel.priceSource != .manual {
|
||||
ForEach(satsViewModel.selectedCurrencies.sorted { $0.identifier < $1.identifier }.filter { $0 != satsViewModel.currentCurrency }, id: \.identifier) { currency in
|
||||
HStack {
|
||||
Text(currency.identifier)
|
||||
TextField(currency.identifier, text: satsViewModel.currencyValueString(for: currency))
|
||||
#if os(iOS) || SKIP
|
||||
.keyboardType(.decimalPad)
|
||||
#endif
|
||||
}
|
||||
.tag(currency.identifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField(satsViewModel.selectedCurrency.identifier, text: $satsViewModel.currencyValueString)
|
||||
#if os(iOS) || SKIP
|
||||
.keyboardType(.decimalPad)
|
||||
#endif
|
||||
} header: {
|
||||
Text(satsViewModel.selectedCurrency.identifier)
|
||||
if satsViewModel.priceSource != .manual {
|
||||
addCurrencyView
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await updatePrice()
|
||||
await satsViewModel.pullSelectedCurrenciesFromDB()
|
||||
await satsViewModel.updatePrice()
|
||||
}
|
||||
.onChange(of: satsViewModel.selectedCurrency) { newCurrency in
|
||||
.onChange(of: satsViewModel.priceSource) { newPriceSource in
|
||||
satsViewModel.lastUpdated = nil
|
||||
Task {
|
||||
await updatePrice()
|
||||
}
|
||||
}
|
||||
.onChange(of: priceSource) { newPriceSource in
|
||||
satsViewModel.lastUpdated = nil
|
||||
priceFetcherDelegator.priceSource = newPriceSource
|
||||
Task {
|
||||
await updatePrice()
|
||||
await satsViewModel.updatePrice()
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
@@ -144,9 +134,6 @@ public struct ContentView: View {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
#if DEBUG
|
||||
ContentView(.fake)
|
||||
#else
|
||||
ContentView(.coinbase)
|
||||
#endif
|
||||
let satsPriceModel = try! SatsPriceModel(url: nil)
|
||||
ContentView(model: satsPriceModel)
|
||||
}
|
||||
|
||||
85
Sources/SatsPrice/CurrencyPickerView.swift
Normal file
85
Sources/SatsPrice/CurrencyPickerView.swift
Normal file
@@ -0,0 +1,85 @@
|
||||
// This is free software: you can redistribute and/or modify it
|
||||
// under the terms of the GNU General Public License 3.0
|
||||
// as published by the Free Software Foundation https://fsf.org
|
||||
//
|
||||
// CurrencyPickerView.swift
|
||||
// sats-price
|
||||
//
|
||||
// Created by Terry Yiu on 11/10/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CurrencyPickerView: View {
|
||||
@ObservedObject var satsViewModel: SatsViewModel
|
||||
|
||||
var body: some View {
|
||||
let currentCurrency = satsViewModel.currentCurrency
|
||||
|
||||
List {
|
||||
Section("Current Currency") {
|
||||
let currentCurrency = satsViewModel.currentCurrency
|
||||
if let localizedCurrency = Locale.current.localizedString(forCurrencyCode: currentCurrency.identifier) {
|
||||
Text("\(currentCurrency.identifier) - \(localizedCurrency)")
|
||||
} else {
|
||||
Text(currentCurrency.identifier)
|
||||
}
|
||||
}
|
||||
|
||||
if !satsViewModel.selectedCurrencies.isEmpty {
|
||||
Section("Selected Currencies") {
|
||||
ForEach(satsViewModel.selectedCurrencies.filter { $0 != currentCurrency }.sorted { $0.identifier < $1.identifier }, id: \.identifier) { currency in
|
||||
Button(
|
||||
action: {
|
||||
satsViewModel.removeSelectedCurrency(currency)
|
||||
},
|
||||
label: {
|
||||
HStack {
|
||||
Group {
|
||||
if let localizedCurrency = Locale.current.localizedString(forCurrencyCode: currency.identifier) {
|
||||
Text("\(currency.identifier) - \(localizedCurrency)")
|
||||
} else {
|
||||
Text(currency.identifier)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Currencies") {
|
||||
ForEach(satsViewModel.currencies.filter { $0 != currentCurrency && !satsViewModel.selectedCurrencies.contains($0) }, id: \.identifier) { currency in
|
||||
Button(
|
||||
action: {
|
||||
satsViewModel.addSelectedCurrency(currency)
|
||||
},
|
||||
label: {
|
||||
if let localizedCurrency = Locale.current.localizedString(forCurrencyCode: currency.identifier) {
|
||||
Text("\(currency.identifier) - \(localizedCurrency)")
|
||||
} else {
|
||||
Text(currency.identifier)
|
||||
}
|
||||
}
|
||||
)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear(perform: {
|
||||
Task {
|
||||
await satsViewModel.updatePrice()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let satsPriceModel = try! SatsPriceModel(url: nil)
|
||||
CurrencyPickerView(satsViewModel: SatsViewModel(model: satsPriceModel))
|
||||
}
|
||||
156
Sources/SatsPrice/Model/SatsPriceModel.swift
Normal file
156
Sources/SatsPrice/Model/SatsPriceModel.swift
Normal file
@@ -0,0 +1,156 @@
|
||||
// This is free software: you can redistribute and/or modify it
|
||||
// under the terms of the GNU General Public License 3.0
|
||||
// as published by the Free Software Foundation https://fsf.org
|
||||
//
|
||||
// SatsPriceModel.swift
|
||||
// sats-price
|
||||
//
|
||||
// Created by Terry Yiu on 11/15/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import Observation
|
||||
import SkipSQL
|
||||
|
||||
public struct SelectedCurrency: Identifiable {
|
||||
public var id: String {
|
||||
currencyCode
|
||||
}
|
||||
|
||||
public var currencyCode: String
|
||||
}
|
||||
|
||||
/// Notification posted by the model when selected currencies change.
|
||||
extension Notification.Name {
|
||||
public static var selectedCurrenciesDidChange: Notification.Name {
|
||||
return Notification.Name("selectedCurrenciesChange")
|
||||
}
|
||||
}
|
||||
|
||||
/// Payload of `selectedCurrenciesDidChange` notifications.
|
||||
public struct SelectedCurrenciesChange {
|
||||
public let inserts: [SelectedCurrency]
|
||||
/// Nil set means all records were deleted.
|
||||
public let deletes: Set<String>?
|
||||
|
||||
public init(inserts: [SelectedCurrency] = [], deletes: [String]? = []) {
|
||||
self.inserts = inserts
|
||||
self.deletes = deletes == nil ? nil : Set(deletes!)
|
||||
}
|
||||
}
|
||||
|
||||
public actor SatsPriceModel {
|
||||
private let ctx: SQLContext
|
||||
private var schemaInitializationResult: Result<Void, Error>?
|
||||
|
||||
public init(url: URL?) throws {
|
||||
ctx = try SQLContext(path: url?.path ?? ":memory:", flags: [.readWrite, .create], logLevel: .info, configuration: .platform)
|
||||
}
|
||||
|
||||
public func selectedCurrencies() throws -> [SelectedCurrency] {
|
||||
do {
|
||||
try initializeSchema()
|
||||
let statement = try ctx.prepare(sql: "SELECT currencyCode FROM SelectedCurrency")
|
||||
defer {
|
||||
do {
|
||||
try statement.close()
|
||||
} catch {
|
||||
logger.warning("Failed to close statement: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
var selectedCurrencies: [SelectedCurrency] = []
|
||||
|
||||
while try statement.next() {
|
||||
let currencyCode = statement.string(at: 0) ?? ""
|
||||
selectedCurrencies.append(SelectedCurrency(currencyCode: currencyCode))
|
||||
}
|
||||
|
||||
return selectedCurrencies
|
||||
} catch {
|
||||
logger.error("Failed to get selected currencies from DB. Error: \(error)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func insert(_ selectedCurrency: SelectedCurrency) throws -> [SelectedCurrency] {
|
||||
try initializeSchema()
|
||||
let statement = try ctx.prepare(sql: "INSERT INTO SelectedCurrency (currencyCode) VALUES (?)")
|
||||
defer {
|
||||
do {
|
||||
try statement.close()
|
||||
} catch {
|
||||
logger.warning("Failed to close statement: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
var insertedItems: [SelectedCurrency] = []
|
||||
try ctx.transaction {
|
||||
statement.reset()
|
||||
let values = Self.bindingValues(for: selectedCurrency)
|
||||
try statement.update(parameters: values)
|
||||
|
||||
insertedItems.append(selectedCurrency)
|
||||
}
|
||||
NotificationCenter.default.post(name: .selectedCurrenciesDidChange, object: SelectedCurrenciesChange(inserts: insertedItems))
|
||||
return insertedItems
|
||||
}
|
||||
|
||||
private static func bindingValues(for selectedCurrency: SelectedCurrency) -> [SQLValue] {
|
||||
return [
|
||||
.text(selectedCurrency.currencyCode)
|
||||
]
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func deleteSelectedCurrency(currencyCode: String) throws -> Int {
|
||||
try initializeSchema()
|
||||
try ctx.exec(sql: "DELETE FROM SelectedCurrency WHERE currencyCode = ?", parameters: [.text(currencyCode)])
|
||||
NotificationCenter.default.post(name: .selectedCurrenciesDidChange, object: SelectedCurrenciesChange(deletes: [currencyCode]))
|
||||
return Int(ctx.changes)
|
||||
}
|
||||
|
||||
private func initializeSchema() throws {
|
||||
switch schemaInitializationResult {
|
||||
case .success:
|
||||
return
|
||||
case .failure(let failure):
|
||||
throw failure
|
||||
case nil:
|
||||
break
|
||||
}
|
||||
|
||||
do {
|
||||
var currentVersion = try currentSchemaVersion()
|
||||
currentVersion = try migrateSchema(v: Int64(1), current: currentVersion, ddl: """
|
||||
CREATE TABLE SelectedCurrency (currencyCode TEXT PRIMARY KEY NOT NULL)
|
||||
""")
|
||||
// Future column additions, etc here...
|
||||
schemaInitializationResult = .success(())
|
||||
} catch {
|
||||
schemaInitializationResult = .failure(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func currentSchemaVersion() throws -> Int64 {
|
||||
try ctx.exec(sql: "CREATE TABLE IF NOT EXISTS SchemaVersion (id INTEGER PRIMARY KEY, version INTEGER)")
|
||||
try ctx.exec(sql: "INSERT OR IGNORE INTO SchemaVersion (id, version) VALUES (0, 0)")
|
||||
return try ctx.query(sql: "SELECT version FROM SchemaVersion").first?.first?.integerValue ?? Int64(0)
|
||||
}
|
||||
|
||||
private func migrateSchema(v version: Int64, current: Int64, ddl: String) throws -> Int64 {
|
||||
guard current < version else {
|
||||
return current
|
||||
}
|
||||
let startTime = Date.now
|
||||
try ctx.transaction {
|
||||
try ctx.exec(sql: ddl)
|
||||
try ctx.exec(sql: "UPDATE SchemaVersion SET version = ?", parameters: [.integer(version)])
|
||||
}
|
||||
logger.log("Updated database schema to \(version) in \(Date.now.timeIntervalSince1970 - startTime.timeIntervalSince1970)")
|
||||
return version
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,11 @@ class CoinGeckoPriceFetcher : PriceFetcher {
|
||||
"https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=\(currency.identifier.lowercased())&precision=18"
|
||||
}
|
||||
|
||||
func urlString(toCurrencies currencies: [Locale.Currency]) -> String {
|
||||
let currenciesString = currencies.map { $0.identifier.lowercased() }.joined(separator: ",")
|
||||
return "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=\(currenciesString)&precision=18"
|
||||
}
|
||||
|
||||
func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? {
|
||||
do {
|
||||
guard let urlComponents = URLComponents(string: urlString(toCurrency: currency)), let url = urlComponents.url else {
|
||||
@@ -45,4 +50,43 @@ class CoinGeckoPriceFetcher : PriceFetcher {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func convertBTC(toCurrencies currencies: [Locale.Currency]) async throws -> [Locale.Currency : Decimal] {
|
||||
do {
|
||||
guard !currencies.isEmpty else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
if currencies.count == 1, let currency = currencies.first {
|
||||
guard let price = try await convertBTC(toCurrency: currency) else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
return [currency: price]
|
||||
}
|
||||
|
||||
guard let urlComponents = URLComponents(string: urlString(toCurrencies: currencies)), let url = urlComponents.url else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(from: url, delegate: nil)
|
||||
|
||||
let priceResponse = try JSONDecoder().decode(CoinGeckoPriceResponse.self, from: data)
|
||||
|
||||
var results: [Locale.Currency : Decimal] = [:]
|
||||
for currency in currencies {
|
||||
if let price = priceResponse.bitcoin[currency.identifier.lowercased()] {
|
||||
#if !SKIP
|
||||
results[currency] = price
|
||||
#else
|
||||
results[currency] = Decimal(price)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
} catch {
|
||||
return [:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,11 +20,22 @@ private struct CoinbasePrice: Codable {
|
||||
let currency: String
|
||||
}
|
||||
|
||||
private struct CoinbaseExchangeRatesResponse: Codable {
|
||||
let data: CoinbaseExchangeRatesResponseData
|
||||
}
|
||||
|
||||
private struct CoinbaseExchangeRatesResponseData: Codable {
|
||||
let currency: String
|
||||
let rates: [String: String]
|
||||
}
|
||||
|
||||
class CoinbasePriceFetcher : PriceFetcher {
|
||||
func urlString(toCurrency currency: Locale.Currency) -> String {
|
||||
"https://api.coinbase.com/v2/prices/BTC-\(currency.identifier)/spot"
|
||||
}
|
||||
|
||||
private static let urlStringForAllCurrencies: String = "https://api.coinbase.com/v2/exchange-rates?currency=BTC"
|
||||
|
||||
func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? {
|
||||
do {
|
||||
guard let urlComponents = URLComponents(string: urlString(toCurrency: currency)), let url = urlComponents.url else {
|
||||
@@ -40,13 +51,57 @@ class CoinbasePriceFetcher : PriceFetcher {
|
||||
return nil
|
||||
}
|
||||
|
||||
#if !SKIP
|
||||
#if !SKIP
|
||||
return Decimal(string: coinbasePrice.amount)
|
||||
#else
|
||||
#else
|
||||
return Decimal(coinbasePrice.amount)
|
||||
#endif
|
||||
#endif
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func convertBTC(toCurrencies currencies: [Locale.Currency]) async throws -> [Locale.Currency : Decimal] {
|
||||
do {
|
||||
guard !currencies.isEmpty else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
if currencies.count == 1, let currency = currencies.first {
|
||||
guard let price = try await convertBTC(toCurrency: currency) else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
return [currency: price]
|
||||
}
|
||||
|
||||
guard let urlComponents = URLComponents(string: CoinbasePriceFetcher.urlStringForAllCurrencies), let url = urlComponents.url else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(from: url, delegate: nil)
|
||||
|
||||
let coinbaseExchangeRatesResponse = try JSONDecoder().decode(CoinbaseExchangeRatesResponse.self, from: data)
|
||||
let rates = coinbaseExchangeRatesResponse.data.rates
|
||||
|
||||
guard coinbaseExchangeRatesResponse.data.currency == "BTC" else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
var results: [Locale.Currency : Decimal] = [:]
|
||||
for currency in currencies {
|
||||
if let price = rates[currency.identifier] {
|
||||
#if !SKIP
|
||||
results[currency] = Decimal(string: price)
|
||||
#else
|
||||
results[currency] = Decimal(price)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
} catch {
|
||||
return [:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,19 @@ import Foundation
|
||||
/// Fake price fetcher that returns a randomized price. Useful for development testing without requiring a network call.
|
||||
class FakePriceFetcher: PriceFetcher {
|
||||
func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? {
|
||||
randomPrice()
|
||||
}
|
||||
|
||||
func convertBTC(toCurrencies currencies: [Locale.Currency]) async throws -> [Locale.Currency : Decimal] {
|
||||
guard !currencies.isEmpty else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
let prices = currencies.map { _ in randomPrice() }
|
||||
return Dictionary(uniqueKeysWithValues: zip(currencies, prices))
|
||||
}
|
||||
|
||||
private func randomPrice() -> Decimal {
|
||||
Decimal(Double.random(in: 10000...100000))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,19 @@ import Foundation
|
||||
|
||||
/// Fake price fetcher that returns a randomized price. Useful for development testing without requiring a network call.
|
||||
class ManualPriceFetcher: PriceFetcher {
|
||||
var price: Decimal = Decimal(1)
|
||||
var prices: [Locale.Currency: Decimal] = [:]
|
||||
|
||||
func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? {
|
||||
return price
|
||||
prices[currency]
|
||||
}
|
||||
|
||||
func convertBTC(toCurrencies currencies: [Locale.Currency]) async throws -> [Locale.Currency : Decimal] {
|
||||
guard !currencies.isEmpty else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
let filteredCurrencies = currencies.filter { prices.keys.contains($0) }
|
||||
let priceValues = filteredCurrencies.map { prices[$0, default: Decimal(0)] }
|
||||
return Dictionary(uniqueKeysWithValues: zip(filteredCurrencies, priceValues))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,4 +12,5 @@ import Foundation
|
||||
|
||||
protocol PriceFetcher {
|
||||
func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal?
|
||||
func convertBTC(toCurrencies currencies: [Locale.Currency]) async throws -> [Locale.Currency: Decimal]
|
||||
}
|
||||
|
||||
@@ -42,4 +42,8 @@ class PriceFetcherDelegator: PriceFetcher {
|
||||
func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? {
|
||||
return try await delegate.convertBTC(toCurrency: currency)
|
||||
}
|
||||
|
||||
func convertBTC(toCurrencies currencies: [Locale.Currency]) async throws -> [Locale.Currency : Decimal] {
|
||||
return try await delegate.convertBTC(toCurrencies: currencies)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,20 +13,23 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@ BTC is the maximum." : {
|
||||
|
||||
},
|
||||
"1 BTC to %@" : {
|
||||
|
||||
},
|
||||
"21000000 BTC is the maximum." : {
|
||||
|
||||
},
|
||||
"2100000000000000 sats is the maximum." : {
|
||||
|
||||
},
|
||||
"BTC" : {
|
||||
|
||||
},
|
||||
"Currency" : {
|
||||
"Change Currencies" : {
|
||||
|
||||
},
|
||||
"Currencies" : {
|
||||
|
||||
},
|
||||
"Current Currency" : {
|
||||
|
||||
},
|
||||
"Last updated: %@" : {
|
||||
@@ -37,6 +40,9 @@
|
||||
},
|
||||
"Sats" : {
|
||||
|
||||
},
|
||||
"Selected Currencies" : {
|
||||
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
|
||||
@@ -11,6 +11,9 @@ let logger: Logger = Logger(subsystem: "xyz.tyiu.SatsPrice", category: "SatsPric
|
||||
/// The Android SDK number we are running against, or `nil` if not running on Android
|
||||
let androidSDK = ProcessInfo.processInfo.environment["android.os.Build.VERSION.SDK_INT"].flatMap({ Int($0) })
|
||||
|
||||
/// The shared data model.
|
||||
private let model = try! SatsPriceModel(url: URL.documentsDirectory.appendingPathComponent("satsprice.sqlite"))
|
||||
|
||||
/// The shared top-level view for the app, loaded from the platform-specific App delegates below.
|
||||
///
|
||||
/// The default implementation merely loads the `ContentView` for the app and logs a message.
|
||||
@@ -19,7 +22,7 @@ public struct RootView : View {
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ContentView(.coinbase)
|
||||
ContentView(model: model)
|
||||
.task {
|
||||
logger.log("Welcome to Skip on \(androidSDK != nil ? "Android" : "Darwin")!")
|
||||
logger.warning("Skip app logs are viewable in the Xcode console for iOS; Android logs can be viewed in Studio or using adb logcat")
|
||||
|
||||
@@ -13,17 +13,33 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
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 btcToCurrencyStringInternal: String = ""
|
||||
@Published var priceSourceInternal: PriceSource = .coinbase
|
||||
let priceFetcherDelegator = PriceFetcherDelegator(.coinbase)
|
||||
|
||||
@Published var satsStringInternal: String = ""
|
||||
@Published var btcStringInternal: String = ""
|
||||
@Published var currencyValueStringInternal: String = ""
|
||||
@Published var selectedCurrency: Locale.Currency = Locale.current.currency ?? Locale.Currency("USD")
|
||||
@Published var selectedCurrencies = Set<Locale.Currency>()
|
||||
@Published var currencyValueStrings: [Locale.Currency: String] = [:]
|
||||
|
||||
@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] {
|
||||
let commonISOCurrencyCodes = Set(Locale.commonISOCurrencyCodes)
|
||||
let currentCurrency = Locale.current.currency ?? Locale.Currency("USD")
|
||||
if commonISOCurrencyCodes.contains(currentCurrency.identifier) {
|
||||
return Locale.commonISOCurrencyCodes.map { Locale.Currency($0) }
|
||||
} else {
|
||||
@@ -34,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 {
|
||||
btcToCurrencyStringInternal
|
||||
priceSourceInternal
|
||||
}
|
||||
set {
|
||||
guard btcToCurrencyStringInternal != newValue else {
|
||||
return
|
||||
}
|
||||
priceSourceInternal = newValue
|
||||
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 {
|
||||
currencyValueStringInternal = (btc * btcToCurrency).formatString()
|
||||
} else {
|
||||
currencyValueStringInternal = ""
|
||||
}
|
||||
currencyPrices = prices
|
||||
updateCurrencyPriceStrings()
|
||||
updateCurrencyValueStrings()
|
||||
} 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
|
||||
}
|
||||
set {
|
||||
guard satsStringInternal != newValue else {
|
||||
let oldPriceWithoutGroupingSeparator = priceWithoutGroupingSeparator(satsStringInternal)
|
||||
let newPriceWithoutGroupingSeparator = priceWithoutGroupingSeparator(newValue)
|
||||
|
||||
guard oldPriceWithoutGroupingSeparator != newPriceWithoutGroupingSeparator else {
|
||||
return
|
||||
}
|
||||
|
||||
satsStringInternal = newValue
|
||||
satsStringInternal = newPriceWithoutGroupingSeparator
|
||||
|
||||
if let sats {
|
||||
#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
|
||||
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
|
||||
btcStringInternal = btc.formatString()
|
||||
if let btcToCurrency {
|
||||
currencyValueStringInternal = (btc * btcToCurrency).formatString()
|
||||
} else {
|
||||
currencyValueStringInternal = ""
|
||||
}
|
||||
btcStringInternal = btc.formatBTCString()
|
||||
|
||||
updateCurrencyValueStrings()
|
||||
} else {
|
||||
btcStringInternal = ""
|
||||
currencyValueStringInternal = ""
|
||||
clearCurrencyValueStrings()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,81 +160,185 @@ class SatsViewModel: ObservableObject {
|
||||
btcStringInternal
|
||||
}
|
||||
set {
|
||||
guard btcStringInternal != newValue else {
|
||||
let oldPriceWithoutGroupingSeparator = priceWithoutGroupingSeparator(btcStringInternal)
|
||||
let newPriceWithoutGroupingSeparator = priceWithoutGroupingSeparator(newValue)
|
||||
|
||||
guard oldPriceWithoutGroupingSeparator != newPriceWithoutGroupingSeparator else {
|
||||
return
|
||||
}
|
||||
|
||||
btcStringInternal = newValue
|
||||
btcStringInternal = newPriceWithoutGroupingSeparator
|
||||
|
||||
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
|
||||
let btc = currencyValue / btcToCurrency
|
||||
#else
|
||||
let btc = currencyValue.divide(btcToCurrency, 20, java.math.RoundingMode.DOWN)
|
||||
// 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
|
||||
btcStringInternal = btc.formatString()
|
||||
|
||||
let sats = btc * Decimal(100000000)
|
||||
satsStringInternal = sats.formatString()
|
||||
} else {
|
||||
satsStringInternal = ""
|
||||
btcStringInternal = ""
|
||||
currencyValueStringInternal = ""
|
||||
}
|
||||
let sats = btc * SatsViewModel.SATS_IN_BTC
|
||||
satsStringInternal = sats.formatSatsString()
|
||||
|
||||
updateCurrencyValueStrings()
|
||||
} else {
|
||||
satsStringInternal = ""
|
||||
btcStringInternal = ""
|
||||
currencyValueStringInternal = ""
|
||||
clearCurrencyValueStrings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
do {
|
||||
return Decimal(btcToCurrencyStringInternal)
|
||||
return Decimal(currencyValueString)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
#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
|
||||
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
|
||||
do {
|
||||
return Decimal(satsStringInternal)
|
||||
return Decimal(priceWithoutGroupingSeparator)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
@@ -170,23 +346,12 @@ class SatsViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
var btc: Decimal? {
|
||||
let priceWithoutGroupingSeparator = priceWithoutGroupingSeparator(btcStringInternal)
|
||||
#if !SKIP
|
||||
return Decimal(string: btcStringInternal)
|
||||
return Decimal(string: priceWithoutGroupingSeparator)
|
||||
#else
|
||||
do {
|
||||
return Decimal(btcStringInternal)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var currencyValue: Decimal? {
|
||||
#if !SKIP
|
||||
return Decimal(string: currencyValueStringInternal)
|
||||
#else
|
||||
do {
|
||||
return Decimal(currencyValueStringInternal)
|
||||
return Decimal(priceWithoutGroupingSeparator)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
@@ -194,7 +359,7 @@ class SatsViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
var exceedsMaximum: Bool {
|
||||
if let btc, btc > Decimal(21000000) {
|
||||
if let btc, btc > SatsViewModel.MAXIMUM_BTC {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -207,6 +372,54 @@ extension Decimal {
|
||||
return String(describing: self)
|
||||
#else
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private extension Character {
|
||||
var isDigit: Bool {
|
||||
self >= "0" && self <= "9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,90 +14,92 @@ import XCTest
|
||||
|
||||
final class SatsViewModelTests: XCTestCase {
|
||||
|
||||
func testSatsViewModel() {
|
||||
let satsViewModel = SatsViewModel()
|
||||
satsViewModel.btcToCurrencyString = "54321"
|
||||
let currency = Locale.Currency("USD")
|
||||
|
||||
func testSatsViewModel() throws {
|
||||
let satsPriceModel = try XCTUnwrap(SatsPriceModel(url: nil))
|
||||
let satsViewModel = SatsViewModel(model: satsPriceModel)
|
||||
satsViewModel.btcToCurrencyString(for: currency).wrappedValue = "54321"
|
||||
|
||||
// Test BTC updates.
|
||||
satsViewModel.btcString = "1"
|
||||
#if !SKIP
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal(string: "1"))
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal(string: "100000000"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue, Decimal(string: "54321"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "54321"))
|
||||
#else
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal("1"))
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal("100000000"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue, Decimal("54321"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal("54321"))
|
||||
#endif
|
||||
XCTAssertEqual(satsViewModel.btcString, "1")
|
||||
XCTAssertEqual(satsViewModel.satsString, "100000000")
|
||||
XCTAssertEqual(satsViewModel.currencyValueString, "54321")
|
||||
XCTAssertEqual(satsViewModel.satsString, "100,000,000")
|
||||
XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "54,321.00")
|
||||
|
||||
// Test Sats updates.
|
||||
satsViewModel.satsString = "200000000"
|
||||
#if !SKIP
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal(string: "2"))
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal(string: "200000000"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue, Decimal(string: "108642"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "108642"))
|
||||
#else
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal("2"))
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal("200000000"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue, Decimal("108642"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal("108642"))
|
||||
#endif
|
||||
XCTAssertEqual(satsViewModel.btcString, "2")
|
||||
XCTAssertEqual(satsViewModel.satsString, "200000000")
|
||||
XCTAssertEqual(satsViewModel.currencyValueString, "108642")
|
||||
XCTAssertEqual(satsViewModel.satsString, "200,000,000")
|
||||
XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "108,642.00")
|
||||
|
||||
// Test currency value updates.
|
||||
satsViewModel.currencyValueString = "162963"
|
||||
satsViewModel.currencyValueString(for: currency).wrappedValue = "162963"
|
||||
#if !SKIP
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal(string: "3"))
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal(string: "300000000"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue, Decimal(string: "162963"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "162963"))
|
||||
#else
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal("3"))
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal("300000000"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue, Decimal("162963"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal("162963"))
|
||||
#endif
|
||||
XCTAssertEqual(satsViewModel.btcString, "3")
|
||||
XCTAssertEqual(satsViewModel.satsString, "300000000")
|
||||
XCTAssertEqual(satsViewModel.currencyValueString, "162963")
|
||||
XCTAssertEqual(satsViewModel.satsString, "300,000,000")
|
||||
XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "162,963.00")
|
||||
|
||||
// Test fractional amounts.
|
||||
// Precision between platforms on this calculation is different so we have different assertions for each.
|
||||
satsViewModel.currencyValueString = "1"
|
||||
satsViewModel.currencyValueString(for: currency).wrappedValue = "1"
|
||||
#if !SKIP
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal(string: "0.00001840908672520756245282671526665562"))
|
||||
XCTAssertEqual(satsViewModel.btcString, "0.00001840908672520756245282671526665562")
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal(string: "1840.908672520756245282671526665562"))
|
||||
XCTAssertEqual(satsViewModel.satsString, "1840.908672520756245282671526665562")
|
||||
XCTAssertEqual(satsViewModel.currencyValue, Decimal(string: "1"))
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal(string: "0.00001841"))
|
||||
XCTAssertEqual(satsViewModel.btcString, "0.00001841")
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal(string: "1841"))
|
||||
XCTAssertEqual(satsViewModel.satsString, "1,841")
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "1"))
|
||||
#else
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal("0.00001840908672520756"))
|
||||
XCTAssertEqual(satsViewModel.btcString, "0.00001840908672520756")
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal("1840.908672520756"))
|
||||
XCTAssertEqual(satsViewModel.satsString, "1840.908672520756")
|
||||
XCTAssertEqual(satsViewModel.currencyValue, Decimal("1"))
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal("0.00001841"))
|
||||
XCTAssertEqual(satsViewModel.btcString, "0.00001841")
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal("1841"))
|
||||
XCTAssertEqual(satsViewModel.satsString, "1,841")
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal("1"))
|
||||
#endif
|
||||
XCTAssertEqual(satsViewModel.currencyValueString, "1")
|
||||
XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "1.00")
|
||||
|
||||
// Test large amounts that exceed the cap of 21M BTC.
|
||||
// Precision between platforms on this calculation is different so we have different assertions for each.
|
||||
satsViewModel.currencyValueString = "11407419999999"
|
||||
satsViewModel.currencyValueString(for: currency).wrappedValue = "11407419999999"
|
||||
#if !SKIP
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal(string: "210000184.09084884298889932070469983984"))
|
||||
XCTAssertEqual(satsViewModel.btcString, "210000184.09084884298889932070469983984")
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal(string: "21000018409084884.298889932070469983984"))
|
||||
XCTAssertEqual(satsViewModel.satsString, "21000018409084884.298889932070469983984")
|
||||
XCTAssertEqual(satsViewModel.currencyValue, Decimal(string: "11407419999999"))
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal(string: "210000184.09084884"))
|
||||
XCTAssertEqual(satsViewModel.btcString, "210,000,184.09084884")
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal(string: "21000018409084884"))
|
||||
XCTAssertEqual(satsViewModel.satsString, "21,000,018,409,084,884")
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "11407419999999"))
|
||||
#else
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal("210000184.0908488429888993207"))
|
||||
XCTAssertEqual(satsViewModel.btcString, "210000184.0908488429888993207")
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal("21000018409084884.29888993207"))
|
||||
XCTAssertEqual(satsViewModel.satsString, "21000018409084884.29888993207")
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal("210000184.09084884"))
|
||||
XCTAssertEqual(satsViewModel.btcString, "210000184.09084884")
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal("21000018409084884"))
|
||||
XCTAssertEqual(satsViewModel.satsString, "21000018409084884")
|
||||
XCTAssertEqual(satsViewModel.currencyValue, Decimal("11407419999999"))
|
||||
#endif
|
||||
XCTAssertEqual(satsViewModel.currencyValueString, "11407419999999")
|
||||
XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "11,407,419,999,999.00")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
46
docs/assets/download_on_apple.svg
Executable file
46
docs/assets/download_on_apple.svg
Executable 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="<Group>">
|
||||
<g id="_Group_2" data-name="<Group>">
|
||||
<g id="_Group_3" data-name="<Group>">
|
||||
<path id="_Path_" data-name="<Path>" 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="<Path>" 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="<Group>">
|
||||
<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 |
1
docs/assets/download_on_github.svg
Normal file
1
docs/assets/download_on_github.svg
Normal 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 |
BIN
docs/assets/download_on_obtainium.png
Normal file
BIN
docs/assets/download_on_obtainium.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
237
docs/assets/download_on_zapstore.svg
Normal file
237
docs/assets/download_on_zapstore.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
BIN
docs/assets/satsprice-logo.png
Normal file
BIN
docs/assets/satsprice-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
7
zapstore.yaml
Normal file
7
zapstore.yaml
Normal 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
|
||||
Reference in New Issue
Block a user