diff --git a/Kukai Mobile.xcodeproj/project.pbxproj b/Kukai Mobile.xcodeproj/project.pbxproj index a88b25c5..995ee4b6 100644 --- a/Kukai Mobile.xcodeproj/project.pbxproj +++ b/Kukai Mobile.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ C003AEB42B470D6E00AEC4A8 /* SendAbstractConfirmViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C003AEB32B470D6E00AEC4A8 /* SendAbstractConfirmViewController.swift */; }; C003CECB27FDE9C900F64B4C /* CloudKitService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C003CECA27FDE9C900F64B4C /* CloudKitService.swift */; }; C003CECD27FDED5D00F64B4C /* CKRecord+extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C003CECC27FDED5D00F64B4C /* CKRecord+extensions.swift */; }; + C005B6D22B5FDEE3006E265F /* OnrampViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C005B6D12B5FDEE3006E265F /* OnrampViewModel.swift */; }; C005BA322822D3DC00D8369D /* AddWalletViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C005BA312822D3DC00D8369D /* AddWalletViewController.swift */; }; C006176A29DB33C8005A1330 /* WalletCreatedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C006176929DB33C8005A1330 /* WalletCreatedViewController.swift */; }; C0081FD627D8FE2300F7FEFF /* ActivityViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0081FD527D8FE2300F7FEFF /* ActivityViewController.swift */; }; @@ -425,6 +426,7 @@ C003AEB32B470D6E00AEC4A8 /* SendAbstractConfirmViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendAbstractConfirmViewController.swift; sourceTree = ""; }; C003CECA27FDE9C900F64B4C /* CloudKitService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitService.swift; sourceTree = ""; }; C003CECC27FDED5D00F64B4C /* CKRecord+extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CKRecord+extensions.swift"; sourceTree = ""; }; + C005B6D12B5FDEE3006E265F /* OnrampViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnrampViewModel.swift; sourceTree = ""; }; C005BA312822D3DC00D8369D /* AddWalletViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddWalletViewController.swift; sourceTree = ""; }; C006176929DB33C8005A1330 /* WalletCreatedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletCreatedViewController.swift; sourceTree = ""; }; C0081FD527D8FE2300F7FEFF /* ActivityViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityViewController.swift; sourceTree = ""; }; @@ -1471,6 +1473,7 @@ C06E0D60287C3E28007A580B /* WalletConnectViewModel.swift */, C055EF1D2A2F95310031CB5F /* ShowQRViewController.swift */, C0172A072A98EC6400163179 /* OnrampViewController.swift */, + C005B6D12B5FDEE3006E265F /* OnrampViewModel.swift */, ); path = "Side Menu"; sourceTree = ""; @@ -1923,6 +1926,7 @@ C0FBC88F292C12CE00B29921 /* HiddenTokensBalancesViewController.swift in Sources */, C001D5B22A027F410089EC7A /* CollectionDetailsViewModel.swift in Sources */, C0AC1D3E2771E5AB002F66C0 /* BottomSheetLargeSegue.swift in Sources */, + C005B6D22B5FDEE3006E265F /* OnrampViewModel.swift in Sources */, C07FC40927C538A90056FA47 /* ImageHeadingCell.swift in Sources */, C05B0A332A03FAC9005AA803 /* CollectiblesCollectionHeaderMediumCell.swift in Sources */, C065AF0F2AB09C270061CC64 /* SideMenuBackupViewController.swift in Sources */, diff --git a/Kukai Mobile/Localization/en.lproj/Localizable.strings b/Kukai Mobile/Localization/en.lproj/Localizable.strings index 503b5fd8..ddb26d03 100644 --- a/Kukai Mobile/Localization/en.lproj/Localizable.strings +++ b/Kukai Mobile/Localization/en.lproj/Localizable.strings @@ -53,3 +53,4 @@ "error-wc2-cant-continue"="Unable to continue with this request due to system error"; "error-beacon-not-supported"="Beacon QRCodes are not supported, only Wallet Connect. Please make sure you are using the kukai option. If you are, please contact the dApp support team and ask them to update their beacon version"; "error-collectible-media-generic"="Unable to play media at this time, please try again later"; +"error-onramp-generic"="Unable to access this provider at this time, please try again later"; diff --git a/Kukai Mobile/Modules/Side Menu/OnrampViewController.swift b/Kukai Mobile/Modules/Side Menu/OnrampViewController.swift index b5c543e2..3f8ccb75 100644 --- a/Kukai Mobile/Modules/Side Menu/OnrampViewController.swift +++ b/Kukai Mobile/Modules/Side Menu/OnrampViewController.swift @@ -6,48 +6,51 @@ // import UIKit +import Combine +import SafariServices -class OnrampViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { +class OnrampViewController: UIViewController, UITableViewDelegate { @IBOutlet weak var tableView: UITableView! - private let ramps = [ - (title: "Coinbase", subtitle: "Transfer from Coinbase", image: "coinbase"), - (title: "Transak", subtitle: "Bank transfers & local payment methods in 120+ countries", image: "transak"), - (title: "Moonpay", subtitle: "Cards & banks transfers", image: "moonpay") - ] + public let viewModel = OnrampViewModel() + + private var bag = [AnyCancellable]() + private var safariViewController: SFSafariViewController? = nil override func viewDidLoad() { super.viewDidLoad() let _ = self.view.addGradientBackgroundFull() - tableView.dataSource = self + // Setup data + viewModel.makeDataSource(withTableView: tableView) + tableView.dataSource = viewModel.dataSource tableView.delegate = self + + viewModel.$state.sink { [weak self] state in + guard let self = self else { return } + + switch state { + case .loading: + let _ = "" + + case .success(_): + let _ = "" + + case .failure(_, let message): + self.windowError(withTitle: "error".localized(), description: message) + } + }.store(in: &bag) } - @IBAction func infoButtonTapped(_ sender: Any) { - self.alert(errorWithMessage: "Under Construction") - } - - func numberOfSections(in tableView: UITableView) -> Int { - return 1 - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return ramps.count + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + viewModel.refresh(animate: true) } - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let cell = tableView.dequeueReusableCell(withIdentifier: "TitleSubtitleImageContainerCell", for: indexPath) as? TitleSubtitleImageContainerCell else { - return UITableViewCell() - } - - let ramp = ramps[indexPath.row] - cell.iconView.image = UIImage(named: ramp.image) - cell.titleLabel.text = ramp.title - cell.subtitleLabel.text = ramp.subtitle - - return cell + @IBAction func infoButtonTapped(_ sender: Any) { + self.alert(errorWithMessage: "Under Construction") } func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { @@ -59,6 +62,21 @@ class OnrampViewController: UIViewController, UITableViewDataSource, UITableView } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - self.alert(errorWithMessage: "Under Construction") + self.showLoadingView() + + viewModel.url(forIndexPath: indexPath) { [weak self] result in + self?.hideLoadingView() + guard let res = try? result.get() else { + self?.windowError(withTitle: "error".localized(), description: "error-onramp-generic".localized()) + return + } + + self?.safariViewController = SFSafariViewController(url: res) + + if let vc = self?.safariViewController { + vc.modalPresentationStyle = .pageSheet + self?.present(vc, animated: true) + } + } } } diff --git a/Kukai Mobile/Modules/Side Menu/OnrampViewModel.swift b/Kukai Mobile/Modules/Side Menu/OnrampViewModel.swift new file mode 100644 index 00000000..e6ae32bc --- /dev/null +++ b/Kukai Mobile/Modules/Side Menu/OnrampViewModel.swift @@ -0,0 +1,158 @@ +// +// OnrampViewModel.swift +// Kukai Mobile +// +// Created by Simon Mcloughlin on 23/01/2024. +// + +import UIKit +import KukaiCoreSwift + +public struct OnrampOption: Codable, Hashable { + let title: String + let subtitle: String + let imageName: String + let key: String +} + +class OnrampViewModel: ViewModel, UITableViewDiffableDataSourceHandler { + + typealias SectionEnum = Int + typealias CellDataType = AnyHashable + + var dataSource: UITableViewDiffableDataSource? = nil + var ramps: [OnrampOption] = [] + + func makeDataSource(withTableView tableView: UITableView) { + + dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { tableView, indexPath, item in + + if let obj = item as? OnrampOption, let cell = tableView.dequeueReusableCell(withIdentifier: "TitleSubtitleImageContainerCell", for: indexPath) as? TitleSubtitleImageContainerCell { + cell.iconView.image = UIImage(named: obj.imageName) + cell.titleLabel.text = obj.title + cell.subtitleLabel.text = obj.subtitle + return cell + + } else { + return UITableViewCell() + } + }) + + dataSource?.defaultRowAnimation = .fade + } + + func refresh(animate: Bool, successMessage: String? = nil) { + guard let ds = dataSource else { + state = .failure(KukaiError.internalApplicationError(error: ViewModelError.dataSourceNotCreated), "Unable to process data at this time") + return + } + + // Build snapshot + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + + snapshot.appendItems([ + OnrampOption(title: "Coinbase", subtitle: "Transfer from Coinbase", imageName: "coinbase", key: "coinbase"), + OnrampOption(title: "Transak", subtitle: "Bank transfers & local payment methods in 120+ countries", imageName: "transak", key: "transak"), + OnrampOption(title: "Moonpay", subtitle: "Cards & banks transfers", imageName: "moonpay", key: "moonpay") + ], toSection: 0) + + ds.applySnapshotUsingReloadData(snapshot) + + self.state = .success(nil) + } + + public func url(forIndexPath indexPath: IndexPath, completion: @escaping ((Result) -> Void)) { + guard let ramp = dataSource?.itemIdentifier(for: indexPath) as? OnrampOption, let currentAddress = DependencyManager.shared.selectedWalletAddress else { + completion(Result.failure(KukaiError.unknown())) + return + } + + var baseURLString = "" + switch ramp.key { + case "coinbase": + baseURLString = "https://pay.coinbase.com" + buildCoinbaseURL(withBaseURL: baseURLString, andAddress: currentAddress, completion: completion) + + case "transak": + baseURLString = "https://global.transak.com" + buildTransakURL(withBaseURL: baseURLString, andAddress: currentAddress, completion: completion) + + case "moonpay": + baseURLString = "https://buy.moonpay.com" + signMoonPayUrl(withBaseURL: baseURLString, andAddress: currentAddress, completion: completion) + + default: + completion(Result.failure(KukaiError.unknown())) + } + + } + + private func buildCoinbaseURL(withBaseURL: String, andAddress address: String, completion: @escaping ((Result) -> Void)) { + let appID = "aa41d510-15f9-4426-87bd-3a506b6e22c0" + let walletData: [[String: Any]] = [ + [ + "address": address, + "blockchains": [ "tezos" ] + ] + ] + + guard let jsonData = try? JSONSerialization.data(withJSONObject: walletData), + let jsonString = String(data: jsonData, encoding: .utf8), + let url = URL(string: "\(withBaseURL)/buy/select-asset?appId=\(appID)&destinationWallets=\(jsonString)") else { + completion(Result.failure(KukaiError.unknown())) + return + } + + completion(Result.success(url)) + } + + private func buildTransakURL(withBaseURL: String, andAddress address: String, completion: @escaping ((Result) -> Void)) { + let apiKey = "f1336570-699b-4181-9bd1-cdd57206981f" // "3b0e81f3-37dc-41f3-9837-bd8d2c350313" : "f1336570-699b-4181-9bd1-cdd57206981f" + let walletData: [String: Any] = [ + "coins": [ + "XTZ": [ address ] + ] + ] + + guard let jsonData = try? JSONSerialization.data(withJSONObject: walletData), + let jsonString = String(data: jsonData, encoding: .utf8), + let url = URL(string: "\(withBaseURL)?apiKey=\(apiKey)&cryptoCurrencyCode=XTZ&walletAddressesData=\(jsonString)&disableWalletAddressForm=true") else { + completion(Result.failure(KukaiError.unknown())) + return + } + + completion(Result.success(url)) + } + + private func signMoonPayUrl(withBaseURL: String, andAddress address: String, completion: @escaping ((Result) -> Void)) { + guard address.prefix(2).lowercased() == "tz", let kukaiServiceURL = URL(string: "https://utils.kukai.network/moonpay/sign") else { + completion(Result.failure(KukaiError.unknown())) + return + } + + let key = "pk_live_rP9HlBRO54nY4QKLxc6ONl4Prrm6vymK" // "pk_test_M23P0Zc5SvBORSFV63sfWKi7n5QbGZR" : "pk_live_rP9HlBRO54nY4QKLxc6ONl4Prrm6vymK" + var query = "?apiKey=\(key)&colorCode=%237178E3¤cyCode=xtz&walletAddress=\(address)" + + let params: [String: Any] = [ + "dev": false, + "url": query + ] + + let data = try? JSONSerialization.data(withJSONObject: params) + DependencyManager.shared.tezosNodeClient.networkService.request(url: kukaiServiceURL, isPOST: true, withBody: data, forReturnType: Data.self) { result in + guard let res = try? result.get(), let sigString = String(data: res, encoding: .utf8), let sanitised = sigString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { + completion(Result.failure(result.getFailure())) + return + } + + query += "&signature=\(sanitised))" + + if let url = URL(string: "\(withBaseURL)\(query)") { + completion(Result.success(url)) + } else { + completion(Result.failure(KukaiError.unknown())) + } + } + } +}