Convert SatsPrice to a Skip multiplatform app

This commit is contained in:
2024-08-31 10:24:07 +03:00
parent 29650a3ea4
commit 7f22956557
101 changed files with 1379 additions and 1095 deletions

View 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
}

View 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
}
}
}

View 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
}
}
}

View 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

View 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
}
}

View 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?
}

View 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()
}
}

View 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
}
}
}

View File

@@ -0,0 +1,27 @@
{
"sourceLanguage" : "en",
"strings" : {
"" : {
},
"1 BTC to USD" : {
},
"BTC" : {
},
"Last updated: %@" : {
},
"Price Source" : {
},
"Sats" : {
},
"USD" : {
}
},
"version" : "1.0"
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View 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 {
}

View 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

View 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
}
}

View File

@@ -0,0 +1,3 @@
# Configuration file for https://skip.tools project
build:
contents: