diff --git a/BDKSwiftExampleWallet.xcodeproj/project.pbxproj b/BDKSwiftExampleWallet.xcodeproj/project.pbxproj index 32758d1..4b66f8d 100644 --- a/BDKSwiftExampleWallet.xcodeproj/project.pbxproj +++ b/BDKSwiftExampleWallet.xcodeproj/project.pbxproj @@ -22,6 +22,10 @@ AE7953902A2D5B4400CCB277 /* BDKSwiftExampleWalletError.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE79538F2A2D5B4400CCB277 /* BDKSwiftExampleWalletError.swift */; }; AE7953922A2D5E3100CCB277 /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE7953912A2D5E3100CCB277 /* Date+Extensions.swift */; }; AE7E68962A59A37300368D82 /* BDKSwiftExampleWalletInt+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE7E68952A59A37300368D82 /* BDKSwiftExampleWalletInt+Extensions.swift */; }; + AE7F67052A7446B600CED561 /* PriceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE7F67042A7446B600CED561 /* PriceService.swift */; }; + AE7F67072A744CE200CED561 /* Double+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE7F67062A744CE200CED561 /* Double+Extensions.swift */; }; + AE7F67092A7451AA00CED561 /* PriceResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE7F67082A7451AA00CED561 /* PriceResponse.swift */; }; + AE7F670C2A7451D700CED561 /* CurrencyCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE7F670B2A7451D700CED561 /* CurrencyCode.swift */; }; AE96F6622A424C400055623C /* BDKSwiftExampleWalletReceiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE96F6612A424C400055623C /* BDKSwiftExampleWalletReceiveTests.swift */; }; AE96F6672A4259260055623C /* QRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE96F6662A4259260055623C /* QRCodeView.swift */; }; AEB130C92A44E4850087785B /* TransactionDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEB130C82A44E4850087785B /* TransactionDetailsView.swift */; }; @@ -57,6 +61,10 @@ AE79538F2A2D5B4400CCB277 /* BDKSwiftExampleWalletError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BDKSwiftExampleWalletError.swift; sourceTree = ""; }; AE7953912A2D5E3100CCB277 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = ""; }; AE7E68952A59A37300368D82 /* BDKSwiftExampleWalletInt+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BDKSwiftExampleWalletInt+Extensions.swift"; sourceTree = ""; }; + AE7F67042A7446B600CED561 /* PriceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceService.swift; sourceTree = ""; }; + AE7F67062A744CE200CED561 /* Double+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extensions.swift"; sourceTree = ""; }; + AE7F67082A7451AA00CED561 /* PriceResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceResponse.swift; sourceTree = ""; }; + AE7F670B2A7451D700CED561 /* CurrencyCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyCode.swift; sourceTree = ""; }; AE96F6612A424C400055623C /* BDKSwiftExampleWalletReceiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BDKSwiftExampleWalletReceiveTests.swift; sourceTree = ""; }; AE96F6662A4259260055623C /* QRCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeView.swift; sourceTree = ""; }; AEB130C82A44E4850087785B /* TransactionDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDetailsView.swift; sourceTree = ""; }; @@ -91,6 +99,7 @@ children = ( AE7953912A2D5E3100CCB277 /* Date+Extensions.swift */, A73F7A352A3B778E00B87FC6 /* Int+Extensions.swift */, + AE7F67062A744CE200CED561 /* Double+Extensions.swift */, ); path = Extensions; sourceTree = ""; @@ -101,6 +110,7 @@ AED4CC0B2A1D3A9400CE1831 /* OnboardingView.swift */, AE3902A32A3B4CD900BEC318 /* TabHomeView.swift */, AED4CC0F2A1D522100CE1831 /* WalletView.swift */, + AE7F67042A7446B600CED561 /* PriceService.swift */, AEB130C82A44E4850087785B /* TransactionDetailsView.swift */, AE1C34232A424456008F807A /* ReceiveView.swift */, AE96F6662A4259260055623C /* QRCodeView.swift */, @@ -158,6 +168,7 @@ children = ( AE1C34222A424440008F807A /* App */, AE1C34212A424434008F807A /* BDK Service */, + AE7F670A2A7451B600CED561 /* Model */, AE1C341F2A424415008F807A /* View */, AE1C341E2A42440A008F807A /* Extensions */, AE1C34202A42441F008F807A /* Utilities */, @@ -186,6 +197,15 @@ path = BDKSwiftExampleWalletTests; sourceTree = ""; }; + AE7F670A2A7451B600CED561 /* Model */ = { + isa = PBXGroup; + children = ( + AE7F67082A7451AA00CED561 /* PriceResponse.swift */, + AE7F670B2A7451D700CED561 /* CurrencyCode.swift */, + ); + path = Model; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -296,6 +316,8 @@ buildActionMask = 2147483647; files = ( AE96F6672A4259260055623C /* QRCodeView.swift in Sources */, + AE7F670C2A7451D700CED561 /* CurrencyCode.swift in Sources */, + AE7F67052A7446B600CED561 /* PriceService.swift in Sources */, AE7953902A2D5B4400CCB277 /* BDKSwiftExampleWalletError.swift in Sources */, AE79538E2A2D59F000CCB277 /* Constants.swift in Sources */, AE1C34242A424456008F807A /* ReceiveView.swift in Sources */, @@ -303,6 +325,8 @@ AE7953922A2D5E3100CCB277 /* Date+Extensions.swift in Sources */, AED4CC0A2A1D297600CE1831 /* BDKService.swift in Sources */, AED4CC102A1D522100CE1831 /* WalletView.swift in Sources */, + AE7F67092A7451AA00CED561 /* PriceResponse.swift in Sources */, + AE7F67072A744CE200CED561 /* Double+Extensions.swift in Sources */, A73F7A362A3B778E00B87FC6 /* Int+Extensions.swift in Sources */, AED4CC0C2A1D3A9400CE1831 /* OnboardingView.swift in Sources */, AE1C34262A4248A5008F807A /* SendView.swift in Sources */, diff --git a/BDKSwiftExampleWallet/Extensions/Date+Extensions.swift b/BDKSwiftExampleWallet/Extensions/Date+Extensions.swift index e383b00..66f60e6 100644 --- a/BDKSwiftExampleWallet/Extensions/Date+Extensions.swift +++ b/BDKSwiftExampleWallet/Extensions/Date+Extensions.swift @@ -12,8 +12,10 @@ extension Date { let formatter = DateFormatter() formatter.dateStyle = .short formatter.timeStyle = .short - formatter.timeZone = TimeZone.autoupdatingCurrent // Use the device's time zone + formatter.timeZone = TimeZone.autoupdatingCurrent - return formatter.string(from: self) + let formattedTime = formatter.string(from: self) + + return formattedTime } } diff --git a/BDKSwiftExampleWallet/Extensions/Double+Extensions.swift b/BDKSwiftExampleWallet/Extensions/Double+Extensions.swift new file mode 100644 index 0000000..0433f6e --- /dev/null +++ b/BDKSwiftExampleWallet/Extensions/Double+Extensions.swift @@ -0,0 +1,26 @@ +// +// Double+Extensions.swift +// BDKSwiftExampleWallet +// +// Created by Matthew Ramsden on 7/28/23. +// + +import Foundation + +extension Double { + + func formattedPrice(currencyCode: CurrencyCode) -> String { + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .currency + numberFormatter.currencyCode = currencyCode.rawValue + + return numberFormatter.string(from: NSNumber(value: self)) ?? "\(self)" + } + + func valueInUSD(price: Double) -> String { + let bitcoin = self / 100_000_000.0 + let usdValue = bitcoin * price + return usdValue.formattedPrice(currencyCode: .USD) + } + +} diff --git a/BDKSwiftExampleWallet/Extensions/Int+Extensions.swift b/BDKSwiftExampleWallet/Extensions/Int+Extensions.swift index e40766c..c0a5d09 100644 --- a/BDKSwiftExampleWallet/Extensions/Int+Extensions.swift +++ b/BDKSwiftExampleWallet/Extensions/Int+Extensions.swift @@ -7,6 +7,32 @@ import Foundation +extension UInt32 { + private static var numberFormatter: NumberFormatter = { + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .decimal + + return numberFormatter + }() + + var delimiter: String { + return UInt32.numberFormatter.string(from: NSNumber(value: self)) ?? "" + } +} + +extension UInt64 { + private static var numberFormatter: NumberFormatter = { + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .decimal + + return numberFormatter + }() + + var delimiter: String { + return UInt64.numberFormatter.string(from: NSNumber(value: self)) ?? "" + } +} + extension UInt64 { func formattedSatoshis() -> String { if self == 0 { @@ -39,32 +65,18 @@ extension UInt64 { let date = Date(timeIntervalSince1970: TimeInterval(self)) let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - return dateFormatter.string(from: date) - } -} - -extension UInt64 { - private static var numberFormatter: NumberFormatter = { - let numberFormatter = NumberFormatter() - numberFormatter.numberStyle = .decimal - return numberFormatter - }() - - var delimiter: String { - return UInt64.numberFormatter.string(from: NSNumber(value: self)) ?? "" + return dateFormatter.string(from: date) } } -extension UInt32 { - private static var numberFormatter: NumberFormatter = { - let numberFormatter = NumberFormatter() - numberFormatter.numberStyle = .decimal +extension Int { + func newDateAgo() -> String { + let date = Date(timeIntervalSince1970: TimeInterval(self)) + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + let relativeDate = formatter.localizedString(for: date, relativeTo: Date.now) - return numberFormatter - }() - - var delimiter: String { - return UInt32.numberFormatter.string(from: NSNumber(value: self)) ?? "" + return relativeDate } } diff --git a/BDKSwiftExampleWallet/Model/CurrencyCode.swift b/BDKSwiftExampleWallet/Model/CurrencyCode.swift new file mode 100644 index 0000000..4355e8f --- /dev/null +++ b/BDKSwiftExampleWallet/Model/CurrencyCode.swift @@ -0,0 +1,18 @@ +// +// CurrencyCode.swift +// BDKSwiftExampleWallet +// +// Created by Matthew Ramsden on 7/28/23. +// + +import Foundation + +enum CurrencyCode: String { + case USD + case EUR + case GBP + case CAD + case CHF + case AUD + case JPY +} diff --git a/BDKSwiftExampleWallet/Model/PriceResponse.swift b/BDKSwiftExampleWallet/Model/PriceResponse.swift new file mode 100644 index 0000000..e3da6f4 --- /dev/null +++ b/BDKSwiftExampleWallet/Model/PriceResponse.swift @@ -0,0 +1,53 @@ +// +// PriceResponse.swift +// BDKSwiftExampleWallet +// +// Created by Matthew Ramsden on 7/28/23. +// + +import Foundation + +struct PriceResponse: Codable { + let prices: [Price] + let exchangeRates: ExchangeRates +} + +struct Price: Codable { + let time: Int + let usd: Double + let eur: Double + let gbp: Double + let cad: Double + let chf: Double + let aud: Double + let jpy: Double + + enum CodingKeys: String, CodingKey { + case time + case usd = "USD" + case eur = "EUR" + case gbp = "GBP" + case cad = "CAD" + case chf = "CHF" + case aud = "AUD" + case jpy = "JPY" + } +} + +struct ExchangeRates : Codable { + let uSDEUR : Double? + let uSDGBP : Double? + let uSDCAD : Double? + let uSDCHF : Double? + let uSDAUD : Double? + let uSDJPY : Double? + + enum CodingKeys: String, CodingKey { + case uSDEUR = "USDEUR" + case uSDGBP = "USDGBP" + case uSDCAD = "USDCAD" + case uSDCHF = "USDCHF" + case uSDAUD = "USDAUD" + case uSDJPY = "USDJPY" + } +} diff --git a/BDKSwiftExampleWallet/View/PriceService.swift b/BDKSwiftExampleWallet/View/PriceService.swift new file mode 100644 index 0000000..aa00010 --- /dev/null +++ b/BDKSwiftExampleWallet/View/PriceService.swift @@ -0,0 +1,27 @@ +// +// PriceService.swift +// BDKSwiftExampleWallet +// +// Created by Matthew Ramsden on 7/28/23. +// + +import Foundation + +struct PriceService { + func hourlyPrice() async throws -> PriceResponse { + guard let url = URL(string: "https://mempool.space/api/v1/historical-price") else { throw PriceServiceError.invalidURL } + let (data, response) = try await URLSession.shared.data(from: url) + guard let httpResponse = response as? HTTPURLResponse, + 200...299 ~= httpResponse.statusCode + else { throw PriceServiceError.invalidServerResponse } + let jsonDecoder = JSONDecoder() + let jsonObject = try jsonDecoder.decode(PriceResponse.self, from: data) + return jsonObject + } +} + +enum PriceServiceError: Error { + case invalidURL + case invalidServerResponse + case serialization +} diff --git a/BDKSwiftExampleWallet/View/TabHomeView.swift b/BDKSwiftExampleWallet/View/TabHomeView.swift index fca277e..f5e4292 100644 --- a/BDKSwiftExampleWallet/View/TabHomeView.swift +++ b/BDKSwiftExampleWallet/View/TabHomeView.swift @@ -16,7 +16,7 @@ struct TabHomeView: View { TabView { - WalletView(viewModel: .init()) + WalletView(viewModel: .init(priceService: .init())) .tabItem { Label( "Wallet", diff --git a/BDKSwiftExampleWallet/View/WalletView.swift b/BDKSwiftExampleWallet/View/WalletView.swift index 541b069..883a86a 100644 --- a/BDKSwiftExampleWallet/View/WalletView.swift +++ b/BDKSwiftExampleWallet/View/WalletView.swift @@ -21,6 +21,38 @@ class WalletViewModel: ObservableObject { // Transactions @Published var transactionDetails: [TransactionDetails] = [] + // Price + @Published var price: Double = 0.0 + @Published var time: Int? + @Published var satsPrice: String = "0" + let priceService: PriceService + + init(priceService: PriceService) { + self.priceService = priceService + } + + func getPrice() async { + do { + let response = try await priceService.hourlyPrice() + if let latestPrice = response.prices.first?.usd { + DispatchQueue.main.async { + self.price = latestPrice + } + } + if let latestTime = response.prices.first?.time { + DispatchQueue.main.async { + self.time = latestTime + } + } + } catch { + print("priceMem error: \(error.localizedDescription)") + } + } + + private func valueInUSD() { + self.satsPrice = Double(balanceTotal).valueInUSD(price: price) + } + func getBalance() { do { let balance = try BDKService.shared.getBalance() @@ -53,6 +85,7 @@ class WalletViewModel: ObservableObject { self.lastSyncTime = Date() self.getBalance() self.getTransactions() + self.valueInUSD() } } catch { DispatchQueue.main.async { @@ -111,20 +144,33 @@ struct WalletView: View { .ignoresSafeArea() VStack(spacing: 20) { - Text("Your Balance") - .bold() - .foregroundColor(.secondary) - HStack(spacing: 15) { - Image(systemName: "bitcoinsign") - .foregroundColor(.secondary) - .font(.title) - Text(viewModel.balanceTotal.formattedSatoshis()) - Text("sats") + + VStack(spacing: 10) { + Text("Your Balance") + .bold() .foregroundColor(.secondary) + HStack(spacing: 15) { + Image(systemName: "bitcoinsign") + .foregroundColor(.secondary) + .font(.title) + Text(viewModel.balanceTotal.formattedSatoshis()) + Text("sats") + .foregroundColor(.secondary) + } + .font(.largeTitle) + .lineLimit(1) + .minimumScaleFactor(0.5) + + HStack { + Text(viewModel.satsPrice) + if let time = viewModel.time?.newDateAgo() { + Text(time) + } + } + .foregroundColor(.secondary) + .font(.footnote) + .padding(.top, 10.0) } - .font(.largeTitle) - .lineLimit(1) - .minimumScaleFactor(0.5) VStack { HStack { @@ -227,6 +273,7 @@ struct WalletView: View { } .task { await viewModel.sync() + await viewModel.getPrice() } } @@ -237,9 +284,9 @@ struct WalletView: View { struct WalletView_Previews: PreviewProvider { static var previews: some View { - WalletView(viewModel: .init()) + WalletView(viewModel: .init(priceService: .init())) .previewDisplayName("Light Mode") - WalletView(viewModel: .init()) + WalletView(viewModel: .init(priceService: .init())) .environment(\.colorScheme, .dark) .previewDisplayName("Dark Mode") } diff --git a/BDKSwiftExampleWalletTests/BDKSwiftExampleWalletWalletViewModelTests.swift b/BDKSwiftExampleWalletTests/BDKSwiftExampleWalletWalletViewModelTests.swift index 12c0bde..99e1e23 100644 --- a/BDKSwiftExampleWalletTests/BDKSwiftExampleWalletWalletViewModelTests.swift +++ b/BDKSwiftExampleWalletTests/BDKSwiftExampleWalletWalletViewModelTests.swift @@ -13,7 +13,7 @@ final class BDKSwiftExampleWalletWalletViewModelTests: XCTestCase { func testWalletViewModel() async { // Set up viewModel - let viewModel = WalletViewModel() + let viewModel = WalletViewModel(priceService: .init()) XCTAssertEqual(viewModel.walletSyncState, .notStarted) // Simulate successful sync() call