ADD: Market Price intent

This commit is contained in:
Marcos Rodriguez Velez 2024-10-27 18:14:37 -04:00
parent fda04c2b02
commit ee739d347d
14 changed files with 760 additions and 522 deletions

View file

@ -125,6 +125,18 @@
B4742E992CCDBE8300380EEE /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B4742E962CCDBE8300380EEE /* Localizable.xcstrings */; };
B4742E9A2CCDBE8300380EEE /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B4742E962CCDBE8300380EEE /* Localizable.xcstrings */; };
B4742E9B2CCDBE8300380EEE /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B4742E962CCDBE8300380EEE /* Localizable.xcstrings */; };
B48630D62CCEE67100A8425C /* PriceWidgetProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48630D52CCEE67100A8425C /* PriceWidgetProvider.swift */; };
B48630DD2CCEE7AC00A8425C /* PriceWidgetEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48630DC2CCEE7AC00A8425C /* PriceWidgetEntry.swift */; };
B48630DE2CCEE7AC00A8425C /* PriceWidgetEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48630DC2CCEE7AC00A8425C /* PriceWidgetEntry.swift */; };
B48630E02CCEE7C800A8425C /* PriceWidgetEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48630DF2CCEE7C800A8425C /* PriceWidgetEntryView.swift */; };
B48630E12CCEE7C800A8425C /* PriceWidgetEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48630DF2CCEE7C800A8425C /* PriceWidgetEntryView.swift */; };
B48630E52CCEE8B800A8425C /* PriceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D6CA5272558EC52009312A5 /* PriceView.swift */; };
B48630E72CCEE91900A8425C /* PriceWidgetProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48630D52CCEE67100A8425C /* PriceWidgetProvider.swift */; };
B48630E82CCEE92400A8425C /* PriceWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D6CA4BC255872E3009312A5 /* PriceWidget.swift */; };
B48630EA2CCEED8400A8425C /* PriceIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48630D02CCEE3B300A8425C /* PriceIntent.swift */; };
B48630EC2CCEEEA700A8425C /* WalletAppShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48630EB2CCEEEA700A8425C /* WalletAppShortcuts.swift */; };
B48630ED2CCEEEB000A8425C /* WalletAppShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48630EB2CCEEEA700A8425C /* WalletAppShortcuts.swift */; };
B48630EE2CCEEEE900A8425C /* PriceIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48630D02CCEE3B300A8425C /* PriceIntent.swift */; };
B48A6A292C1DF01000030AB9 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = B48A6A282C1DF01000030AB9 /* KeychainSwift */; };
B4AB225D2B02AD12001F4328 /* XMLParserDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4AB225C2B02AD12001F4328 /* XMLParserDelegate.swift */; };
B4AB225E2B02AD12001F4328 /* XMLParserDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4AB225C2B02AD12001F4328 /* XMLParserDelegate.swift */; };
@ -355,6 +367,11 @@
B4742E962CCDBE8300380EEE /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
B4742E9C2CCDC31300380EEE /* en_US */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en_US; path = en_US.lproj/Interface.strings; sourceTree = "<group>"; };
B47B21EB2B2128B8001F6690 /* BlueWalletUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlueWalletUITests.swift; sourceTree = "<group>"; };
B48630D02CCEE3B300A8425C /* PriceIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceIntent.swift; sourceTree = "<group>"; };
B48630D52CCEE67100A8425C /* PriceWidgetProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceWidgetProvider.swift; sourceTree = "<group>"; };
B48630DC2CCEE7AC00A8425C /* PriceWidgetEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceWidgetEntry.swift; sourceTree = "<group>"; };
B48630DF2CCEE7C800A8425C /* PriceWidgetEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceWidgetEntryView.swift; sourceTree = "<group>"; };
B48630EB2CCEEEA700A8425C /* WalletAppShortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletAppShortcuts.swift; sourceTree = "<group>"; };
B49038D82B8FBAD300A8164A /* BlueWalletUITest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlueWalletUITest.swift; sourceTree = "<group>"; };
B4AB225C2B02AD12001F4328 /* XMLParserDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XMLParserDelegate.swift; sourceTree = "<group>"; };
B4B1A4612BFA73110072E3BB /* WidgetHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetHelper.swift; sourceTree = "<group>"; };
@ -527,6 +544,10 @@
6D6CA4BB255872E3009312A5 /* PriceWidget */ = {
isa = PBXGroup;
children = (
B48630DF2CCEE7C800A8425C /* PriceWidgetEntryView.swift */,
B48630DC2CCEE7AC00A8425C /* PriceWidgetEntry.swift */,
B48630D52CCEE67100A8425C /* PriceWidgetProvider.swift */,
B48630D02CCEE3B300A8425C /* PriceIntent.swift */,
6D6CA4BC255872E3009312A5 /* PriceWidget.swift */,
);
path = PriceWidget;
@ -551,6 +572,7 @@
6DD4109F266CADF10087DE03 /* Widgets */ = {
isa = PBXGroup;
children = (
B48630EB2CCEEEA700A8425C /* WalletAppShortcuts.swift */,
6DD410C3266CCB780087DE03 /* WidgetsExtension.entitlements */,
6DD410A0266CADF10087DE03 /* Widgets.swift */,
6DD410A4266CADF40087DE03 /* Info.plist */,
@ -1181,20 +1203,26 @@
B44033E92BCC371A00162242 /* MarketData.swift in Sources */,
B44033CA2BCC350A00162242 /* Currency.swift in Sources */,
B44033EE2BCC374500162242 /* Numeric+abbreviated.swift in Sources */,
B48630E82CCEE92400A8425C /* PriceWidget.swift in Sources */,
B44033DD2BCC36C300162242 /* LatestTransaction.swift in Sources */,
6D32C5C62596CE3A008C077C /* EventEmitter.m in Sources */,
B44033FE2BCC37D700162242 /* MarketAPI.swift in Sources */,
B450109C2C0FCD8A00619044 /* Utilities.swift in Sources */,
B48630E52CCEE8B800A8425C /* PriceView.swift in Sources */,
B48630E72CCEE91900A8425C /* PriceWidgetProvider.swift in Sources */,
B48630ED2CCEEEB000A8425C /* WalletAppShortcuts.swift in Sources */,
B45010A62C1507DE00619044 /* CustomSegmentedControlManager.m in Sources */,
B44033CE2BCC352900162242 /* UserDefaultsGroup.swift in Sources */,
13B07FC11A68108700A75B9A /* main.m in Sources */,
B461B852299599F800E431AA /* AppDelegate.mm in Sources */,
B44033F42BCC377F00162242 /* WidgetData.swift in Sources */,
B44033C42BCC332400162242 /* Balance.swift in Sources */,
B48630EE2CCEEEE900A8425C /* PriceIntent.swift in Sources */,
B44034072BCC38A000162242 /* FiatUnit.swift in Sources */,
B44034002BCC37F800162242 /* Bundle+decode.swift in Sources */,
B44033E22BCC36CB00162242 /* Placeholders.swift in Sources */,
B4B1A4622BFA73110072E3BB /* WidgetHelper.swift in Sources */,
B48630E12CCEE7C800A8425C /* PriceWidgetEntryView.swift in Sources */,
B44033DA2BCC369A00162242 /* Colors.swift in Sources */,
B44033D32BCC368800162242 /* UserDefaultsGroupKey.swift in Sources */,
32B5A32A2334450100F8D608 /* Bridge.swift in Sources */,
@ -1202,6 +1230,7 @@
B44033E42BCC36FF00162242 /* WalletData.swift in Sources */,
B44033BF2BCC32F800162242 /* BitcoinUnit.swift in Sources */,
B44034052BCC389200162242 /* XMLParserDelegate.swift in Sources */,
B48630DD2CCEE7AC00A8425C /* PriceWidgetEntry.swift in Sources */,
B44033F92BCC379200162242 /* WidgetDataStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -1211,8 +1240,10 @@
buildActionMask = 2147483647;
files = (
6DD410BE266CAF5C0087DE03 /* SendReceiveButtons.swift in Sources */,
B48630E02CCEE7C800A8425C /* PriceWidgetEntryView.swift in Sources */,
6DD410B4266CAF5C0087DE03 /* MarketAPI.swift in Sources */,
B40FC3FA29CCD1D00007EBAC /* SwiftTCPClient.swift in Sources */,
B48630EC2CCEEEA700A8425C /* WalletAppShortcuts.swift in Sources */,
6DD410A1266CADF10087DE03 /* Widgets.swift in Sources */,
B450109D2C0FCD9F00619044 /* Utilities.swift in Sources */,
6DD410AC266CAE470087DE03 /* PriceWidget.swift in Sources */,
@ -1220,8 +1251,10 @@
B44033D52BCC368800162242 /* UserDefaultsGroupKey.swift in Sources */,
6DD410B2266CAF5C0087DE03 /* WalletInformationView.swift in Sources */,
B44034022BCC37F800162242 /* Bundle+decode.swift in Sources */,
B48630D62CCEE67100A8425C /* PriceWidgetProvider.swift in Sources */,
B44033CC2BCC350A00162242 /* Currency.swift in Sources */,
6DD410B6266CAF5C0087DE03 /* PriceView.swift in Sources */,
B48630DE2CCEE7AC00A8425C /* PriceWidgetEntry.swift in Sources */,
6DD410B3266CAF5C0087DE03 /* Colors.swift in Sources */,
B44033C12BCC32F800162242 /* BitcoinUnit.swift in Sources */,
6DD410BB266CAF5C0087DE03 /* MarketView.swift in Sources */,
@ -1241,6 +1274,7 @@
6DD410B1266CAF5C0087DE03 /* MarketAPI+Electrum.swift in Sources */,
6DD410B9266CAF5C0087DE03 /* UserDefaultsGroup.swift in Sources */,
6DD410B8266CAF5C0087DE03 /* UserDefaultsExtension.swift in Sources */,
B48630EA2CCEED8400A8425C /* PriceIntent.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -1580,7 +1614,7 @@
"DEVELOPMENT_TEAM[sdk=macosx*]" = A7W54YZ4WU;
GCC_C_LANGUAGE_STANDARD = gnu11;
INFOPLIST_FILE = Widgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -1636,7 +1670,7 @@
"DEVELOPMENT_TEAM[sdk=macosx*]" = A7W54YZ4WU;
GCC_C_LANGUAGE_STANDARD = gnu11;
INFOPLIST_FILE = Widgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",

View file

@ -206,7 +206,18 @@
<key>UIFileSharingEnabled</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<string>LaunchScreen</string>
<key>NSAppIntents</key>
<array>
<dict>
<key>INIntentClassName</key>
<string>PriceView</string>
<key>IntentName</key>
<string>Bitcoin Price</string>
<key>IntentDescription</key>
<string>Quickly view the current Bitcoin market rate.</string>
</dict>
</array>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>

View file

@ -1,6 +1,78 @@
{
"sourceLanguage" : "en_US",
"strings" : {
"at %@" : {
},
"Balance" : {
},
"Bitcoin (%@)" : {
},
"BTC" : {
},
"Checked at %@" : {
},
"Current Bitcoin Market Rate" : {
},
"Current Bitcoin Price: %@" : {
},
"from" : {
},
"From %@" : {
},
"Last Updated" : {
},
"Latest transaction" : {
},
"Market" : {
},
"Market Rate" : {
},
"Next Block" : {
},
"Price" : {
},
"receive" : {
},
"Sats/%@" : {
},
"send" : {
},
"View the current Bitcoin market rate." : {
},
"View the current market information." : {
},
"View the current price of Bitcoin" : {
},
"View the current price of Bitcoin." : {
},
"View your accumulated balance." : {
},
"View your total wallet balance and network prices." : {
},
"VIEW_ADDRESS_TRANSACTIONS_TITLE" : {
"extractionState" : "manual",
"localizations" : {
@ -56,6 +128,9 @@
}
}
}
},
"Wallet and Market" : {
}
},
"version" : "1.0"

View file

@ -2,230 +2,205 @@
// MarketAPI.swift
//
// Created by Marcos Rodriguez on 11/2/19.
//
//
import Foundation
var numberFormatter: NumberFormatter {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = 0
formatter.locale = Locale.current
return formatter
}
class MarketAPI {
private static func buildURLString(source: String, endPointKey: String) -> String {
switch source {
case "Yadio":
return "https://api.yadio.io/json/\(endPointKey)"
case "YadioConvert":
return "https://api.yadio.io/convert/1/BTC/\(endPointKey)"
case "Exir":
return "https://api.exir.io/v1/ticker?symbol=btc-irt"
case "coinpaprika":
return "https://api.coinpaprika.com/v1/tickers/btc-bitcoin?quotes=INR"
case "Bitstamp":
return "https://www.bitstamp.net/api/v2/ticker/btc\(endPointKey.lowercased())"
case "Coinbase":
return "https://api.coinbase.com/v2/prices/BTC-\(endPointKey.uppercased())/buy"
case "CoinGecko":
return "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=\(endPointKey.lowercased())"
case "BNR":
return "https://www.bnr.ro/nbrfxrates.xml"
case "Kraken":
return "https://api.kraken.com/0/public/Ticker?pair=XXBTZ\(endPointKey.uppercased())"
default:
return "https://api.coindesk.com/v1/bpi/currentprice/\(endPointKey).json"
}
}
private static func handleDefaultData(data: Data, source: String, endPointKey: String, completion: @escaping ((WidgetDataStore?, Error?) -> Void)) {
guard let json = (try? JSONSerialization.jsonObject(with: data, options: [])) as? Dictionary<String, Any> else {
completion(nil, CurrencyError(errorDescription: "JSON parsing error."))
return
}
parseJSONBasedOnSource(json: json, source: source, endPointKey: endPointKey, completion: completion)
private static func buildURLString(source: String, endPointKey: String) -> String {
switch source {
case "Yadio":
return "https://api.yadio.io/json/\(endPointKey)"
case "YadioConvert":
return "https://api.yadio.io/convert/1/BTC/\(endPointKey)"
case "Exir":
return "https://api.exir.io/v1/ticker?symbol=btc-irt"
case "coinpaprika":
return "https://api.coinpaprika.com/v1/tickers/btc-bitcoin?quotes=INR"
case "Bitstamp":
return "https://www.bitstamp.net/api/v2/ticker/btc\(endPointKey.lowercased())"
case "Coinbase":
return "https://api.coinbase.com/v2/prices/BTC-\(endPointKey.uppercased())/buy"
case "CoinGecko":
return "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=\(endPointKey.lowercased())"
case "BNR":
return "https://www.bnr.ro/nbrfxrates.xml"
case "Kraken":
return "https://api.kraken.com/0/public/Ticker?pair=XXBTZ\(endPointKey.uppercased())"
default:
return "https://api.coindesk.com/v1/bpi/currentprice/\(endPointKey).json"
}
}
private static func parseJSONBasedOnSource(json: Dictionary<String, Any>, source: String, endPointKey: String, completion: @escaping ((WidgetDataStore?, Error?) -> Void)) {
var latestRateDataStore: WidgetDataStore?
switch source {
case "Yadio":
if let rateDict = json[endPointKey] as? [String: Any],
let rateDouble = rateDict["price"] as? Double,
let lastUpdated = rateDict["timestamp"] as? Int {
let unix = Double(lastUpdated / 1_000)
let lastUpdatedString = ISO8601DateFormatter().string(from: Date(timeIntervalSince1970: unix))
latestRateDataStore = WidgetDataStore(rate: String(rateDouble), lastUpdate: lastUpdatedString, rateDouble: rateDouble)
completion(latestRateDataStore, nil)
} else {
completion(nil, CurrencyError(errorDescription: "Data formatting error for source: \(source)"))
}
case "YadioConvert":
guard let rateDouble = json["rate"] as? Double,
let lastUpdated = json["timestamp"] as? Int
else { break }
let unix = Double(lastUpdated / 1_000)
let lastUpdatedString = ISO8601DateFormatter().string(from: Date(timeIntervalSince1970: unix))
latestRateDataStore = WidgetDataStore(rate: String(rateDouble), lastUpdate: lastUpdatedString, rateDouble: rateDouble)
completion(latestRateDataStore, nil)
case "CoinGecko":
if let bitcoinDict = json["bitcoin"] as? [String: Any],
let rateDouble = bitcoinDict[endPointKey.lowercased()] as? Double {
let lastUpdatedString = ISO8601DateFormatter().string(from: Date())
latestRateDataStore = WidgetDataStore(rate: String(rateDouble), lastUpdate: lastUpdatedString, rateDouble: rateDouble)
completion(latestRateDataStore, nil)
} else {
completion(nil, CurrencyError(errorDescription: "Data formatting error for source: \(source)"))
}
case "Exir":
if let rateDouble = json["last"] as? Double {
let lastUpdatedString = ISO8601DateFormatter().string(from: Date())
latestRateDataStore = WidgetDataStore(rate: String(rateDouble), lastUpdate: lastUpdatedString, rateDouble: rateDouble)
completion(latestRateDataStore, nil)
} else {
completion(nil, CurrencyError(errorDescription: "Data formatting error for source: \(source)"))
}
case "Bitstamp":
if let rateString = json["last"] as? String, let rateDouble = Double(rateString) {
let lastUpdatedString = ISO8601DateFormatter().string(from: Date())
latestRateDataStore = WidgetDataStore(rate: rateString, lastUpdate: lastUpdatedString, rateDouble: rateDouble)
completion(latestRateDataStore, nil)
} else {
completion(nil, CurrencyError(errorDescription: "Data formatting error for source: \(source)"))
}
case "coinpaprika":
if let quotesDict = json["quotes"] as? [String: Any],
let inrDict = quotesDict["INR"] as? [String: Any],
let rateDouble = inrDict["price"] as? Double {
private static func handleDefaultData(data: Data, source: String, endPointKey: String) throws -> WidgetDataStore? {
guard let json = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any] else {
throw CurrencyError(errorDescription: "JSON parsing error.")
}
let rateString = String(rateDouble)
let lastUpdatedString = ISO8601DateFormatter().string(from: Date())
latestRateDataStore = WidgetDataStore(rate: rateString, lastUpdate: lastUpdatedString, rateDouble: rateDouble)
completion(latestRateDataStore, nil)
} else {
completion(nil, CurrencyError(errorDescription: "Data formatting error for source: \(source)"))
return try parseJSONBasedOnSource(json: json, source: source, endPointKey: endPointKey)
}
private static func parseJSONBasedOnSource(json: [String: Any], source: String, endPointKey: String) throws -> WidgetDataStore? {
var latestRateDataStore: WidgetDataStore?
switch source {
case "Yadio":
if let rateDict = json[endPointKey] as? [String: Any],
let rateDouble = rateDict["price"] as? Double,
let lastUpdated = rateDict["timestamp"] as? Int {
let unix = Double(lastUpdated / 1_000)
let lastUpdatedString = ISO8601DateFormatter().string(from: Date(timeIntervalSince1970: unix))
latestRateDataStore = WidgetDataStore(rate: String(rateDouble), lastUpdate: lastUpdatedString, rateDouble: rateDouble)
return latestRateDataStore
} else {
throw CurrencyError(errorDescription: "Data formatting error for source: \(source)")
}
case "YadioConvert":
guard let rateDouble = json["rate"] as? Double,
let lastUpdated = json["timestamp"] as? Int else {
throw CurrencyError(errorDescription: "Data formatting error for source: \(source)")
}
let unix = Double(lastUpdated / 1_000)
let lastUpdatedString = ISO8601DateFormatter().string(from: Date(timeIntervalSince1970: unix))
latestRateDataStore = WidgetDataStore(rate: String(rateDouble), lastUpdate: lastUpdatedString, rateDouble: rateDouble)
return latestRateDataStore
case "CoinGecko":
if let bitcoinDict = json["bitcoin"] as? [String: Any],
let rateDouble = bitcoinDict[endPointKey.lowercased()] as? Double {
let lastUpdatedString = ISO8601DateFormatter().string(from: Date())
latestRateDataStore = WidgetDataStore(rate: String(rateDouble), lastUpdate: lastUpdatedString, rateDouble: rateDouble)
return latestRateDataStore
} else {
throw CurrencyError(errorDescription: "Data formatting error for source: \(source)")
}
case "Exir":
if let rateDouble = json["last"] as? Double {
let lastUpdatedString = ISO8601DateFormatter().string(from: Date())
latestRateDataStore = WidgetDataStore(rate: String(rateDouble), lastUpdate: lastUpdatedString, rateDouble: rateDouble)
return latestRateDataStore
} else {
throw CurrencyError(errorDescription: "Data formatting error for source: \(source)")
}
case "Bitstamp":
if let rateString = json["last"] as? String, let rateDouble = Double(rateString) {
let lastUpdatedString = ISO8601DateFormatter().string(from: Date())
latestRateDataStore = WidgetDataStore(rate: rateString, lastUpdate: lastUpdatedString, rateDouble: rateDouble)
return latestRateDataStore
} else {
throw CurrencyError(errorDescription: "Data formatting error for source: \(source)")
}
case "coinpaprika":
if let quotesDict = json["quotes"] as? [String: Any],
let currencyDict = quotesDict[endPointKey.uppercased()] as? [String: Any],
let rateDouble = currencyDict["price"] as? Double {
let rateString = String(rateDouble)
let lastUpdatedString = ISO8601DateFormatter().string(from: Date())
latestRateDataStore = WidgetDataStore(rate: rateString, lastUpdate: lastUpdatedString, rateDouble: rateDouble)
return latestRateDataStore
} else {
throw CurrencyError(errorDescription: "Data formatting error for source: \(source)")
}
case "Coinbase":
if let data = json["data"] as? [String: Any],
let rateString = data["amount"] as? String,
let rateDouble = Double(rateString) {
let lastUpdatedString = ISO8601DateFormatter().string(from: Date())
latestRateDataStore = WidgetDataStore(rate: rateString, lastUpdate: lastUpdatedString, rateDouble: rateDouble)
return latestRateDataStore
} else {
throw CurrencyError(errorDescription: "Data formatting error for source: \(source)")
}
case "BNR":
throw CurrencyError(errorDescription: "BNR data source is not yet implemented")
case "Kraken":
if let result = json["result"] as? [String: Any],
let tickerData = result["XXBTZ\(endPointKey.uppercased())"] as? [String: Any],
let c = tickerData["c"] as? [String],
let rateString = c.first,
let rateDouble = Double(rateString) {
let lastUpdatedString = ISO8601DateFormatter().string(from: Date())
latestRateDataStore = WidgetDataStore(rate: rateString, lastUpdate: lastUpdatedString, rateDouble: rateDouble)
return latestRateDataStore
} else {
if let errorMessage = json["error"] as? [String] {
throw CurrencyError(errorDescription: "Kraken API error: \(errorMessage.joined(separator: ", "))")
} else {
throw CurrencyError(errorDescription: "Data formatting error for source: \(source)")
}
}
default:
throw CurrencyError(errorDescription: "Unsupported data source \(source)")
}
}
private static func handleBNRData(data: Data) async throws -> WidgetDataStore? {
let parser = XMLParser(data: data)
let delegate = BNRXMLParserDelegate()
parser.delegate = delegate
if parser.parse(), let usdToRonRate = delegate.usdRate {
let coinGeckoUrl = URL(string: "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd")!
let (data, _) = try await URLSession.shared.data(from: coinGeckoUrl)
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let bitcoinDict = json["bitcoin"] as? [String: Double],
let btcToUsdRate = bitcoinDict["usd"] {
let btcToRonRate = btcToUsdRate * usdToRonRate
let lastUpdatedString = ISO8601DateFormatter().string(from: Date())
let latestRateDataStore = WidgetDataStore(rate: String(btcToRonRate), lastUpdate: lastUpdatedString, rateDouble: btcToRonRate)
return latestRateDataStore
} else {
throw CurrencyError()
}
} else {
throw CurrencyError(errorDescription: "XML parsing error.")
}
}
case "Coinbase":
if let data = json["data"] as? [String: Any],
let rateString = data["amount"] as? String,
let rateDouble = Double(rateString) {
let lastUpdatedString = ISO8601DateFormatter().string(from: Date())
latestRateDataStore = WidgetDataStore(rate: rateString, lastUpdate: lastUpdatedString, rateDouble: rateDouble)
completion(latestRateDataStore, nil)
} else {
completion(nil, CurrencyError(errorDescription: "Data formatting error for source: \(source)"))
}
case "BNR":
completion(nil, CurrencyError(errorDescription: "BNR data source is not yet implemented"))
case "Kraken":
if let result = json["result"] as? [String: Any],
let tickerData = result["XXBTZ\(endPointKey.uppercased())"] as? [String: Any],
let c = tickerData["c"] as? [String],
let rateString = c.first,
let rateDouble = Double(rateString) {
let lastUpdatedString = ISO8601DateFormatter().string(from: Date())
latestRateDataStore = WidgetDataStore(rate: rateString, lastUpdate: lastUpdatedString, rateDouble: rateDouble)
completion(latestRateDataStore, nil)
} else {
if let errorMessage = json["error"] as? [String] {
completion(nil, CurrencyError(errorDescription: "Kraken API error: \(errorMessage.joined(separator: ", "))"))
} else {
completion(nil, CurrencyError(errorDescription: "Data formatting error for source: \(source)"))
}
}
static func fetchPrice(currency: String) async throws -> WidgetDataStore? {
let currencyToFiatUnit = fiatUnit(currency: currency)
guard let source = currencyToFiatUnit?.source, let endPointKey = currencyToFiatUnit?.endPointKey else {
throw CurrencyError(errorDescription: "Invalid currency unit or endpoint.")
}
let urlString = buildURLString(source: source, endPointKey: endPointKey)
guard let url = URL(string: urlString) else {
throw CurrencyError(errorDescription: "Invalid URL.")
}
default:
completion(nil, CurrencyError(errorDescription: "Unsupported data source \(source)"))
}
}
private static func handleBNRData(data: Data, completion: @escaping ((WidgetDataStore?, Error?) -> Void)) {
let parser = XMLParser(data: data)
let delegate = BNRXMLParserDelegate()
parser.delegate = delegate
if parser.parse(), let usdToRonRate = delegate.usdRate {
let coinGeckoUrl = URL(string: "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd")!
URLSession.shared.dataTask(with: coinGeckoUrl) { data, _, error in
guard let data = data, error == nil else {
completion(nil, error ?? CurrencyError())
return
}
return try await fetchData(url: url, source: source, endPointKey: endPointKey)
}
do {
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let bitcoinDict = json["bitcoin"] as? [String: Double],
let btcToUsdRate = bitcoinDict["usd"] {
let btcToRonRate = btcToUsdRate * usdToRonRate
let lastUpdatedString = ISO8601DateFormatter().string(from: Date())
let latestRateDataStore = WidgetDataStore(rate: String(btcToRonRate), lastUpdate: lastUpdatedString, rateDouble: btcToRonRate)
completion(latestRateDataStore, nil)
} else {
completion(nil, CurrencyError())
}
} catch {
completion(nil, error)
}
}.resume()
} else {
completion(nil, CurrencyError(errorDescription: "XML parsing error."))
}
}
static func fetchPrice(currency: String, completion: @escaping ((WidgetDataStore?, Error?) -> Void)) {
let currencyToFiatUnit = fiatUnit(currency: currency)
guard let source = currencyToFiatUnit?.source, let endPointKey = currencyToFiatUnit?.endPointKey else {
completion(nil, CurrencyError(errorDescription: "Invalid currency unit or endpoint."))
return
}
let urlString = buildURLString(source: source, endPointKey: endPointKey)
guard let url = URL(string: urlString) else {
completion(nil, CurrencyError(errorDescription: "Invalid URL."))
return
}
fetchData(url: url, source: source, endPointKey: endPointKey, completion: completion)
}
private static func fetchData(url: URL, source: String, endPointKey: String, retries: Int = 3, completion: @escaping ((WidgetDataStore?, Error?) -> Void)) {
URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
if retries > 0 {
fetchData(url: url, source: source, endPointKey: endPointKey, retries: retries - 1, completion: completion)
} else {
completion(nil, error)
}
return
}
guard let data = data else {
if retries > 0 {
fetchData(url: url, source: source, endPointKey: endPointKey, retries: retries - 1, completion: completion)
} else {
completion(nil, CurrencyError(errorDescription: "Data not found."))
}
return
}
if source == "BNR" {
handleBNRData(data: data, completion: completion)
} else {
handleDefaultData(data: data, source: source, endPointKey: endPointKey, completion: completion)
}
}.resume()
}
private static func fetchData(url: URL, source: String, endPointKey: String, retries: Int = 3) async throws -> WidgetDataStore? {
do {
let (data, _) = try await URLSession.shared.data(from: url)
if source == "BNR" {
return try await handleBNRData(data: data)
} else {
return try handleDefaultData(data: data, source: source, endPointKey: endPointKey)
}
} catch {
if retries > 0 {
return try await fetchData(url: url, source: source, endPointKey: endPointKey, retries: retries - 1)
} else {
throw error
}
}
}
static func fetchPrice(currency: String, completion: @escaping ((WidgetDataStore?, Error?) -> Void)) {
Task {
do {
if let dataStore = try await fetchPrice(currency: currency) {
completion(dataStore, nil)
} else {
completion(nil, CurrencyError(errorDescription: "No data received."))
}
} catch {
completion(nil, error)
}
}
}
}

View file

@ -8,7 +8,7 @@
import Foundation
struct MarketData:Codable {
public struct MarketData:Codable {
var nextBlock: String
var sats: String
var price: String

View file

@ -51,3 +51,38 @@ func calcEstimateFeeFromFeeHistogram(numberOfBlocks: Int, feeHistogram: [[Double
return max(2, percentile(histogramFlat, p: 0.5))
}
var numberFormatter: NumberFormatter {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = 0
formatter.locale = Locale.current
return formatter
}
extension Double {
func formattedPriceString() -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = 0
return formatter.string(from: NSNumber(value: self)) ?? "--"
}
func formattedCurrencyString() -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.maximumFractionDigits = 0
formatter.currencySymbol = fiatUnit(currency: Currency.getUserPreferredCurrency())?.symbol
return formatter.string(from: NSNumber(value: self)) ?? "--"
}
}
extension Date {
var formattedDate: String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
return formatter.string(from: self)
}
}

View file

@ -0,0 +1,75 @@
import AppIntents
import SwiftUI
@available(iOS 16.0, *)
struct PriceIntent: AppIntent {
static var title: LocalizedStringResource = "Market Rate"
static var description = IntentDescription("View the current Bitcoin market rate.")
static var parameterSummary: some ParameterSummary {
Summary("View the current Bitcoin market rate.")
}
@MainActor
func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog & ShowsSnippetView {
let userPreferredCurrency = Currency.getUserPreferredCurrency()
if userPreferredCurrency != Currency.getLastSelectedCurrency() {
Currency.saveNewSelectedCurrency()
}
var resultText = "--"
var lastUpdated = "--"
do {
if let data = try await MarketAPI.fetchPrice(currency: userPreferredCurrency),
let formattedRate = data.formattedRate {
resultText = formattedRate
let currentMarketData = MarketData(
nextBlock: "",
sats: "",
price: formattedRate,
rate: data.rateDouble,
dateString: data.lastUpdate
)
lastUpdated = formattedDate(from: data.lastUpdate)
}
} catch {
}
let view = CompactPriceView(price: resultText, lastUpdated: lastUpdated)
return .result(
value: resultText,
dialog: "Current Bitcoin Market Rate",
view: view
)
}
private func formattedDate(from isoString: String) -> String {
let isoFormatter = ISO8601DateFormatter()
if let date = isoFormatter.date(from: isoString) {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter.string(from: date)
}
return "--"
}
}
@available(iOS 15.0, *)
struct CompactPriceView: View {
let price: String
let lastUpdated: String
var body: some View {
VStack {
Text(price)
.font(.title)
Text("at \(lastUpdated)")
.font(.subheadline)
.foregroundColor(.gray)
}
.frame(maxWidth: 200)
.padding()
}
}

View file

@ -9,129 +9,73 @@
import WidgetKit
import SwiftUI
struct PriceWidgetProvider: TimelineProvider {
typealias Entry = PriceWidgetEntry
static var lastSuccessfulEntry: PriceWidgetEntry?
func placeholder(in context: Context) -> PriceWidgetEntry {
return PriceWidgetEntry(date: Date(), family: context.family, currentMarketData: MarketData(nextBlock: "", sats: "", price: "$10,000", rate: 10000, dateString: "2019-09-18T17:27:00+00:00"), previousMarketData: emptyMarketData)
}
func getSnapshot(in context: Context, completion: @escaping (PriceWidgetEntry) -> ()) {
let entry: PriceWidgetEntry
if context.isPreview {
entry = PriceWidgetEntry(date: Date(), family: context.family, currentMarketData: MarketData(nextBlock: "", sats: "", price: "$10,000", rate: 10000, dateString: "2019-09-18T17:27:00+00:00"), previousMarketData: emptyMarketData)
} else {
entry = PriceWidgetEntry(date: Date(), family: context.family, currentMarketData: emptyMarketData, previousMarketData: emptyMarketData)
}
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
var entries: [PriceWidgetEntry] = []
if context.isPreview {
let entry = PriceWidgetEntry(date: Date(), family: context.family, currentMarketData: MarketData(nextBlock: "", sats: "", price: "$10,000", rate: 10000, dateString: "2019-09-18T17:27:00+00:00"), previousMarketData: emptyMarketData)
entries.append(entry)
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
} else {
let userPreferredCurrency = Currency.getUserPreferredCurrency()
if userPreferredCurrency != Currency.getLastSelectedCurrency() {
Currency.saveNewSelectedCurrency()
}
MarketAPI.fetchPrice(currency: userPreferredCurrency) { (data, error) in
if let data = data, let formattedRate = data.formattedRate {
let currentMarketData = MarketData(nextBlock: "", sats: "", price: formattedRate, rate: data.rateDouble, dateString: data.lastUpdate)
// Retrieve previous market data from the last entry, if available
let previousEntry = PriceWidgetProvider.lastSuccessfulEntry
let previousMarketData = previousEntry?.currentMarketData
// Check if the new fetched price is the same as the current price
if let previousMarketData = previousMarketData, previousMarketData.rate == currentMarketData.rate {
// If the new price is the same, only update the date
let updatedEntry = PriceWidgetEntry(date: Date(), family: context.family, currentMarketData: previousMarketData, previousMarketData: previousEntry?.previousMarketData ?? emptyMarketData)
PriceWidgetProvider.lastSuccessfulEntry = updatedEntry
entries.append(updatedEntry)
} else {
// If the new price is different, update the data
let entry = PriceWidgetEntry(date: Date(), family: context.family, currentMarketData: currentMarketData, previousMarketData: previousMarketData ?? emptyMarketData)
PriceWidgetProvider.lastSuccessfulEntry = entry
entries.append(entry)
}
} else {
// Use the last successful entry if available
if let lastEntry = PriceWidgetProvider.lastSuccessfulEntry {
entries.append(lastEntry)
} else {
// Fallback to a default entry if no successful entry is available
let entry = PriceWidgetEntry(date: Date(), family: context.family, currentMarketData: emptyMarketData, previousMarketData: emptyMarketData)
entries.append(entry)
}
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
}
}
struct PriceWidgetEntry: TimelineEntry {
let date: Date
let family: WidgetFamily
let currentMarketData: MarketData?
let previousMarketData: MarketData?
}
struct PriceWidgetEntryView : View {
let entry: PriceWidgetEntry
@ViewBuilder
var body: some View {
PriceView(entry: entry)
}
}
@available(iOS 16.0, *)
struct PriceWidget: Widget {
let kind: String = "PriceWidget"
let kind: String = "PriceWidget"
var body: some WidgetConfiguration {
if #available(iOSApplicationExtension 16.0, *) {
return StaticConfiguration(kind: kind, provider: PriceWidgetProvider()) { entry in
PriceWidgetEntryView(entry: entry)
}
.configurationDisplayName("Price")
.description("View the current price of Bitcoin.")
.supportedFamilies([.systemSmall, .accessoryCircular, .accessoryInline, .accessoryRectangular])
.contentMarginsDisabledIfAvailable()
} else {
return StaticConfiguration(kind: kind, provider: PriceWidgetProvider()) { entry in
PriceWidgetEntryView(entry: entry)
}
.configurationDisplayName("Price")
.description("View the current price of Bitcoin.")
.supportedFamilies([.systemSmall])
.contentMarginsDisabledIfAvailable()
StaticConfiguration(kind: kind, provider: PriceWidgetProvider()) { entry in
PriceWidgetEntryView(entry: entry)
}
.configurationDisplayName("Price")
.description("View the current price of Bitcoin.")
.supportedFamilies(supportedFamilies)
.contentMarginsDisabledIfAvailable()
}
@available(iOS 16.0, *)
private var supportedFamilies: [WidgetFamily] {
if #available(iOSApplicationExtension 16.0, *) {
return [.systemSmall, .accessoryCircular, .accessoryInline, .accessoryRectangular]
} else {
return [.systemSmall]
}
}
}
}
@available(iOS 16.0, *)
struct PriceWidget_Previews: PreviewProvider {
static var previews: some View {
Group {
PriceWidgetEntryView(entry: PriceWidgetEntry(date: Date(), family: .systemSmall, currentMarketData: MarketData(nextBlock: "", sats: "", price: "$10,000", rate: 10000, dateString: "2019-09-18T17:27:00+00:00"), previousMarketData: emptyMarketData))
.previewContext(WidgetPreviewContext(family: .systemSmall))
if #available(iOSApplicationExtension 16.0, *) {
PriceWidgetEntryView(entry: PriceWidgetEntry(date: Date(), family: .accessoryCircular, currentMarketData: MarketData(nextBlock: "", sats: "", price: "$10,000", rate: 10000, dateString: "2019-09-18T17:27:00+00:00"), previousMarketData: emptyMarketData))
.previewContext(WidgetPreviewContext(family: .accessoryCircular))
PriceWidgetEntryView(entry: PriceWidgetEntry(date: Date(), family: .accessoryInline, currentMarketData: MarketData(nextBlock: "", sats: "", price: "$10,000", rate: 10000, dateString: "2019-09-18T17:27:00+00:00"), previousMarketData: emptyMarketData))
.previewContext(WidgetPreviewContext(family: .accessoryInline))
PriceWidgetEntryView(entry: PriceWidgetEntry(date: Date(), family: .accessoryRectangular, currentMarketData: MarketData(nextBlock: "", sats: "", price: "$10,000", rate: 10000, dateString: "2019-09-18T17:27:00+00:00"), previousMarketData: emptyMarketData))
.previewContext(WidgetPreviewContext(family: .accessoryRectangular))
}
Group {
PriceWidgetEntryView(entry: PreviewData.entry)
.previewContext(WidgetPreviewContext(family: .systemSmall))
if #available(iOSApplicationExtension 16.0, *) {
PriceWidgetEntryView(entry: PreviewData.entry)
.previewContext(WidgetPreviewContext(family: .accessoryCircular))
PriceWidgetEntryView(entry: PreviewData.entry)
.previewContext(WidgetPreviewContext(family: .accessoryInline))
PriceWidgetEntryView(entry: PreviewData.entry)
.previewContext(WidgetPreviewContext(family: .accessoryRectangular))
}
}
}
}
let previewMarketData = MarketData(nextBlock: "", sats: "", price: "$10,000", rate: 10000, dateString: "2019-09-18T17:27:00+00:00")
@available(iOS 14.0, *)
struct PreviewData {
static let entry = PriceWidgetEntry(
date: Date(),
family: .systemSmall,
currentMarketData: previewMarketData,
previousMarketData: emptyMarketData
)
}
@available(iOS 14.0, *)
extension WidgetConfiguration
{
@available(iOS 15.0, *)
func contentMarginsDisabledIfAvailable() -> some WidgetConfiguration
{
if #available(iOSApplicationExtension 17.0, *)
{
return self.contentMarginsDisabled()
}
else
{
return self
}
}
}
}

View file

@ -0,0 +1,25 @@
//
// PriceWidgetEntry.swift
// BlueWallet
//
// Created by Marcos Rodriguez on 10/27/24.
// Copyright © 2024 BlueWallet. All rights reserved.
//
import AppIntents
import WidgetKit
@available(iOS 14.0, *)
public struct PriceWidgetEntry: TimelineEntry {
public let date: Date
public let family: WidgetFamily
public let currentMarketData: MarketData?
public let previousMarketData: MarketData?
public init(date: Date, family: WidgetFamily, currentMarketData: MarketData?, previousMarketData: MarketData?) {
self.date = date
self.family = family
self.currentMarketData = currentMarketData
self.previousMarketData = previousMarketData
}
}

View file

@ -0,0 +1,19 @@
//
// PriceWidgetEntryView.swift
// BlueWallet
//
// Created by Marcos Rodriguez on 10/27/24.
// Copyright © 2024 BlueWallet. All rights reserved.
//
import SwiftUICore
@available(iOS 14.0, *)
struct PriceWidgetEntryView: View {
let entry: PriceWidgetEntry
var body: some View {
PriceView(entry: entry)
}
}

View file

@ -0,0 +1,79 @@
//
// PriceWidgetProvider.swift
// BlueWallet
//
// Created by Marcos Rodriguez on 10/27/24.
// Copyright © 2024 BlueWallet. All rights reserved.
//
import WidgetKit
import SwiftUI
@available(iOS 16.0, *)
struct PriceWidgetProvider: TimelineProvider {
typealias Entry = PriceWidgetEntry
static var lastSuccessfulEntry: PriceWidgetEntry?
func placeholder(in context: Context) -> PriceWidgetEntry {
createEntry(date: Date(), family: context.family, currentMarketData: previewMarketData)
}
func getSnapshot(in context: Context, completion: @escaping (PriceWidgetEntry) -> Void) {
let entry: PriceWidgetEntry
if context.isPreview {
entry = createEntry(date: Date(), family: context.family, currentMarketData: previewMarketData)
} else {
entry = createEntry(date: Date(), family: context.family, currentMarketData: emptyMarketData)
}
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<PriceWidgetEntry>) -> Void) {
var entries: [PriceWidgetEntry] = []
let userPreferredCurrency = Currency.getUserPreferredCurrency()
if userPreferredCurrency != Currency.getLastSelectedCurrency() {
Currency.saveNewSelectedCurrency()
}
Task {
do {
if let data = try await MarketAPI.fetchPrice(currency: userPreferredCurrency), let formattedRate = data.formattedRate {
let currentMarketData = MarketData(nextBlock: "", sats: "", price: formattedRate, rate: data.rateDouble, dateString: data.lastUpdate)
let previousMarketData = PriceWidgetProvider.lastSuccessfulEntry?.currentMarketData
let entry = createEntry(
date: Date(),
family: context.family,
currentMarketData: currentMarketData,
previousMarketData: previousMarketData ?? emptyMarketData
)
PriceWidgetProvider.lastSuccessfulEntry = entry
entries.append(entry)
} else {
if let lastEntry = PriceWidgetProvider.lastSuccessfulEntry {
entries.append(lastEntry)
} else {
let entry = createEntry(date: Date(), family: context.family, currentMarketData: emptyMarketData)
entries.append(entry)
}
}
} catch {
if let lastEntry = PriceWidgetProvider.lastSuccessfulEntry {
entries.append(lastEntry)
} else {
let entry = createEntry(date: Date(), family: context.family, currentMarketData: emptyMarketData)
entries.append(entry)
}
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
private func createEntry(date: Date, family: WidgetFamily, currentMarketData: MarketData, previousMarketData: MarketData = emptyMarketData) -> PriceWidgetEntry {
PriceWidgetEntry(date: date, family: family, currentMarketData: currentMarketData, previousMarketData: previousMarketData)
}
}

View file

@ -9,180 +9,134 @@
import SwiftUI
import WidgetKit
@available(iOS 14.0, *)
struct PriceView: View {
var entry: PriceWidgetEntry
var body: some View {
switch entry.family {
case .accessoryInline, .accessoryCircular, .accessoryRectangular:
if #available(iOSApplicationExtension 16.0, *) {
wrappedView(for: getView(for: entry.family), family: entry.family)
} else {
getView(for: entry.family)
}
default:
defaultView.background(Color(UIColor.systemBackground))
}
}
private func getView(for family: WidgetFamily) -> some View {
switch family {
case .accessoryCircular:
return AnyView(accessoryCircularView)
case .accessoryInline:
return AnyView(accessoryInlineView)
case .accessoryRectangular:
return AnyView(accessoryRectangularView)
default:
return AnyView(defaultView)
}
}
@ViewBuilder
private func wrappedView<Content: View>(for content: Content, family: WidgetFamily) -> some View {
if #available(iOSApplicationExtension 16.0, *) {
ZStack {
if family == .accessoryRectangular {
AccessoryWidgetBackground()
.background(Color(UIColor.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
} else {
AccessoryWidgetBackground()
}
content
}
} else {
content
}
}
private var accessoryCircularView: some View {
let priceString = formattedPriceString(from: entry.currentMarketData?.rate)
let priceChangePercentage = formattedPriceChangePercentage(currentRate: entry.currentMarketData?.rate, previousRate: entry.previousMarketData?.rate)
return VStack(alignment: .center, spacing: 4) {
Text("BTC")
.font(.caption)
.minimumScaleFactor(0.1)
Text(priceString)
.font(.body)
.minimumScaleFactor(0.1)
.lineLimit(1)
if let priceChangePercentage = priceChangePercentage {
Text(priceChangePercentage)
.font(.caption2)
.foregroundColor(priceChangePercentage.contains("-") ? .red : .green)
}
}
.widgetURL(URL(string: "bluewallet://marketprice"))
}
private var accessoryInlineView: some View {
let priceString = formattedCurrencyString(from: entry.currentMarketData?.rate)
let priceChangePercentage = formattedPriceChangePercentage(currentRate: entry.currentMarketData?.rate, previousRate: entry.previousMarketData?.rate)
return HStack {
Text(priceString)
.font(.body)
.minimumScaleFactor(0.1)
if let priceChangePercentage = priceChangePercentage {
Image(systemName: priceChangePercentage.contains("-") ? "arrow.down" : "arrow.up")
.foregroundColor(priceChangePercentage.contains("-") ? .red : .green)
}
}
}
private var accessoryRectangularView: some View {
let currentPrice = formattedCurrencyString(from: entry.currentMarketData?.rate)
return VStack(alignment: .leading, spacing: 4) {
Text("Bitcoin (\(Currency.getUserPreferredCurrency()))")
.font(.caption)
.foregroundColor(.secondary)
HStack {
Text(currentPrice)
.font(.caption)
.fontWeight(.bold)
if let currentMarketDataRate = entry.currentMarketData?.rate,
let previousMarketDataRate = entry.previousMarketData?.rate,
currentMarketDataRate != previousMarketDataRate {
Image(systemName: currentMarketDataRate > previousMarketDataRate ? "arrow.up" : "arrow.down")
}
}
if let previousMarketDataPrice = entry.previousMarketData?.price, Int(entry.currentMarketData?.rate ?? 0) != Int(entry.previousMarketData?.rate ?? 0) {
Text("From \(previousMarketDataPrice)")
.font(.caption)
.foregroundColor(.secondary)
}
Text("at \(entry.currentMarketData?.formattedDate ?? "--")")
.font(.caption2)
.foregroundColor(.secondary)
}
.padding(.all, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(UIColor.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
private var defaultView: some View {
VStack(alignment: .trailing, spacing: nil, content: {
Text("Last Updated").font(Font.system(size: 11, weight: .regular)).foregroundColor(Color(UIColor.lightGray))
HStack(alignment: .lastTextBaseline, spacing: nil, content: {
Text(entry.currentMarketData?.formattedDate ?? "").lineLimit(1).foregroundColor(.primary).font(Font.system(size: 13, weight: .regular)).minimumScaleFactor(0.01).transition(.opacity)
})
Spacer()
VStack(alignment: .trailing, spacing: 16, content: {
HStack(alignment: .lastTextBaseline, spacing: nil, content: {
Text(entry.currentMarketData?.price ?? "").lineLimit(1).foregroundColor(.primary).font(Font.system(size: 28, weight: .bold)).minimumScaleFactor(0.01).transition(.opacity)
})
if let previousMarketDataPrice = entry.previousMarketData?.price, let currentMarketDataRate = entry.currentMarketData?.rate, let previousMarketDataRate = entry.previousMarketData?.rate, previousMarketDataRate > 0, currentMarketDataRate != previousMarketDataRate {
HStack(alignment: .lastTextBaseline, spacing: nil, content: {
Image(systemName: currentMarketDataRate > previousMarketDataRate ? "arrow.up" : "arrow.down")
Text("from").lineLimit(1).foregroundColor(.primary).font(Font.system(size: 13, weight: .regular)).minimumScaleFactor(0.01)
Text(previousMarketDataPrice).lineLimit(1).foregroundColor(.primary).font(Font.system(size: 13, weight: .regular)).minimumScaleFactor(0.01)
}).transition(.slide)
}
})
}).frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .trailing).padding()
}
private func formattedPriceString(from rate: Double?) -> String {
let numberFormatter = NumberFormatter()
numberFormatter.numberStyle = .decimal
numberFormatter.maximumFractionDigits = 0
return numberFormatter.string(from: NSNumber(value: rate ?? 0)) ?? "--"
}
private func formattedCurrencyString(from rate: Double?) -> String {
let numberFormatter = NumberFormatter()
numberFormatter.maximumFractionDigits = 0
numberFormatter.numberStyle = .currency
numberFormatter.currencySymbol = fiatUnit(currency: Currency.getUserPreferredCurrency())?.symbol
return numberFormatter.string(from: NSNumber(value: rate ?? 0)) ?? "--"
}
private func formattedPriceChangePercentage(currentRate: Double?, previousRate: Double?) -> String? {
guard let currentRate = currentRate, let previousRate = previousRate, previousRate > 0 else { return nil }
let change = ((currentRate - previousRate) / previousRate) * 100
return change == 0 ? nil : String(format: "%+.1f%%", change)
}
}
var entry: PriceWidgetEntry
struct PriceView_Previews: PreviewProvider {
static var previews: some View {
Group {
PriceView(entry: PriceWidgetEntry(date: Date(), family: .systemSmall, currentMarketData: MarketData(nextBlock: "", sats: "", price: "$10,000", rate: 10000, dateString: "2019-09-18T17:27:00+00:00"), previousMarketData: emptyMarketData))
.previewContext(WidgetPreviewContext(family: .systemSmall)).padding()
if #available(iOSApplicationExtension 16.0, *) {
PriceView(entry: PriceWidgetEntry(date: Date(), family: .accessoryCircular, currentMarketData: MarketData(nextBlock: "", sats: "", price: "$10,000", rate: 10000, dateString: "2019-09-18T17:27:00+00:00"), previousMarketData: emptyMarketData))
.previewContext(WidgetPreviewContext(family: .accessoryCircular))
PriceView(entry: PriceWidgetEntry(date: Date(), family: .accessoryInline, currentMarketData: MarketData(nextBlock: "", sats: "", price: "$10,000", rate: 10000, dateString: "2019-09-18T17:27:00+00:00"), previousMarketData: emptyMarketData))
.previewContext(WidgetPreviewContext(family: .accessoryInline))
PriceView(entry: PriceWidgetEntry(date: Date(), family: .accessoryRectangular, currentMarketData: MarketData(nextBlock: "", sats: "", price: "$10,000", rate: 10000, dateString: "2019-09-18T17:27:00+00:00"), previousMarketData: emptyMarketData))
.previewContext(WidgetPreviewContext(family: .accessoryRectangular))
}
var body: some View {
switch entry.family {
case .accessoryInline, .accessoryCircular, .accessoryRectangular:
getView(for: entry.family)
default:
defaultView.background(Color(UIColor.systemBackground))
}
}
@ViewBuilder
private func getView(for family: WidgetFamily) -> some View {
switch family {
case .accessoryCircular:
accessoryCircularView
case .accessoryInline:
accessoryInlineView
case .accessoryRectangular:
accessoryRectangularView
default:
defaultView
}
}
private var accessoryCircularView: some View {
let priceString = (entry.currentMarketData?.rate ?? 0).formattedPriceString()
let priceChangePercentage = formattedPriceChangePercentage(currentRate: entry.currentMarketData?.rate, previousRate: entry.previousMarketData?.rate)
return VStack(alignment: .center, spacing: 4) {
Text("BTC")
.font(.caption)
.minimumScaleFactor(0.1)
Text(priceString)
.font(.body)
.minimumScaleFactor(0.1)
.lineLimit(1)
if let priceChangePercentage = priceChangePercentage {
Text(priceChangePercentage)
.font(.caption2)
.foregroundColor(priceChangePercentage.contains("-") ? .red : .green)
}
}
.widgetURL(URL(string: "bluewallet://marketprice"))
}
private var accessoryInlineView: some View {
let priceString = (entry.currentMarketData?.rate ?? 0).formattedCurrencyString()
let priceChangePercentage = formattedPriceChangePercentage(currentRate: entry.currentMarketData?.rate, previousRate: entry.previousMarketData?.rate)
return HStack {
Text(priceString)
.font(.body)
.minimumScaleFactor(0.1)
if let priceChangePercentage = priceChangePercentage {
Image(systemName: priceChangePercentage.contains("-") ? "arrow.down" : "arrow.up")
.foregroundColor(priceChangePercentage.contains("-") ? .red : .green)
}
}
}
private var accessoryRectangularView: some View {
let currentPrice = (entry.currentMarketData?.rate ?? 0).formattedCurrencyString()
return VStack(alignment: .leading, spacing: 4) {
Text("Bitcoin (\(Currency.getUserPreferredCurrency()))")
.font(.caption)
.foregroundColor(.secondary)
HStack {
Text(currentPrice)
.font(.caption)
.fontWeight(.bold)
if let currentRate = entry.currentMarketData?.rate,
let previousRate = entry.previousMarketData?.rate,
currentRate != previousRate {
Image(systemName: currentRate > previousRate ? "arrow.up" : "arrow.down")
}
}
if let previousPrice = entry.previousMarketData?.price,
let currentRate = entry.currentMarketData?.rate,
let previousRate = entry.previousMarketData?.rate,
currentRate != previousRate {
Text("From \(previousPrice)")
.font(.caption)
.foregroundColor(.secondary)
}
Text("at \(entry.currentMarketData?.formattedDate ?? "--")")
.font(.caption2)
.foregroundColor(.secondary)
}
.padding(.all, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(UIColor.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
private var defaultView: some View {
VStack(alignment: .trailing, spacing: nil) {
Text("Last Updated").font(Font.system(size: 11, weight: .regular)).foregroundColor(Color(UIColor.lightGray))
HStack(alignment: .lastTextBaseline, spacing: nil) {
Text(entry.currentMarketData?.formattedDate ?? "").lineLimit(1).foregroundColor(.primary).font(Font.system(size: 13, weight: .regular)).minimumScaleFactor(0.01).transition(.opacity)
}
Spacer()
VStack(alignment: .trailing, spacing: 16) {
HStack(alignment: .lastTextBaseline, spacing: nil) {
Text(entry.currentMarketData?.price ?? "").lineLimit(1).foregroundColor(.primary).font(Font.system(size: 28, weight: .bold)).minimumScaleFactor(0.01).transition(.opacity)
}
if let previousPrice = entry.previousMarketData?.price,
let currentRate = entry.currentMarketData?.rate,
let previousRate = entry.previousMarketData?.rate,
previousRate > 0, currentRate != previousRate {
HStack(alignment: .lastTextBaseline, spacing: nil) {
Image(systemName: currentRate > previousRate ? "arrow.up" : "arrow.down")
Text("from").lineLimit(1).foregroundColor(.primary).font(Font.system(size: 13, weight: .regular)).minimumScaleFactor(0.01)
Text(previousPrice).lineLimit(1).foregroundColor(.primary).font(Font.system(size: 13, weight: .regular)).minimumScaleFactor(0.01)
}.transition(.slide)
}
}
}.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing).padding()
}
private func formattedPriceChangePercentage(currentRate: Double?, previousRate: Double?) -> String? {
guard let currentRate = currentRate, let previousRate = previousRate, previousRate > 0 else { return nil }
let change = ((currentRate - previousRate) / previousRate) * 100
return change == 0 ? nil : String(format: "%+.1f%%", change)
}
}
}

View file

@ -0,0 +1,27 @@
//
// WalletAppShortcuts.swift
// BlueWallet
//
// Created by Marcos Rodriguez on 10/27/24.
// Copyright © 2024 BlueWallet. All rights reserved.
//
import AppIntents
@available(iOS 16.4, *)
struct WalletAppShortcuts: AppShortcutsProvider {
@AppShortcutsBuilder
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: PriceIntent(),
phrases: [
"Market Rate \(.applicationName)",
"Get the current Bitcoin market rate in \(.applicationName)"
],
shortTitle: "Market Rate",
systemImageName: "bitcoinsign.circle"
)
}
}

View file

@ -19,18 +19,3 @@ struct Widgets: WidgetBundle {
WalletInformationAndMarketWidget()
}
}
extension WidgetConfiguration
{
func contentMarginsDisabledIfAvailable() -> some WidgetConfiguration
{
if #available(iOSApplicationExtension 17.0, *)
{
return self.contentMarginsDisabled()
}
else
{
return self
}
}
}