diff --git a/Example/DApp/Modules/Sign/SignPresenter.swift b/Example/DApp/Modules/Sign/SignPresenter.swift index 1c5194e2c..e96791894 100644 --- a/Example/DApp/Modules/Sign/SignPresenter.swift +++ b/Example/DApp/Modules/Sign/SignPresenter.swift @@ -2,6 +2,7 @@ import UIKit import Combine import Web3Modal +import WalletConnectModal import WalletConnectSign final class SignPresenter: ObservableObject { @@ -52,6 +53,14 @@ final class SignPresenter: ObservableObject { Web3Modal.present(from: nil) } + func connectWalletWithWCM() { + WalletConnectModal.set(sessionParams: .init( + requiredNamespaces: Proposal.requiredNamespaces, + optionalNamespaces: Proposal.optionalNamespaces + )) + WalletConnectModal.present(from: nil) + } + @MainActor func connectWalletWithSign() { Task { diff --git a/Example/DApp/Modules/Sign/SignView.swift b/Example/DApp/Modules/Sign/SignView.swift index 54c9d62e7..51d12a806 100644 --- a/Example/DApp/Modules/Sign/SignView.swift +++ b/Example/DApp/Modules/Sign/SignView.swift @@ -18,29 +18,42 @@ struct SignView: View { Spacer() - Button { - presenter.connectWalletWithW3M() - } label: { - Text("Connect with Web3Modal") - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.white) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(Color(red: 95/255, green: 159/255, blue: 248/255)) - .cornerRadius(16) - } - .padding(.top, 20) - - Button { - presenter.connectWalletWithSign() - } label: { - Text("Connect with Sign API") - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.white) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(Color(red: 95/255, green: 159/255, blue: 248/255)) - .cornerRadius(16) + VStack(spacing: 10) { + Button { + presenter.connectWalletWithW3M() + } label: { + Text("Connect with Web3Modal") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Color(red: 95/255, green: 159/255, blue: 248/255)) + .cornerRadius(16) + } + + Button { + presenter.connectWalletWithSign() + } label: { + Text("Connect with Sign API") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Color(red: 95/255, green: 159/255, blue: 248/255)) + .cornerRadius(16) + } + + Button { + presenter.connectWalletWithWCM() + } label: { + Text("Connect with WalletConnectModal") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Color(red: 95/255, green: 159/255, blue: 248/255)) + .cornerRadius(16) + } } .padding(.top, 10) } diff --git a/Example/DApp/SceneDelegate.swift b/Example/DApp/SceneDelegate.swift index 521b89ff0..38e007ed9 100644 --- a/Example/DApp/SceneDelegate.swift +++ b/Example/DApp/SceneDelegate.swift @@ -1,6 +1,7 @@ import UIKit import Web3Modal +import WalletConnectModal import Auth import WalletConnectRelay import WalletConnectNetworking @@ -42,6 +43,12 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { ) ] ) + + WalletConnectModal.configure( + projectId: InputConfig.projectId, + metadata: metadata + ) + Sign.instance.logsPublisher.sink { log in switch log { diff --git a/Example/ExampleApp.xcodeproj/project.pbxproj b/Example/ExampleApp.xcodeproj/project.pbxproj index 24712ead6..900432d34 100644 --- a/Example/ExampleApp.xcodeproj/project.pbxproj +++ b/Example/ExampleApp.xcodeproj/project.pbxproj @@ -341,6 +341,7 @@ CF25F2892A432476009C7E49 /* WalletConnectModal in Frameworks */ = {isa = PBXBuildFile; productRef = CF25F2882A432476009C7E49 /* WalletConnectModal */; }; CF6704DF29E59DDC003326A4 /* XCUIElementQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF6704DE29E59DDC003326A4 /* XCUIElementQuery.swift */; }; CF6704E129E5A014003326A4 /* XCTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF6704E029E5A014003326A4 /* XCTestCase.swift */; }; + CFDB50722B2869AA00A0CBC2 /* WalletConnectModal in Frameworks */ = {isa = PBXBuildFile; productRef = CFDB50712B2869AA00A0CBC2 /* WalletConnectModal */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -712,6 +713,7 @@ 84943C7B2A9BA206007EBAC2 /* Mixpanel in Frameworks */, A573C53929EC365000E3CBFD /* HDWalletKit in Frameworks */, A5BB7FA328B6A50400707FC6 /* WalletConnectAuth in Frameworks */, + CFDB50722B2869AA00A0CBC2 /* WalletConnectModal in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1961,6 +1963,7 @@ 84943C7A2A9BA206007EBAC2 /* Mixpanel */, C5BE01DE2AF692D80064FC88 /* WalletConnectRouter */, C579FEB52AFA86CD008855EB /* Web3Modal */, + CFDB50712B2869AA00A0CBC2 /* WalletConnectModal */, 8486EDD22B4F2EA6008E53C3 /* SwiftMessages */, ); productName = DApp; @@ -3348,7 +3351,7 @@ repositoryURL = "https://github.com/WalletConnect/web3modal-swift"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.0.9; + minimumVersion = 1.0.15; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -3567,6 +3570,10 @@ isa = XCSwiftPackageProductDependency; productName = WalletConnectModal; }; + CFDB50712B2869AA00A0CBC2 /* WalletConnectModal */ = { + isa = XCSwiftPackageProductDependency; + productName = WalletConnectModal; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 764E1D3426F8D3FC00A1FB15 /* Project object */; diff --git a/Sources/WalletConnectModal/Extensions/View+Backport.swift b/Sources/WalletConnectModal/Extensions/View+Backport.swift index 3c40e44ba..a2ed92408 100644 --- a/Sources/WalletConnectModal/Extensions/View+Backport.swift +++ b/Sources/WalletConnectModal/Extensions/View+Backport.swift @@ -32,7 +32,7 @@ extension View { @ViewBuilder func onTapGestureBackported(count: Int = 1, perform action: @escaping () -> Void) -> some View { - self + self.onTapGesture(count: count, perform: action) } #elseif os(tvOS) diff --git a/Sources/WalletConnectModal/Mocks/Listing+Mocks.swift b/Sources/WalletConnectModal/Mocks/Listing+Mocks.swift index 0c8af0885..721524d4e 100644 --- a/Sources/WalletConnectModal/Mocks/Listing+Mocks.swift +++ b/Sources/WalletConnectModal/Mocks/Listing+Mocks.swift @@ -2,64 +2,40 @@ import Foundation #if DEBUG -extension Listing { - static let stubList: [Listing] = [ - Listing( +extension Wallet { + static let stubList: [Wallet] = [ + Wallet( id: UUID().uuidString, name: "Sample Wallet", - homepage: "https://example.com", + homepage: "https://example.com/cool", + imageId: "0528ee7e-16d1-4089-21e3-bbfb41933100", order: 1, - imageId: UUID().uuidString, - app: Listing.App( - ios: "https://example.com/download-ios", - browser: "https://example.com/download-safari" - ), - mobile: .init( - native: "sampleapp://deeplink", - universal: "https://example.com/universal" - ), - desktop: .init( - native: nil, - universal: "https://example.com/universal" - ) + mobileLink: "https://sample.com/foo/universal", + desktopLink: "sampleapp://deeplink", + webappLink: "https://sample.com/foo/webapp", + appStore: "" ), - Listing( + Wallet( id: UUID().uuidString, - name: "Awesome Wallet", - homepage: "https://example.com/awesome", + name: "Cool Wallet", + homepage: "https://example.com/cool", + imageId: "5195e9db-94d8-4579-6f11-ef553be95100", order: 2, - imageId: UUID().uuidString, - app: Listing.App( - ios: "https://example.com/download-ios", - browser: "https://example.com/download-safari" - ), - mobile: .init( - native: "awesomeapp://deeplink", - universal: "https://example.com/awesome/universal" - ), - desktop: .init( - native: nil, - universal: "https://example.com/awesome/universal" - ) + mobileLink: "awsomeapp://", + desktopLink: "awsomeapp://deeplink", + webappLink: "https://awesome.com/foo/webapp", + appStore: "" ), - Listing( + Wallet( id: UUID().uuidString, name: "Cool Wallet", homepage: "https://example.com/cool", + imageId: "3913df81-63c2-4413-d60b-8ff83cbed500", order: 3, - imageId: UUID().uuidString, - app: Listing.App( - ios: "https://example.com/download-ios", - browser: "https://example.com/download-safari" - ), - mobile: .init( - native: "coolapp://deeplink", - universal: "https://example.com/cool/universal" - ), - desktop: .init( - native: nil, - universal: "https://example.com/cool/universal" - ) + mobileLink: "https://cool.com/foo/universal", + desktopLink: "coolapp://deeplink", + webappLink: "https://cool.com/foo/webapp", + appStore: "" ) ] } diff --git a/Sources/WalletConnectModal/Modal/ModalInteractor.swift b/Sources/WalletConnectModal/Modal/ModalInteractor.swift index 19fc70c51..fe18b4f48 100644 --- a/Sources/WalletConnectModal/Modal/ModalInteractor.swift +++ b/Sources/WalletConnectModal/Modal/ModalInteractor.swift @@ -3,7 +3,7 @@ import Combine import Foundation protocol ModalSheetInteractor { - func getListings() async throws -> [Listing] + func getWallets(page: Int, entries: Int) async throws -> (Int, [Wallet]) func createPairingAndConnect() async throws -> WalletConnectURI? var sessionSettlePublisher: AnyPublisher { get } @@ -11,24 +11,27 @@ protocol ModalSheetInteractor { } final class DefaultModalSheetInteractor: ModalSheetInteractor { - lazy var sessionSettlePublisher: AnyPublisher = WalletConnectModal.instance.sessionSettlePublisher lazy var sessionRejectionPublisher: AnyPublisher<(Session.Proposal, Reason), Never> = WalletConnectModal.instance.sessionRejectionPublisher - func getListings() async throws -> [Listing] { - - let httpClient = HTTPNetworkClient(host: "explorer-api.walletconnect.com") + func getWallets(page: Int, entries: Int) async throws -> (Int, [Wallet]) { + let httpClient = HTTPNetworkClient(host: "api.web3modal.org") let response = try await httpClient.request( - ListingsResponse.self, - at: ExplorerAPI.getListings( - projectId: WalletConnectModal.config.projectId, - metadata: WalletConnectModal.config.metadata, - recommendedIds: WalletConnectModal.config.recommendedWalletIds, - excludedIds: WalletConnectModal.config.excludedWalletIds + GetWalletsResponse.self, + at: Web3ModalAPI.getWallets( + params: Web3ModalAPI.GetWalletsParams( + page: page, + entries: entries, + search: nil, + projectId: WalletConnectModal.config.projectId, + metadata: WalletConnectModal.config.metadata, + recommendedIds: WalletConnectModal.config.recommendedWalletIds, + excludedIds: WalletConnectModal.config.excludedWalletIds + ) ) ) - return response.listings.values.compactMap { $0 } + return (response.count, response.data.compactMap { $0 }) } func createPairingAndConnect() async throws -> WalletConnectURI? { diff --git a/Sources/WalletConnectModal/Modal/ModalSheet.swift b/Sources/WalletConnectModal/Modal/ModalSheet.swift index b09226db0..1bfdbaa59 100644 --- a/Sources/WalletConnectModal/Modal/ModalSheet.swift +++ b/Sources/WalletConnectModal/Modal/ModalSheet.swift @@ -119,14 +119,12 @@ public struct ModalSheet: View { @ViewBuilder private func welcome() -> some View { WalletList( - wallets: .init(get: { - viewModel.filteredWallets - }, set: { _ in }), destination: .init(get: { viewModel.destination }, set: { _ in }), + viewModel: viewModel, navigateTo: viewModel.navigateTo(_:), - onListingTap: { viewModel.onListingTap($0) } + onWalletTap: { viewModel.onWalletTap($0) } ) } diff --git a/Sources/WalletConnectModal/Modal/ModalViewModel.swift b/Sources/WalletConnectModal/Modal/ModalViewModel.swift index 5c274468a..e78aa56e1 100644 --- a/Sources/WalletConnectModal/Modal/ModalViewModel.swift +++ b/Sources/WalletConnectModal/Modal/ModalViewModel.swift @@ -7,7 +7,7 @@ enum Destination: Equatable { case welcome case viewAll case qr - case walletDetail(Listing) + case walletDetail(Wallet) case getWallet var contentTitle: String { @@ -42,17 +42,21 @@ final class ModalViewModel: ObservableObject { @Published private(set) var destinationStack: [Destination] = [.welcome] @Published private(set) var uri: String? - @Published private(set) var wallets: [Listing] = [] - + @Published private(set) var wallets: [Wallet] = [] + @Published var searchTerm: String = "" @Published var toast: Toast? + @Published private(set) var isThereMoreWallets: Bool = true + private var maxPage = Int.max + private var currentPage: Int = 0 + var destination: Destination { destinationStack.last! } - var filteredWallets: [Listing] { + var filteredWallets: [Wallet] { wallets .sortByRecent() .filter(searchTerm: searchTerm) @@ -119,8 +123,8 @@ final class ModalViewModel: ObservableObject { uiApplicationWrapper.openURL(url, nil) } - func onListingTap(_ listing: Listing) { - setLastTimeUsed(listing.id) + func onWalletTap(_ wallet: Wallet) { + setLastTimeUsed(wallet.id) } func onBackButton() { @@ -162,17 +166,32 @@ final class ModalViewModel: ObservableObject { @MainActor func fetchWallets() async { + let entries = 40 + do { - let wallets = try await interactor.getListings() + guard currentPage <= maxPage else { + return + } + + currentPage += 1 + + if currentPage == maxPage { + isThereMoreWallets = false + } + + let (total, wallets) = try await interactor.getWallets(page: currentPage, entries: entries) + maxPage = Int(Double(total / entries).rounded(.up)) + // Small deliberate delay to ensure animations execute properly try await Task.sleep(nanoseconds: 500_000_000) - + loadRecentWallets() checkWhetherInstalled(wallets: wallets) - self.wallets = wallets + self.wallets.append(contentsOf: wallets .sortByOrder() .sortByInstalled() + ) } catch { toast = Toast(style: .error, message: error.localizedDescription) } @@ -181,28 +200,20 @@ final class ModalViewModel: ObservableObject { // MARK: - Sorting and filtering -private extension Array where Element: Listing { - func sortByOrder() -> [Listing] { +private extension Array where Element: Wallet { + func sortByOrder() -> [Wallet] { sorted { - guard let lhs = $0.order else { - return false - } - - guard let rhs = $1.order else { - return true - } - - return lhs < rhs + $0.order < $1.order } } - func sortByInstalled() -> [Listing] { + func sortByInstalled() -> [Wallet] { sorted { lhs, rhs in - if lhs.installed, !rhs.installed { + if lhs.isInstalled, !rhs.isInstalled { return true } - if !lhs.installed, rhs.installed { + if !lhs.isInstalled, rhs.isInstalled { return false } @@ -210,7 +221,7 @@ private extension Array where Element: Listing { } } - func sortByRecent() -> [Listing] { + func sortByRecent() -> [Wallet] { sorted { lhs, rhs in guard let lhsLastTimeUsed = lhs.lastTimeUsed else { return false @@ -224,7 +235,7 @@ private extension Array where Element: Listing { } } - func filter(searchTerm: String) -> [Listing] { + func filter(searchTerm: String) -> [Wallet] { if searchTerm.isEmpty { return self } return filter { @@ -236,18 +247,18 @@ private extension Array where Element: Listing { // MARK: - Recent & Installed Wallets private extension ModalViewModel { - func checkWhetherInstalled(wallets: [Listing]) { + func checkWhetherInstalled(wallets: [Wallet]) { guard let schemes = Bundle.main.object(forInfoDictionaryKey: "LSApplicationQueriesSchemes") as? [String] else { return } wallets.forEach { if - let walletScheme = $0.mobile.native, + let walletScheme = $0.mobileLink, !walletScheme.isEmpty, schemes.contains(walletScheme.replacingOccurrences(of: "://", with: "")) { - $0.installed = uiApplicationWrapper.canOpenURL(URL(string: walletScheme)!) + $0.isInstalled = uiApplicationWrapper.canOpenURL(URL(string: walletScheme)!) } } } @@ -270,40 +281,31 @@ private extension ModalViewModel { // MARK: - Deeplinking protocol WalletDeeplinkHandler { - func openAppstore(wallet: Listing) - func navigateToDeepLink(wallet: Listing, preferUniversal: Bool, preferBrowser: Bool) + func openAppstore(wallet: Wallet) + func navigateToDeepLink(wallet: Wallet, preferBrowser: Bool) } extension ModalViewModel: WalletDeeplinkHandler { - func openAppstore(wallet: Listing) { + func openAppstore(wallet: Wallet) { guard - let storeLinkString = wallet.app.ios, + let storeLinkString = wallet.appStore, let storeLink = URL(string: storeLinkString) else { return } uiApplicationWrapper.openURL(storeLink, nil) } - func navigateToDeepLink(wallet: Listing, preferUniversal: Bool, preferBrowser: Bool) { + func navigateToDeepLink(wallet: Wallet, preferBrowser: Bool) { do { - let nativeScheme = preferBrowser ? nil : wallet.mobile.native - let universalScheme = preferBrowser ? wallet.desktop.universal : wallet.mobile.universal - + let nativeScheme = preferBrowser ? wallet.webappLink : wallet.mobileLink let nativeUrlString = try formatNativeUrlString(nativeScheme) - let universalUrlString = try formatUniversalUrlString(universalScheme) - if let nativeUrl = nativeUrlString?.toURL(), !preferUniversal { + if let nativeUrl = nativeUrlString?.toURL() { uiApplicationWrapper.openURL(nativeUrl) { success in if !success { self.toast = Toast(style: .error, message: DeeplinkErrors.failedToOpen.localizedDescription) } } - } else if let universalUrl = universalUrlString?.toURL() { - uiApplicationWrapper.openURL(universalUrl) { success in - if !success { - self.toast = Toast(style: .error, message: DeeplinkErrors.failedToOpen.localizedDescription) - } - } } else { throw DeeplinkErrors.noWalletLinkFound } diff --git a/Sources/WalletConnectModal/Modal/RecentWalletStorage.swift b/Sources/WalletConnectModal/Modal/RecentWalletStorage.swift index 00ccd5929..87ca09a09 100644 --- a/Sources/WalletConnectModal/Modal/RecentWalletStorage.swift +++ b/Sources/WalletConnectModal/Modal/RecentWalletStorage.swift @@ -7,7 +7,7 @@ final class RecentWalletsStorage { self.defaults = defaults } - var recentWallets: [Listing] { + var recentWallets: [Wallet] { get { loadRecentWallets() } @@ -16,16 +16,16 @@ final class RecentWalletsStorage { } } - func loadRecentWallets() -> [Listing] { + func loadRecentWallets() -> [Wallet] { guard let data = defaults.data(forKey: "recentWallets"), - let wallets = try? JSONDecoder().decode([Listing].self, from: data) + let wallets = try? JSONDecoder().decode([Wallet].self, from: data) else { return [] } - return wallets.filter { listing in - guard let lastTimeUsed = listing.lastTimeUsed else { + return wallets.filter { wallet in + guard let lastTimeUsed = wallet.lastTimeUsed else { assertionFailure("Shouldn't happen we stored wallet without `lastTimeUsed`") return false } @@ -35,9 +35,9 @@ final class RecentWalletsStorage { } } - func saveRecentWallets(_ listings: [Listing]) { + func saveRecentWallets(_ wallets: [Wallet]) { - let subset = Array(listings.filter { + let subset = Array(wallets.filter { $0.lastTimeUsed != nil }.prefix(5)) diff --git a/Sources/WalletConnectModal/Modal/Screens/GetAWalletView.swift b/Sources/WalletConnectModal/Modal/Screens/GetAWalletView.swift index b5f2c7438..0255cd648 100644 --- a/Sources/WalletConnectModal/Modal/Screens/GetAWalletView.swift +++ b/Sources/WalletConnectModal/Modal/Screens/GetAWalletView.swift @@ -1,8 +1,8 @@ import SwiftUI struct GetAWalletView: View { - let wallets: [Listing] - let onWalletTap: (Listing) -> Void + let wallets: [Wallet] + let onWalletTap: (Wallet) -> Void let navigateToExternalLink: (URL) -> Void var body: some View { @@ -71,7 +71,7 @@ struct GetAWalletView: View { struct GetAWalletView_Previews: PreviewProvider { static var previews: some View { GetAWalletView( - wallets: Listing.stubList, + wallets: Wallet.stubList, onWalletTap: { _ in }, navigateToExternalLink: { _ in } ) diff --git a/Sources/WalletConnectModal/Modal/Screens/WalletDetail/WalletDetailViewModel.swift b/Sources/WalletConnectModal/Modal/Screens/WalletDetail/WalletDetailViewModel.swift index 4b146927c..f1ee61ac5 100644 --- a/Sources/WalletConnectModal/Modal/Screens/WalletDetail/WalletDetailViewModel.swift +++ b/Sources/WalletConnectModal/Modal/Screens/WalletDetail/WalletDetailViewModel.swift @@ -15,49 +15,37 @@ final class WalletDetailViewModel: ObservableObject { case didTapAppStore } - let wallet: Listing + let wallet: Wallet let deeplinkHandler: WalletDeeplinkHandler @Published var preferredPlatform: Platform = .native - var showToggle: Bool { wallet.app.browser != nil && wallet.app.ios != nil } - var showUniversalLink: Bool { preferredPlatform == .native && wallet.mobile.universal?.isEmpty == false } - var hasNativeLink: Bool { wallet.mobile.native?.isEmpty == false } + var showToggle: Bool { wallet.webappLink != nil && wallet.appStore != nil } + var showUniversalLink: Bool { preferredPlatform == .native && wallet.mobileLink?.isEmpty == false } + var hasNativeLink: Bool { wallet.mobileLink?.isEmpty == false } init( - wallet: Listing, + wallet: Wallet, deeplinkHandler: WalletDeeplinkHandler ) { self.wallet = wallet self.deeplinkHandler = deeplinkHandler - preferredPlatform = wallet.app.ios != nil ? .native : .browser + preferredPlatform = wallet.appStore != nil ? .native : .browser } func handle(_ event: Event) { switch event { - case .onAppear: - deeplinkHandler.navigateToDeepLink( - wallet: wallet, - preferUniversal: true, - preferBrowser: preferredPlatform == .browser - ) - - case .didTapUniversalLink: - deeplinkHandler.navigateToDeepLink( - wallet: wallet, - preferUniversal: true, - preferBrowser: preferredPlatform == .browser - ) - - case .didTapTryAgain: - deeplinkHandler.navigateToDeepLink( - wallet: wallet, - preferUniversal: false, - preferBrowser: preferredPlatform == .browser - ) - + case .onAppear, .didTapUniversalLink, .didTapTryAgain: + deeplinkToWallet() case .didTapAppStore: deeplinkHandler.openAppstore(wallet: wallet) } } + + func deeplinkToWallet() { + deeplinkHandler.navigateToDeepLink( + wallet: wallet, + preferBrowser: preferredPlatform == .browser + ) + } } diff --git a/Sources/WalletConnectModal/Modal/Screens/WalletList.swift b/Sources/WalletConnectModal/Modal/Screens/WalletList.swift index 55ba54c2a..96efd6a13 100644 --- a/Sources/WalletConnectModal/Modal/Screens/WalletList.swift +++ b/Sources/WalletConnectModal/Modal/Screens/WalletList.swift @@ -1,16 +1,45 @@ import SwiftUI struct WalletList: View { - @Binding var wallets: [Listing] + @Binding var destination: Destination + @ObservedObject var viewModel: ModalViewModel + var navigateTo: (Destination) -> Void - var onListingTap: (Listing) -> Void + var onWalletTap: (Wallet) -> Void @State var numberOfColumns = 4 - @State var availableSize: CGSize = .zero + init( + destination: Binding, + viewModel: ModalViewModel, + navigateTo: @escaping (Destination) -> Void, + onWalletTap: @escaping (Wallet) -> Void, + numberOfColumns: Int = 4, + availableSize: CGSize = .zero, + infiniteScrollLoading: Bool = false + ) { + self._destination = destination + self.viewModel = viewModel + self.navigateTo = navigateTo + self.onWalletTap = onWalletTap + self.numberOfColumns = numberOfColumns + self.availableSize = availableSize + self.infiniteScrollLoading = infiniteScrollLoading + + if #available(iOS 14.0, *) { + // iOS 14 doesn't have extra separators below the list by default. + } else { + // To remove only extra separators below the list: + UITableView.appearance(whenContainedInInstancesOf: [WalletConnectModalSheetController.self]).tableFooterView = UIView() + } + + // To remove all separators including the actual ones: + UITableView.appearance(whenContainedInInstancesOf: [WalletConnectModalSheetController.self]).separatorStyle = .none + } + var body: some View { ZStack { content() @@ -23,6 +52,7 @@ struct WalletList: View { numberOfColumns = Int(round(size.width / 100)) availableSize = size } + } } @@ -47,16 +77,16 @@ struct WalletList: View { VStack { HStack { - ForEach(wallets.prefix(numberOfColumns)) { wallet in + ForEach(viewModel.filteredWallets.prefix(numberOfColumns)) { wallet in gridItem(for: wallet) } } HStack { - ForEach(wallets.dropFirst(numberOfColumns).prefix(max(numberOfColumns - 1, 0))) { wallet in + ForEach(viewModel.filteredWallets.dropFirst(numberOfColumns).prefix(max(numberOfColumns - 1, 0))) { wallet in gridItem(for: wallet) } - if wallets.count > numberOfColumns * 2 { + if viewModel.filteredWallets.count > numberOfColumns * 2 { viewAllItem() .onTapGestureBackported { withAnimation { @@ -67,32 +97,52 @@ struct WalletList: View { } } - if wallets.isEmpty { + if viewModel.filteredWallets.isEmpty { ActivityIndicator(isAnimating: .constant(true)) } } } + @State var infiniteScrollLoading = false + @ViewBuilder private func viewAll() -> some View { ZStack { Spacer().frame(maxWidth: .infinity, maxHeight: 150) - ScrollView(.vertical) { - VStack(alignment: .leading) { - ForEach(Array(stride(from: 0, to: wallets.count, by: numberOfColumns)), id: \.self) { row in - HStack { - ForEach(row ..< (row + numberOfColumns), id: \.self) { index in - if let wallet = wallets[safe: index] { - gridItem(for: wallet) - } + List { + ForEach(Array(stride(from: 0, to: viewModel.filteredWallets.count, by: numberOfColumns)), id: \.self) { row in + HStack { + ForEach(row ..< (row + numberOfColumns), id: \.self) { index in + if let wallet = viewModel.filteredWallets[safe: index] { + gridItem(for: wallet) } } } } - .padding(.vertical) + .listRowInsets(EdgeInsets(top: 0, leading: 24, bottom: 8, trailing: 24)) + .transform { + if #available(iOS 15.0, *) { + $0.listRowSeparator(.hidden) + } + } + + if viewModel.isThereMoreWallets { + Color.clear.frame(height: 100) + .onAppear { + Task { + await viewModel.fetchWallets() + } + } + .transform { + if #available(iOS 15.0, *) { + $0.listRowSeparator(.hidden) + } + } + } } - + .listStyle(.plain) + LinearGradient( stops: [ .init(color: .background1, location: 0.0), @@ -112,7 +162,7 @@ struct WalletList: View { func viewAllItem() -> some View { VStack { VStack(spacing: 3) { - let viewAllWalletsFirstRow = wallets.dropFirst(2 * numberOfColumns - 1).prefix(2) + let viewAllWalletsFirstRow = viewModel.filteredWallets.dropFirst(2 * numberOfColumns - 1).prefix(2) HStack(spacing: 3) { ForEach(viewAllWalletsFirstRow) { wallet in @@ -123,7 +173,7 @@ struct WalletList: View { } .padding(.horizontal, 5) - let viewAllWalletsSecondRow = wallets.dropFirst(2 * numberOfColumns + 1).prefix(2) + let viewAllWalletsSecondRow = viewModel.filteredWallets.dropFirst(2 * numberOfColumns + 1).prefix(2) HStack(spacing: 3) { ForEach(viewAllWalletsSecondRow) { wallet in @@ -155,7 +205,7 @@ struct WalletList: View { } @ViewBuilder - func gridItem(for wallet: Listing) -> some View { + func gridItem(for wallet: Wallet) -> some View { VStack { WalletImage(wallet: wallet) .frame(width: 60, height: 60) @@ -171,7 +221,7 @@ struct WalletList: View { .multilineTextAlignment(.center) Text(wallet.lastTimeUsed != nil ? "RECENT" : "INSTALLED") - .opacity(wallet.lastTimeUsed != nil || wallet.installed ? 1 : 0) + .opacity(wallet.lastTimeUsed != nil || wallet.isInstalled ? 1 : 0) .font(.system(size: 10)) .foregroundColor(.foreground3) .padding(.horizontal, 12) @@ -183,7 +233,7 @@ struct WalletList: View { // Small delay to let detail screen present before actually deeplinking DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - onListingTap(wallet) + onWalletTap(wallet) } } } diff --git a/Sources/WalletConnectModal/Networking/Explorer/ExplorerAPI.swift b/Sources/WalletConnectModal/Networking/Explorer/ExplorerAPI.swift deleted file mode 100644 index f52a3db67..000000000 --- a/Sources/WalletConnectModal/Networking/Explorer/ExplorerAPI.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Foundation - -enum ExplorerAPI: HTTPService { - case getListings( - projectId: String, - metadata: AppMetadata, - recommendedIds: [String], - excludedIds: [String] - ) - - var path: String { - switch self { - case .getListings: return "/w3m/v1/getiOSListings" - } - } - - var method: HTTPMethod { - switch self { - case .getListings: return .get - } - } - - var body: Data? { - nil - } - - var queryParameters: [String: String]? { - switch self { - case let .getListings(projectId, _, recommendedIds, excludedIds): - return [ - "projectId": projectId, - "recommendedIds": recommendedIds.joined(separator: ","), - "excludedIds": excludedIds.joined(separator: ","), - "sdkType": "wcm", - "sdkVersion": EnvironmentInfo.sdkName, - ] - .compactMapValues { value in - value.isEmpty ? nil : value - } - } - } - - var scheme: String { - return "https" - } - - var additionalHeaderFields: [String: String]? { - switch self { - case let .getListings(_, metadata, _, _): - return [ - "Referer": metadata.name - ] - } - } -} diff --git a/Sources/WalletConnectModal/Networking/Explorer/GetIosDataResponse.swift b/Sources/WalletConnectModal/Networking/Explorer/GetIosDataResponse.swift new file mode 100644 index 000000000..31445bebd --- /dev/null +++ b/Sources/WalletConnectModal/Networking/Explorer/GetIosDataResponse.swift @@ -0,0 +1,11 @@ +import Foundation + +struct GetIosDataResponse: Codable { + let count: Int + let data: [WalletMetadata] + + struct WalletMetadata: Codable { + let id: String + let ios_schema: String + } +} diff --git a/Sources/WalletConnectModal/Networking/Explorer/GetWalletsResponse.swift b/Sources/WalletConnectModal/Networking/Explorer/GetWalletsResponse.swift new file mode 100644 index 000000000..02d84ed88 --- /dev/null +++ b/Sources/WalletConnectModal/Networking/Explorer/GetWalletsResponse.swift @@ -0,0 +1,87 @@ +import Foundation + +struct GetWalletsResponse: Codable { + let count: Int + let data: [Wallet] +} + +class Wallet: Codable, Identifiable, Hashable { + let id: String + let name: String + let homepage: String + let imageId: String + let order: Int + let mobileLink: String? + let desktopLink: String? + let webappLink: String? + let appStore: String? + + var lastTimeUsed: Date? + var isInstalled: Bool = false + + enum CodingKeys: String, CodingKey { + case id + case name + case homepage + case imageId = "image_id" + case order + case mobileLink = "mobile_link" + case desktopLink = "desktop_link" + case webappLink = "webapp_link" + case appStore = "app_store" + + // Decorated + case lastTimeUsed + case isInstalled + } + + init( + id: String, + name: String, + homepage: String, + imageId: String, + order: Int, + mobileLink: String? = nil, + desktopLink: String? = nil, + webappLink: String? = nil, + appStore: String? = nil, + lastTimeUsed: Date? = nil, + isInstalled: Bool = false + ) { + self.id = id + self.name = name + self.homepage = homepage + self.imageId = imageId + self.order = order + self.mobileLink = mobileLink + self.desktopLink = desktopLink + self.webappLink = webappLink + self.appStore = appStore + self.lastTimeUsed = lastTimeUsed + self.isInstalled = isInstalled + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(String.self, forKey: .id) + self.name = try container.decode(String.self, forKey: .name) + self.homepage = try container.decode(String.self, forKey: .homepage) + self.imageId = try container.decode(String.self, forKey: .imageId) + self.order = try container.decode(Int.self, forKey: .order) + self.mobileLink = try container.decodeIfPresent(String.self, forKey: .mobileLink) + self.desktopLink = try container.decodeIfPresent(String.self, forKey: .desktopLink) + self.webappLink = try container.decodeIfPresent(String.self, forKey: .webappLink) + self.appStore = try container.decodeIfPresent(String.self, forKey: .appStore) + self.lastTimeUsed = try container.decodeIfPresent(Date.self, forKey: .lastTimeUsed) + self.isInstalled = try container.decodeIfPresent(Bool.self, forKey: .isInstalled) ?? false + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(name) + } + + static func == (lhs: Wallet, rhs: Wallet) -> Bool { + lhs.id == rhs.id && lhs.name == rhs.name + } +} diff --git a/Sources/WalletConnectModal/Networking/Explorer/ListingsResponse.swift b/Sources/WalletConnectModal/Networking/Explorer/ListingsResponse.swift deleted file mode 100644 index 0ddd4446c..000000000 --- a/Sources/WalletConnectModal/Networking/Explorer/ListingsResponse.swift +++ /dev/null @@ -1,74 +0,0 @@ -import Foundation - -struct ListingsResponse: Codable { - let listings: [String: Listing] -} - -class Listing: Codable, Hashable, Identifiable { - init( - id: String, - name: String, - homepage: String, - order: Int? = nil, - imageId: String, - app: Listing.App, - mobile: Listing.Links, - desktop: Listing.Links, - lastTimeUsed: Date? = nil, - installed: Bool = false - ) { - self.id = id - self.name = name - self.homepage = homepage - self.order = order - self.imageId = imageId - self.app = app - self.mobile = mobile - self.desktop = desktop - self.lastTimeUsed = lastTimeUsed - self.installed = installed - } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - hasher.combine(name) - } - - static func == (lhs: Listing, rhs: Listing) -> Bool { - lhs.id == rhs.id && lhs.name == rhs.name - } - - let id: String - let name: String - let homepage: String - let order: Int? - let imageId: String - let app: App - let mobile: Links - let desktop: Links - - var lastTimeUsed: Date? - var installed: Bool = false - - private enum CodingKeys: String, CodingKey { - case id - case name - case homepage - case order - case imageId = "image_id" - case app - case mobile - case desktop - case lastTimeUsed - } - - struct App: Codable, Hashable { - let ios: String? - let browser: String? - } - - struct Links: Codable, Hashable { - let native: String? - let universal: String? - } -} diff --git a/Sources/WalletConnectModal/Networking/Explorer/Web3ModalAPI.swift b/Sources/WalletConnectModal/Networking/Explorer/Web3ModalAPI.swift new file mode 100644 index 000000000..e2c63128a --- /dev/null +++ b/Sources/WalletConnectModal/Networking/Explorer/Web3ModalAPI.swift @@ -0,0 +1,84 @@ +import Foundation + +enum Web3ModalAPI: HTTPService { + struct GetWalletsParams { + let page: Int + let entries: Int + let search: String? + let projectId: String + let metadata: AppMetadata + let recommendedIds: [String] + let excludedIds: [String] + } + + struct GetIosDataParams { + let projectId: String + let metadata: AppMetadata + } + + case getWallets(params: GetWalletsParams) + case getIosData(params: GetIosDataParams) + + var path: String { + switch self { + case .getWallets: return "/getWallets" + case .getIosData: return "/getIosData" + } + } + + var method: HTTPMethod { + switch self { + case .getWallets: return .get + case .getIosData: return .get + } + } + + var body: Data? { + nil + } + + var queryParameters: [String: String]? { + switch self { + case let .getWallets(params): + return [ + "page": "\(params.page)", + "entries": "\(params.entries)", + "search": params.search ?? "", + "recommendedIds": params.recommendedIds.joined(separator: ","), + "excludedIds": params.excludedIds.joined(separator: ","), + "platform": "ios", + ] + .compactMapValues { value in + value.isEmpty ? nil : value + } + case let .getIosData(params): + return [ + "projectId": params.projectId, + "metadata": params.metadata.name + ] + } + } + + var scheme: String { + return "https" + } + + var additionalHeaderFields: [String: String]? { + switch self { + case let .getWallets(params): + return [ + "x-project-id": params.projectId, + "x-sdk-version": WalletConnectModal.Config.sdkVersion, + "x-sdk-type": WalletConnectModal.Config.sdkType, + "Referer": params.metadata.name + ] + case let .getIosData(params): + return [ + "x-project-id": params.projectId, + "x-sdk-version": WalletConnectModal.Config.sdkVersion, + "x-sdk-type": WalletConnectModal.Config.sdkType, + "Referer": params.metadata.name + ] + } + } +} diff --git a/Sources/WalletConnectModal/UI/WalletImage.swift b/Sources/WalletConnectModal/UI/WalletImage.swift index cd70dae0a..9b142eab0 100644 --- a/Sources/WalletConnectModal/UI/WalletImage.swift +++ b/Sources/WalletConnectModal/UI/WalletImage.swift @@ -10,7 +10,7 @@ struct WalletImage: View { @Environment(\.projectId) var projectId - var wallet: Listing? + var wallet: Wallet? var size: Size = .medium var body: some View { @@ -24,7 +24,7 @@ struct WalletImage: View { } } - private func imageURL(for wallet: Listing?) -> URL? { + private func imageURL(for wallet: Wallet?) -> URL? { guard let wallet else { return nil } diff --git a/Sources/WalletConnectModal/WalletConnectModal.swift b/Sources/WalletConnectModal/WalletConnectModal.swift index 87085fcf5..c74e5c884 100644 --- a/Sources/WalletConnectModal/WalletConnectModal.swift +++ b/Sources/WalletConnectModal/WalletConnectModal.swift @@ -34,6 +34,9 @@ public class WalletConnectModal { }() struct Config { + static let sdkVersion: String = "swift-\(EnvironmentInfo.packageVersion)" + static let sdkType = "wcm" + let projectId: String var metadata: AppMetadata var sessionParams: SessionParams diff --git a/Tests/WalletConnectModalTests/ExplorerAPITests.swift b/Tests/WalletConnectModalTests/ExplorerAPITests.swift index 14f0f6bf5..26bdb83e9 100644 --- a/Tests/WalletConnectModalTests/ExplorerAPITests.swift +++ b/Tests/WalletConnectModalTests/ExplorerAPITests.swift @@ -6,18 +6,31 @@ final class ExplorerAPITests: XCTestCase { func testCorrectMappingOfWalletIds() throws { - let request = ExplorerAPI - .getListings(projectId: "123", metadata: .stub(), recommendedIds: ["foo", "bar"], excludedIds: ["boo", "far"]) + let request = Web3ModalAPI + .getWallets( + params: .init( + page: 2, + entries: 40, + search: "", + projectId: "123", + metadata: .stub(), + recommendedIds: ["foo", "bar"], + excludedIds: ["boo", "far"] + ) + ) .resolve(for: "www.google.com") XCTAssertEqual(request?.allHTTPHeaderFields?["Referer"], "Wallet Connect") + XCTAssertEqual(request?.allHTTPHeaderFields?["x-sdk-version"], WalletConnectModal.Config.sdkVersion) + XCTAssertEqual(request?.allHTTPHeaderFields?["x-sdk-type"], "wcm") + XCTAssertEqual(request?.allHTTPHeaderFields?["x-project-id"], "123") XCTAssertEqual(request?.url?.queryParameters, [ - "projectId": "123", "recommendedIds": "foo,bar", + "page": "2", + "entries": "40", + "platform": "ios", "excludedIds": "boo,far", - "sdkVersion": EnvironmentInfo.sdkName, - "sdkType": "wcm" ]) } } diff --git a/Tests/WalletConnectModalTests/Mocks/ModalSheetInteractorMock.swift b/Tests/WalletConnectModalTests/Mocks/ModalSheetInteractorMock.swift index bfc9a34b6..23ed24b76 100644 --- a/Tests/WalletConnectModalTests/Mocks/ModalSheetInteractorMock.swift +++ b/Tests/WalletConnectModalTests/Mocks/ModalSheetInteractorMock.swift @@ -7,14 +7,14 @@ import WalletConnectSign final class ModalSheetInteractorMock: ModalSheetInteractor { - var listings: [Listing] + var wallets: [Wallet] - init(listings: [Listing] = Listing.stubList) { - self.listings = listings + init(wallets: [Wallet] = Wallet.stubList) { + self.wallets = wallets } - func getListings() async throws -> [Listing] { - listings + func getWallets(page: Int, entries: Int) async throws -> (Int, [Wallet]) { + (1, wallets) } func createPairingAndConnect() async throws -> WalletConnectURI? { diff --git a/Tests/WalletConnectModalTests/ModalViewModelTests.swift b/Tests/WalletConnectModalTests/ModalViewModelTests.swift index 2b9fd7c89..a7ec21f6d 100644 --- a/Tests/WalletConnectModalTests/ModalViewModelTests.swift +++ b/Tests/WalletConnectModalTests/ModalViewModelTests.swift @@ -17,44 +17,28 @@ final class ModalViewModelTests: XCTestCase { sut = .init( isShown: .constant(true), - interactor: ModalSheetInteractorMock(listings: [ - Listing( + interactor: ModalSheetInteractorMock(wallets: [ + Wallet( id: "1", name: "Sample App", - homepage: "https://example.com", + homepage: "https://example.com/cool", + imageId: "0528ee7e-16d1-4089-21e3-bbfb41933100", order: 1, - imageId: "1", - app: Listing.App( - ios: "https://example.com/download-ios", - browser: "https://example.com/wallet" - ), - mobile: Listing.Links( - native: nil, - universal: "https://example.com/universal" - ), - desktop: Listing.Links( - native: nil, - universal: "https://example.com/universal" - ) + mobileLink: "https://example.com/universal/", + desktopLink: "sampleapp://deeplink", + webappLink: "https://sample.com/foo/webapp", + appStore: "" ), - Listing( + Wallet( id: "2", name: "Awesome App", - homepage: "https://example.com/awesome", + homepage: "https://example.com/cool", + imageId: "5195e9db-94d8-4579-6f11-ef553be95100", order: 2, - imageId: "2", - app: Listing.App( - ios: "https://example.com/download-ios", - browser: "https://example.com/wallet" - ), - mobile: Listing.Links( - native: "awesomeapp://deeplink", - universal: "https://awesome.com/awesome/universal" - ), - desktop: Listing.Links( - native: "awesomeapp://deeplink", - universal: "https://awesome.com/awesome/desktop/universal" - ) + mobileLink: "awesomeapp://deeplink", + desktopLink: "awesomeapp://deeplink", + webappLink: "https://awesome.com/awesome/universal/", + appStore: "" ), ]), uiApplicationWrapper: .init( @@ -87,19 +71,19 @@ final class ModalViewModelTests: XCTestCase { XCTAssertEqual(sut.wallets.map(\.id), ["1", "2"]) XCTAssertEqual(sut.wallets.map(\.name), ["Sample App", "Awesome App"]) - expectation = XCTestExpectation(description: "Wait for openUrl to be called") + expectation = XCTestExpectation(description: "Wait for openUrl to be called using native link") - sut.navigateToDeepLink(wallet: sut.wallets[0], preferUniversal: true, preferBrowser: false) + sut.navigateToDeepLink(wallet: sut.wallets[1], preferBrowser: false) XCTWaiter.wait(for: [expectation], timeout: 3) XCTAssertEqual( openURLFuncTest.currentValue, - URL(string: "https://example.com/universal/wc?uri=wc%3Afoo%402%3FsymKey%3Dbar%26relay-protocol%3Dirn%26expiryTimestamp%3D1706001526")! + URL(string: "awesomeapp://deeplinkwc?uri=wc%3Afoo%402%3FsymKey%3Dbar%26relay-protocol%3Dirn%26expiryTimestamp%3D1706001526")! ) expectation = XCTestExpectation(description: "Wait for openUrl to be called using universal link") - sut.navigateToDeepLink(wallet: sut.wallets[1], preferUniversal: false, preferBrowser: false) + sut.navigateToDeepLink(wallet: sut.wallets[1], preferBrowser: false) XCTWaiter.wait(for: [expectation], timeout: 3) XCTAssertEqual( @@ -107,9 +91,9 @@ final class ModalViewModelTests: XCTestCase { URL(string: "awesomeapp://deeplinkwc?uri=wc%3Afoo%402%3FsymKey%3Dbar%26relay-protocol%3Dirn%26expiryTimestamp%3D1706001526")! ) - expectation = XCTestExpectation(description: "Wait for openUrl to be called using native link") + expectation = XCTestExpectation(description: "Wait for openUrl to be called using webapp link") - sut.navigateToDeepLink(wallet: sut.wallets[1], preferUniversal: true, preferBrowser: false) + sut.navigateToDeepLink(wallet: sut.wallets[1], preferBrowser: true) XCTWaiter.wait(for: [expectation], timeout: 3) XCTAssertEqual( @@ -119,12 +103,12 @@ final class ModalViewModelTests: XCTestCase { expectation = XCTestExpectation(description: "Wait for openUrl to be called using native link") - sut.navigateToDeepLink(wallet: sut.wallets[1], preferUniversal: false, preferBrowser: true) + sut.navigateToDeepLink(wallet: sut.wallets[1], preferBrowser: true) XCTWaiter.wait(for: [expectation], timeout: 3) XCTAssertEqual( openURLFuncTest.currentValue, - URL(string: "https://awesome.com/awesome/desktop/universal/wc?uri=wc%3Afoo%402%3FsymKey%3Dbar%26relay-protocol%3Dirn%26expiryTimestamp%3D1706001526")! + URL(string: "https://awesome.com/awesome/universal/wc?uri=wc%3Afoo%402%3FsymKey%3Dbar%26relay-protocol%3Dirn%26expiryTimestamp%3D1706001526")! ) } }