Convert SatsPrice to a Skip multiplatform app
This commit is contained in:
122
Sources/SatsPrice/ContentView.swift
Normal file
122
Sources/SatsPrice/ContentView.swift
Normal file
@@ -0,0 +1,122 @@
|
||||
// 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
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
public struct ContentView: View {
|
||||
@ObservedObject private var satsViewModel = SatsViewModel()
|
||||
|
||||
@State private var priceSource: PriceSource
|
||||
|
||||
private let dateFormatter: DateFormatter
|
||||
|
||||
private let priceFetcherDelegator: PriceFetcherDelegator
|
||||
|
||||
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.btcToUsd() else {
|
||||
satsViewModel.btcToUsdString = ""
|
||||
return
|
||||
}
|
||||
|
||||
satsViewModel.btcToUsdString = "\(price)"
|
||||
} catch {
|
||||
satsViewModel.btcToUsdString = ""
|
||||
}
|
||||
satsViewModel.lastUpdated = Date.now
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Picker("Price Source", selection: $priceSource) {
|
||||
ForEach(PriceSource.allCases, id: \.self) {
|
||||
Text($0.description)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
TextField("", text: $satsViewModel.btcToUsdString)
|
||||
.disabled(priceSource != .manual)
|
||||
#if os(iOS) || SKIP
|
||||
.keyboardType(.decimalPad)
|
||||
#endif
|
||||
if priceSource != .manual {
|
||||
Button(action: {
|
||||
Task {
|
||||
await updatePrice()
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "arrow.clockwise.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("1 BTC to USD")
|
||||
} footer: {
|
||||
if priceSource != .manual {
|
||||
Text("Last updated: \(dateFormatter.string(from: satsViewModel.lastUpdated))")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("", text: $satsViewModel.satsString)
|
||||
#if os(iOS) || SKIP
|
||||
.keyboardType(.numberPad)
|
||||
#endif
|
||||
} header: {
|
||||
Text("Sats")
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("", text: $satsViewModel.btcString)
|
||||
#if os(iOS) || SKIP
|
||||
.keyboardType(.decimalPad)
|
||||
#endif
|
||||
} header: {
|
||||
Text("BTC")
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("", text: $satsViewModel.usdString)
|
||||
#if os(iOS) || SKIP
|
||||
.keyboardType(.decimalPad)
|
||||
#endif
|
||||
} header: {
|
||||
Text("USD")
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await updatePrice()
|
||||
}
|
||||
.onChange(of: priceSource) { newPriceSource in
|
||||
priceFetcherDelegator.priceSource = newPriceSource
|
||||
Task {
|
||||
await updatePrice()
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.formStyle(.grouped)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
#if DEBUG
|
||||
ContentView(.fake)
|
||||
#else
|
||||
ContentView(.coinbase)
|
||||
#endif
|
||||
}
|
||||
48
Sources/SatsPrice/Network/CoinGeckoPriceFetcher.swift
Normal file
48
Sources/SatsPrice/Network/CoinGeckoPriceFetcher.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
// 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
|
||||
//
|
||||
// CoinGeckoPriceFetcher.swift
|
||||
// SatsPrice
|
||||
//
|
||||
// Created by Terry Yiu on 2/19/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
private struct CoinGeckoPriceResponse: Codable {
|
||||
let bitcoin: CoinGeckoPrice
|
||||
}
|
||||
|
||||
private struct CoinGeckoPrice: Codable {
|
||||
#if !SKIP
|
||||
let usd: Decimal
|
||||
#else
|
||||
let usd: String
|
||||
#endif
|
||||
}
|
||||
|
||||
class CoinGeckoPriceFetcher : PriceFetcher {
|
||||
private static let urlString = "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd&precision=18"
|
||||
|
||||
func btcToUsd() async throws -> Decimal? {
|
||||
do {
|
||||
guard let urlComponents = URLComponents(string: CoinGeckoPriceFetcher.urlString), let url = urlComponents.url else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(from: url, delegate: nil)
|
||||
|
||||
let priceResponse = try JSONDecoder().decode(CoinGeckoPriceResponse.self, from: data)
|
||||
let price = priceResponse.bitcoin
|
||||
|
||||
#if !SKIP
|
||||
return price.usd
|
||||
#else
|
||||
return Decimal(price.usd)
|
||||
#endif
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
52
Sources/SatsPrice/Network/CoinbasePriceFetcher.swift
Normal file
52
Sources/SatsPrice/Network/CoinbasePriceFetcher.swift
Normal file
@@ -0,0 +1,52 @@
|
||||
// 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
|
||||
//
|
||||
// CoinbasePriceFetcher.swift
|
||||
// SatsPrice
|
||||
//
|
||||
// Created by Terry Yiu on 2/19/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
private struct CoinbasePriceResponse: Codable {
|
||||
let data: CoinbasePrice
|
||||
}
|
||||
|
||||
private struct CoinbasePrice: Codable {
|
||||
let amount: String
|
||||
let base: String
|
||||
let currency: String
|
||||
}
|
||||
|
||||
class CoinbasePriceFetcher : PriceFetcher {
|
||||
private static let urlString = "https://api.coinbase.com/v2/prices/BTC-USD/spot"
|
||||
private static let btc = "BTC"
|
||||
private static let usd = "USD"
|
||||
|
||||
func btcToUsd() async throws -> Decimal? {
|
||||
do {
|
||||
guard let urlComponents = URLComponents(string: CoinbasePriceFetcher.urlString), let url = urlComponents.url else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(from: url, delegate: nil)
|
||||
|
||||
let coinbasePriceResponse = try JSONDecoder().decode(CoinbasePriceResponse.self, from: data)
|
||||
let coinbasePrice = coinbasePriceResponse.data
|
||||
|
||||
guard coinbasePrice.base == CoinbasePriceFetcher.btc && coinbasePrice.currency == CoinbasePriceFetcher.usd else {
|
||||
return nil
|
||||
}
|
||||
|
||||
#if !SKIP
|
||||
return Decimal(string: coinbasePrice.amount)
|
||||
#else
|
||||
return Decimal(coinbasePrice.amount)
|
||||
#endif
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
20
Sources/SatsPrice/Network/FakePriceFetcher.swift
Normal file
20
Sources/SatsPrice/Network/FakePriceFetcher.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
// 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
|
||||
//
|
||||
// FakePriceFetcher.swift
|
||||
// SatsPrice
|
||||
//
|
||||
// Created by Terry Yiu on 2/21/24.
|
||||
//
|
||||
|
||||
#if DEBUG
|
||||
import Foundation
|
||||
|
||||
/// Fake price fetcher that returns a randomized price. Useful for development testing without requiring a network call.
|
||||
class FakePriceFetcher: PriceFetcher {
|
||||
func btcToUsd() async throws -> Decimal? {
|
||||
Decimal(Double.random(in: 10000...100000))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
20
Sources/SatsPrice/Network/ManualPriceFetcher.swift
Normal file
20
Sources/SatsPrice/Network/ManualPriceFetcher.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
// 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
|
||||
//
|
||||
// ManualPriceFetcher.swift
|
||||
// SatsPrice
|
||||
//
|
||||
// Created by Terry Yiu on 8/29/24.
|
||||
//
|
||||
|
||||
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)
|
||||
|
||||
func btcToUsd() async throws -> Decimal? {
|
||||
return price
|
||||
}
|
||||
}
|
||||
15
Sources/SatsPrice/Network/PriceFetcher.swift
Normal file
15
Sources/SatsPrice/Network/PriceFetcher.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
// 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
|
||||
//
|
||||
// PriceFetcher.swift
|
||||
// SatsPrice
|
||||
//
|
||||
// Created by Terry Yiu on 2/19/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol PriceFetcher {
|
||||
func btcToUsd() async throws -> Decimal?
|
||||
}
|
||||
45
Sources/SatsPrice/Network/PriceFetcherDelegator.swift
Normal file
45
Sources/SatsPrice/Network/PriceFetcherDelegator.swift
Normal file
@@ -0,0 +1,45 @@
|
||||
// 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
|
||||
//
|
||||
// PriceFetcherDelegator.swift
|
||||
// SatsPrice
|
||||
//
|
||||
// Created by Terry Yiu on 2/20/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class PriceFetcherDelegator: PriceFetcher {
|
||||
private let coinbasePriceFetcher = CoinbasePriceFetcher()
|
||||
private let coinGeckoPriceFetcher = CoinGeckoPriceFetcher()
|
||||
private let manualPriceFetcher = ManualPriceFetcher()
|
||||
#if DEBUG
|
||||
private let fakePriceFetcher = FakePriceFetcher()
|
||||
#endif
|
||||
|
||||
var priceSource: PriceSource
|
||||
|
||||
init(_ priceSource: PriceSource) {
|
||||
self.priceSource = priceSource
|
||||
}
|
||||
|
||||
private var delegate: PriceFetcher {
|
||||
switch priceSource {
|
||||
case .coinbase:
|
||||
coinbasePriceFetcher
|
||||
case .coingecko:
|
||||
coinGeckoPriceFetcher
|
||||
case .manual:
|
||||
manualPriceFetcher
|
||||
#if DEBUG
|
||||
case .fake:
|
||||
fakePriceFetcher
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func btcToUsd() async throws -> Decimal? {
|
||||
return try await delegate.btcToUsd()
|
||||
}
|
||||
}
|
||||
45
Sources/SatsPrice/Network/PriceSource.swift
Normal file
45
Sources/SatsPrice/Network/PriceSource.swift
Normal file
@@ -0,0 +1,45 @@
|
||||
// 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
|
||||
//
|
||||
// PriceSource.swift
|
||||
// SatsPrice
|
||||
//
|
||||
// Created by Terry Yiu on 2/20/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum PriceSource: CaseIterable, CustomStringConvertible {
|
||||
|
||||
static var allCases: [PriceSource] {
|
||||
#if DEBUG
|
||||
[.coinbase, .coingecko, .manual, .fake]
|
||||
#else
|
||||
[.coinbase, .coingecko, .manual]
|
||||
#endif
|
||||
}
|
||||
|
||||
case coinbase
|
||||
case coingecko
|
||||
case manual
|
||||
|
||||
#if DEBUG
|
||||
case fake
|
||||
#endif
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .coinbase:
|
||||
"Coinbase"
|
||||
case .coingecko:
|
||||
"CoinGecko"
|
||||
case .manual:
|
||||
"Manual"
|
||||
#if DEBUG
|
||||
case .fake:
|
||||
"Fake"
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
27
Sources/SatsPrice/Resources/Localizable.xcstrings
Normal file
27
Sources/SatsPrice/Resources/Localizable.xcstrings
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"" : {
|
||||
|
||||
},
|
||||
"1 BTC to USD" : {
|
||||
|
||||
},
|
||||
"BTC" : {
|
||||
|
||||
},
|
||||
"Last updated: %@" : {
|
||||
|
||||
},
|
||||
"Price Source" : {
|
||||
|
||||
},
|
||||
"Sats" : {
|
||||
|
||||
},
|
||||
"USD" : {
|
||||
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
Sources/SatsPrice/SatsPrice.swift
Normal file
6
Sources/SatsPrice/SatsPrice.swift
Normal file
@@ -0,0 +1,6 @@
|
||||
// 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
|
||||
|
||||
public class SatsPriceModule {
|
||||
}
|
||||
43
Sources/SatsPrice/SatsPriceApp.swift
Normal file
43
Sources/SatsPrice/SatsPriceApp.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
// 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
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
let logger: Logger = Logger(subsystem: "xyz.tyiu.SatsPrice", category: "SatsPrice")
|
||||
|
||||
/// 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 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.
|
||||
public struct RootView : View {
|
||||
public init() {
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ContentView(.coinbase)
|
||||
.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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if !SKIP
|
||||
public protocol SatsPriceApp : App {
|
||||
}
|
||||
|
||||
/// The entry point to the SatsPrice app.
|
||||
/// The concrete implementation is in the SatsPriceApp module.
|
||||
public extension SatsPriceApp {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
173
Sources/SatsPrice/SatsViewModel.swift
Normal file
173
Sources/SatsPrice/SatsViewModel.swift
Normal file
@@ -0,0 +1,173 @@
|
||||
// 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
|
||||
//
|
||||
//
|
||||
// SatsViewModel.swift
|
||||
// SatsPrice
|
||||
//
|
||||
// Created by Terry Yiu on 2/19/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
class SatsViewModel: ObservableObject {
|
||||
@Published var lastUpdated: Date = Date.now
|
||||
|
||||
@Published var btcToUsdStringInternal: String = ""
|
||||
@Published var satsStringInternal: String = ""
|
||||
@Published var btcStringInternal: String = ""
|
||||
@Published var usdStringInternal: String = ""
|
||||
|
||||
var btcToUsdString: String {
|
||||
get {
|
||||
btcToUsdStringInternal
|
||||
}
|
||||
set {
|
||||
btcToUsdStringInternal = newValue
|
||||
|
||||
if let btc, let btcToUsd {
|
||||
usdStringInternal = (btc * btcToUsd).formatString()
|
||||
} else {
|
||||
usdStringInternal = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var satsString: String {
|
||||
get {
|
||||
satsStringInternal
|
||||
}
|
||||
set {
|
||||
satsStringInternal = newValue
|
||||
|
||||
if let sats {
|
||||
#if !SKIP
|
||||
let btc = sats / Decimal(100000000)
|
||||
#else
|
||||
let btc = sats.divide(Decimal(100000000), 20, java.math.RoundingMode.DOWN)
|
||||
#endif
|
||||
btcStringInternal = btc.formatString()
|
||||
if let btcToUsd {
|
||||
usdStringInternal = (btc * btcToUsd).formatString()
|
||||
} else {
|
||||
usdStringInternal = ""
|
||||
}
|
||||
} else {
|
||||
btcStringInternal = ""
|
||||
usdStringInternal = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var btcString: String {
|
||||
get {
|
||||
btcStringInternal
|
||||
}
|
||||
set {
|
||||
btcStringInternal = newValue
|
||||
|
||||
if let btc {
|
||||
let sats = btc * Decimal(100000000)
|
||||
satsStringInternal = sats.formatString()
|
||||
|
||||
if let btcToUsd {
|
||||
usdStringInternal = (btc * btcToUsd).formatString()
|
||||
} else {
|
||||
usdStringInternal = ""
|
||||
}
|
||||
} else {
|
||||
satsStringInternal = ""
|
||||
usdStringInternal = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var usdString: String {
|
||||
get {
|
||||
usdStringInternal
|
||||
}
|
||||
set {
|
||||
usdStringInternal = newValue
|
||||
|
||||
if let usd {
|
||||
if let btcToUsd {
|
||||
#if !SKIP
|
||||
let btc = usd / btcToUsd
|
||||
#else
|
||||
let btc = usd.divide(btcToUsd, 20, java.math.RoundingMode.DOWN)
|
||||
#endif
|
||||
btcStringInternal = btc.formatString()
|
||||
|
||||
let sats = btc * Decimal(100000000)
|
||||
satsStringInternal = sats.formatString()
|
||||
} else {
|
||||
satsStringInternal = ""
|
||||
usdStringInternal = ""
|
||||
}
|
||||
} else {
|
||||
satsStringInternal = ""
|
||||
usdStringInternal = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var btcToUsd: Decimal? {
|
||||
#if !SKIP
|
||||
return Decimal(string: btcToUsdStringInternal)
|
||||
#else
|
||||
do {
|
||||
return Decimal(btcToUsdStringInternal)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var sats: Decimal? {
|
||||
#if !SKIP
|
||||
return Decimal(string: satsStringInternal)
|
||||
#else
|
||||
do {
|
||||
return Decimal(satsStringInternal)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var btc: Decimal? {
|
||||
#if !SKIP
|
||||
return Decimal(string: btcStringInternal)
|
||||
#else
|
||||
do {
|
||||
return Decimal(btcStringInternal)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var usd: Decimal? {
|
||||
#if !SKIP
|
||||
return Decimal(string: usdStringInternal)
|
||||
#else
|
||||
do {
|
||||
return Decimal(usdStringInternal)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
extension Decimal {
|
||||
func formatString() -> String {
|
||||
#if !SKIP
|
||||
return String(describing: self)
|
||||
#else
|
||||
return stripTrailingZeros().toPlainString()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
3
Sources/SatsPrice/Skip/skip.yml
Normal file
3
Sources/SatsPrice/Skip/skip.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Configuration file for https://skip.tools project
|
||||
build:
|
||||
contents:
|
||||
Reference in New Issue
Block a user