diff --git a/Package.resolved b/Package.resolved index 3d8029f..28f681c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/WalletConnect/WalletConnectSwiftV2", "state" : { - "branch" : "remove-wcm", - "revision" : "0085250fd993f40a638f8d3e300f4af8cbf9e7a8" + "revision" : "4aa4c8229077c133730e361d0d7a17f9e56bdffd", + "version" : "1.9.6" } } ], diff --git a/Package.swift b/Package.swift index e9198f3..47532fc 100644 --- a/Package.swift +++ b/Package.swift @@ -23,7 +23,7 @@ let package = Package( dependencies: [ .package( url: "https://github.com/WalletConnect/WalletConnectSwiftV2", - from: "1.9.2" + from: "1.9.8" ), .package( url: "https://github.com/WalletConnect/QRCode", diff --git a/Sample/Example.xcodeproj/project.pbxproj b/Sample/Example.xcodeproj/project.pbxproj index 356d079..a58b379 100644 --- a/Sample/Example.xcodeproj/project.pbxproj +++ b/Sample/Example.xcodeproj/project.pbxproj @@ -322,7 +322,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Example/Example.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 9; DEVELOPMENT_ASSET_PATHS = "\"Example/Preview Content\""; DEVELOPMENT_TEAM = W5R8AG9K22; ENABLE_HARDENED_RUNTIME = YES; @@ -364,7 +364,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Example/Example.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 9; DEVELOPMENT_ASSET_PATHS = "\"Example/Preview Content\""; DEVELOPMENT_TEAM = W5R8AG9K22; ENABLE_HARDENED_RUNTIME = YES; diff --git a/Sample/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Sample/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index fd35cd7..8bfd48f 100644 --- a/Sample/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Sample/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/WalletConnect/WalletConnectSwiftV2", "state" : { - "revision" : "c3c84f221ef945edb766125bc07f03c7694fc516", - "version" : "1.9.2" + "revision" : "addf9a3688ef5e5d9d148ecbb30ca0fd3132b908", + "version" : "1.9.8" } } ], diff --git a/Sample/Example/ComponentLibraryView.swift b/Sample/Example/ComponentLibraryView.swift index 20c58ff..5c5b8d8 100644 --- a/Sample/Example/ComponentLibraryView.swift +++ b/Sample/Example/ComponentLibraryView.swift @@ -12,6 +12,12 @@ struct ComponentLibraryView: View { Group { #if DEBUG List { + NavigationLink(destination: AccountButtonPreviewView()) { + Text("AccountButton") + } + NavigationLink(destination: NetworkButtonPreviewView()) { + Text("NetworkButton") + } NavigationLink(destination: W3MButtonStylePreviewView()) { Text("W3MButton") } diff --git a/Sample/Example/ContentView.swift b/Sample/Example/ContentView.swift index 0488410..11d62c6 100644 --- a/Sample/Example/ContentView.swift +++ b/Sample/Example/ContentView.swift @@ -12,9 +12,9 @@ struct ContentView: View { VStack { Spacer() - Web3Button() + Web3ModalButton() - NetworkButton() + Web3ModalNetworkButton() Spacer() diff --git a/Sample/Example/ExampleApp.swift b/Sample/Example/ExampleApp.swift index 70bc3f9..9bdbb37 100644 --- a/Sample/Example/ExampleApp.swift +++ b/Sample/Example/ExampleApp.swift @@ -18,7 +18,8 @@ struct ExampleApp: App { name: "Web3Modal Swift Dapp", description: "Web3Modal DApp sample", url: "wallet.connect", - icons: ["https://avatars.githubusercontent.com/u/37784886"] + icons: ["https://avatars.githubusercontent.com/u/37784886"], + redirect: .init(native: "", universal: "") ) let projectId = Secrets.load().projectID @@ -30,7 +31,6 @@ struct ExampleApp: App { Web3Modal.configure( projectId: projectId, - chainId: Blockchain("eip155:1")!, metadata: metadata ) } diff --git a/Sources/Web3Modal/Components/AccountButton.swift b/Sources/Web3Modal/Components/AccountButton.swift index 3de2447..7376070 100644 --- a/Sources/Web3Modal/Components/AccountButton.swift +++ b/Sources/Web3Modal/Components/AccountButton.swift @@ -30,7 +30,7 @@ struct AccountButtonStyle: ButtonStyle { } var selectedChain: Chain { - return store.selectedChain ?? ChainsPresets.ethChains.first! + return store.selectedChain ?? ChainPresets.ethChains.first! } func makeBody(configuration: Configuration) -> some View { @@ -158,11 +158,11 @@ public struct AccountButton: View { Button(action: { Web3Modal.present() }, label: {}) - .buttonStyle(AccountButtonStyle(store: store)) - .onAppear { - fetchIdentity() - fetchBalance() - } + .buttonStyle(AccountButtonStyle(store: store)) + .onAppear { + fetchIdentity() + fetchBalance() + } } func fetchIdentity() { @@ -186,45 +186,65 @@ public struct AccountButton: View { } } -struct AccountButton_Preview: PreviewProvider { +#if DEBUG + +public struct AccountButtonPreviewView: View { + public init() {} + static let store = { (balance: Double?) -> Store in let store = Store() store.balance = balance store.session = .stub + + Web3Modal.configure( + projectId: "", + metadata: .init( + name: "", + description: "", + url: "", + icons: [], + redirect: .init(native: "", universal: "") + ) + ) + return store } - static var previews: some View { + public var body: some View { VStack { - AccountButton(store: AccountButton_Preview.store(1.23)) + AccountButton(store: AccountButtonPreviewView.store(1.23)) - AccountButton(store: AccountButton_Preview.store(nil)) + AccountButton(store: AccountButtonPreviewView.store(nil)) - AccountButton(store: AccountButton_Preview.store(1.23)) + AccountButton(store: AccountButtonPreviewView.store(1.23)) .disabled(true) - AccountButton(store: AccountButton_Preview.store(nil)) + AccountButton(store: AccountButtonPreviewView.store(nil)) .disabled(true) - Button(action: {}, label: { -// Text("Foo") - }) - .buttonStyle( - AccountButtonStyle( - store: AccountButton_Preview.store(1.23), - isPressedOverride: true + Button(action: {}, label: {}) + .buttonStyle( + AccountButtonStyle( + store: AccountButtonPreviewView.store(1.23), + isPressedOverride: true + ) ) - ) - Button(action: {}, label: { -// Text("Foo") - }) - .buttonStyle( - AccountButtonStyle( - store: AccountButton_Preview.store(nil), - isPressedOverride: true + Button(action: {}, label: {}) + .buttonStyle( + AccountButtonStyle( + store: AccountButtonPreviewView.store(nil), + isPressedOverride: true + ) ) - ) } } } + +struct AccountButton_Preview: PreviewProvider { + static var previews: some View { + AccountButtonPreviewView() + } +} + +#endif diff --git a/Sources/Web3Modal/Components/ConnectButton.swift b/Sources/Web3Modal/Components/ConnectButton.swift index 3a1c9fe..38c0620 100644 --- a/Sources/Web3Modal/Components/ConnectButton.swift +++ b/Sources/Web3Modal/Components/ConnectButton.swift @@ -18,7 +18,13 @@ public struct ConnectButton: View { Web3Modal.present() } label: { if store.connecting { - CircleProgressView(color: .white, lineWidth: 2, isAnimating: .constant(true)) + DrawingProgressView( + shape: .circle, + color: .white, + lineWidth: 2, + duration: 1, + isAnimating: .constant(true) + ) .frame(width: 20, height: 20) } else { Text("Connect wallet") diff --git a/Sources/Web3Modal/Components/Web3Button.swift b/Sources/Web3Modal/Components/Web3ModalButton.swift similarity index 81% rename from Sources/Web3Modal/Components/Web3Button.swift rename to Sources/Web3Modal/Components/Web3ModalButton.swift index 30b53f0..5e08755 100644 --- a/Sources/Web3Modal/Components/Web3Button.swift +++ b/Sources/Web3Modal/Components/Web3ModalButton.swift @@ -1,7 +1,6 @@ import SwiftUI -public struct Web3Button: View { - +public struct Web3ModalButton: View { @ObservedObject var store: Store public init() { @@ -24,7 +23,6 @@ public struct Web3Button: View { } struct Web3Button_Preview: PreviewProvider { - static let store = { () -> Store in let store = Store() store.balance = 1.23 @@ -34,9 +32,9 @@ struct Web3Button_Preview: PreviewProvider { static var previews: some View { VStack { - Web3Button(store: Web3Button_Preview.store) + Web3ModalButton(store: Web3Button_Preview.store) - Web3Button(store: Web3Button_Preview.store) + Web3ModalButton(store: Web3Button_Preview.store) .disabled(true) } } diff --git a/Sources/Web3Modal/Components/NetworkButton.swift b/Sources/Web3Modal/Components/Web3ModalNetworkButton.swift similarity index 70% rename from Sources/Web3Modal/Components/NetworkButton.swift rename to Sources/Web3Modal/Components/Web3ModalNetworkButton.swift index 0c962d0..374001f 100644 --- a/Sources/Web3Modal/Components/NetworkButton.swift +++ b/Sources/Web3Modal/Components/Web3ModalNetworkButton.swift @@ -1,7 +1,7 @@ import SwiftUI import Web3ModalUI -public struct NetworkButton: View { +public struct Web3ModalNetworkButton: View { @ObservedObject var store: Store public init() { @@ -14,7 +14,6 @@ public struct NetworkButton: View { public var body: some View { if let selectedChain = store.selectedChain { - let storedImage = store.chainImages[selectedChain.imageId] let chainImage = Image( uiImage: storedImage ?? UIImage() @@ -45,7 +44,11 @@ public struct NetworkButton: View { } } -struct NetworkButton_Preview: PreviewProvider { +#if DEBUG + +public struct NetworkButtonPreviewView: View { + public init() {} + static let store = { (chain: Chain?) -> Store in let store = Store() store.balance = 1.23 @@ -54,21 +57,27 @@ struct NetworkButton_Preview: PreviewProvider { return store } - static var previews: some View { + public var body: some View { VStack { - NetworkButton(store: NetworkButton_Preview.store(nil)) - - ConnectButton() + Web3ModalNetworkButton(store: NetworkButtonPreviewView.store(nil)) - NetworkButton(store: NetworkButton_Preview.store(ChainsPresets.ethChains[0])) + Web3ModalNetworkButton(store: NetworkButtonPreviewView.store(ChainPresets.ethChains[0])) - NetworkButton(store: NetworkButton_Preview.store(ChainsPresets.ethChains[1])) + Web3ModalNetworkButton(store: NetworkButtonPreviewView.store(ChainPresets.ethChains[1])) - NetworkButton(store: NetworkButton_Preview.store(ChainsPresets.ethChains[0])) + Web3ModalNetworkButton(store: NetworkButtonPreviewView.store(ChainPresets.ethChains[0])) .disabled(true) - NetworkButton(store: NetworkButton_Preview.store(nil)) + Web3ModalNetworkButton(store: NetworkButtonPreviewView.store(nil)) .disabled(true) } } } + +struct NetworkButton_Preview: PreviewProvider { + static var previews: some View { + NetworkButtonPreviewView() + } +} + +#endif diff --git a/Sources/Web3Modal/Core/W3MAPIInteractor.swift b/Sources/Web3Modal/Core/W3MAPIInteractor.swift index 72c532b..8930ee7 100644 --- a/Sources/Web3Modal/Core/W3MAPIInteractor.swift +++ b/Sources/Web3Modal/Core/W3MAPIInteractor.swift @@ -66,6 +66,7 @@ final class W3MAPIInteractor: ObservableObject { } self.isLoading = false + self.objectWillChange.send() } } @@ -174,7 +175,7 @@ final class W3MAPIInteractor: ObservableObject { func prefetchChainImages() async throws { var chainImages: [String: UIImage] = [:] - try await ChainsPresets.ethChains.concurrentMap { chain in + try await ChainPresets.ethChains.concurrentMap { chain in let url = URL(string: "https://api.web3modal.com/public/getAssetImage/\(chain.imageId)")! var request = URLRequest(url: url) diff --git a/Sources/Web3Modal/Core/Web3Modal.swift b/Sources/Web3Modal/Core/Web3Modal.swift index 788f99a..7da9fe0 100644 --- a/Sources/Web3Modal/Core/Web3Modal.swift +++ b/Sources/Web3Modal/Core/Web3Modal.swift @@ -40,7 +40,7 @@ public class Web3Modal { if let blockchain = session.accounts.first?.blockchain { - let matchingChain = ChainsPresets.ethChains.first(where: { + let matchingChain = ChainPresets.ethChains.first(where: { $0.chainNamespace == blockchain.namespace && $0.chainReference == blockchain.reference }) @@ -53,7 +53,6 @@ public class Web3Modal { struct Config { let projectId: String - var chainId: Blockchain var metadata: AppMetadata var sessionParams: SessionParams @@ -71,7 +70,6 @@ public class Web3Modal { /// - metadata: App metadata public static func configure( projectId: String, - chainId: Blockchain, metadata: AppMetadata, sessionParams: SessionParams = .default, recommendedWalletIds: [String] = [], @@ -81,22 +79,13 @@ public class Web3Modal { Pair.configure(metadata: metadata) Web3Modal.config = Web3Modal.Config( projectId: projectId, - chainId: chainId, metadata: metadata, sessionParams: sessionParams, includeWebWallets: includeWebWallets, recommendedWalletIds: recommendedWalletIds, excludedWalletIds: excludedWalletIds ) - - let matchingChain = ChainsPresets.ethChains.first(where: { - $0.chainNamespace == chainId.namespace && $0.chainReference == chainId.reference - }) - - Store.shared.selectedChain = matchingChain - - Task { let interactor = W3MAPIInteractor() @@ -111,7 +100,6 @@ public class Web3Modal { Web3Modal.config.sessionParams = sessionParams } - public static func getSelectedChain() -> Chain? { guard let chain = Store.shared.selectedChain else { return nil @@ -125,6 +113,14 @@ public class Web3Modal { extension Web3Modal { + public static func addChainPreset(_ chain: Chain) { + ChainPresets.ethChains.append(chain) + } + + public static func selectChain(_ chain: Chain) { + Store.shared.selectedChain = chain + } + public static func selectChain(from presentingViewController: UIViewController? = nil) { guard let vc = presentingViewController ?? topViewController() else { assertionFailure("No controller found for presenting modal") @@ -208,7 +204,7 @@ public struct SessionParams { public static let `default`: Self = { let methods: Set = Set(EthUtils.ethMethods) let events: Set = ["chainChanged", "accountsChanged"] - let blockchains: Set = Set(ChainsPresets.ethChains.map(\.id).compactMap(Blockchain.init)) + let blockchains: Set = Set(ChainPresets.ethChains.map(\.id).compactMap(Blockchain.init)) let namespaces: [String: ProposalNamespace] = [ "eip155": ProposalNamespace( diff --git a/Sources/Web3Modal/Helpers/Session+Stub.swift b/Sources/Web3Modal/Helpers/Session+Stub.swift index 2693938..2c69fa9 100644 --- a/Sources/Web3Modal/Helpers/Session+Stub.swift +++ b/Sources/Web3Modal/Helpers/Session+Stub.swift @@ -1,6 +1,7 @@ import WalletConnectSign import Foundation +#if DEBUG extension Session { static let stubJson: Data = """ { @@ -46,3 +47,5 @@ extension Session { try! JSONDecoder().decode(Session.self, from: Session.stubJson) }() } + +#endif diff --git a/Sources/Web3Modal/Models/Chain.swift b/Sources/Web3Modal/Models/Chain.swift index 11b910f..73a6ea2 100644 --- a/Sources/Web3Modal/Models/Chain.swift +++ b/Sources/Web3Modal/Models/Chain.swift @@ -1,26 +1,46 @@ import Foundation public struct Chain: Identifiable, Hashable { - struct Token: Hashable { - var name: String - var symbol: String - var decimal: Int - } - + public var id: String { "\(chainNamespace):\(chainReference)" } public var chainName: String - var chainNamespace: String - var chainReference: String - var requiredMethods: [String] - var optionalMethods: [String] - var events: [String] - var token: Token - var rpcUrl: String - var blockExplorerUrl: String - var imageId: String + public var chainNamespace: String + public var chainReference: String + public var requiredMethods: [String] + public var optionalMethods: [String] + public var events: [String] + public var token: Token + public var rpcUrl: String + public var blockExplorerUrl: String + public var imageId: String + + public struct Token: Hashable { + public var name: String + public var symbol: String + public var decimal: Int + + public init(name: String, symbol: String, decimal: Int) { + self.name = name + self.symbol = symbol + self.decimal = decimal + } + } + + public init(chainName: String, chainNamespace: String, chainReference: String, requiredMethods: [String], optionalMethods: [String], events: [String], token: Chain.Token, rpcUrl: String, blockExplorerUrl: String, imageId: String) { + self.chainName = chainName + self.chainNamespace = chainNamespace + self.chainReference = chainReference + self.requiredMethods = requiredMethods + self.optionalMethods = optionalMethods + self.events = events + self.token = token + self.rpcUrl = rpcUrl + self.blockExplorerUrl = blockExplorerUrl + self.imageId = imageId + } } enum EthUtils { @@ -42,10 +62,10 @@ enum EthUtils { static let ethEvents = [chainChanged, accountsChanged] } -enum ChainsPresets { +enum ChainPresets { static let ethToken = Chain.Token(name: "Ether", symbol: "ETH", decimal: 18) - static let ethChains: [Chain] = [ + static var ethChains: [Chain] = [ Chain( chainName: "Ethereum", chainNamespace: "eip155", diff --git a/Sources/Web3Modal/Router.swift b/Sources/Web3Modal/Router.swift index 15cab8c..ef021b8 100644 --- a/Sources/Web3Modal/Router.swift +++ b/Sources/Web3Modal/Router.swift @@ -57,6 +57,7 @@ class Router: ObservableObject { enum NetworkSwitchSubpage: SubPage { case selectChain + case networkDetail(Chain) case whatIsANetwork } } diff --git a/Sources/Web3Modal/Screens/AccountView.swift b/Sources/Web3Modal/Screens/AccountView.swift index b4e3ce5..a7b0161 100644 --- a/Sources/Web3Modal/Screens/AccountView.swift +++ b/Sources/Web3Modal/Screens/AccountView.swift @@ -16,7 +16,7 @@ struct AccountView: View { } var selectedChain: Chain { - return store.selectedChain ?? ChainsPresets.ethChains.first! + return store.selectedChain ?? ChainPresets.ethChains.first! } var body: some View { diff --git a/Sources/Web3Modal/Screens/ChainSwitch/ChainSelectView.swift b/Sources/Web3Modal/Screens/ChainSwitch/ChainSelectView.swift index b6dad66..3bec969 100644 --- a/Sources/Web3Modal/Screens/ChainSwitch/ChainSelectView.swift +++ b/Sources/Web3Modal/Screens/ChainSwitch/ChainSelectView.swift @@ -10,7 +10,6 @@ struct ChainSelectView: View { var body: some View { VStack(spacing: 0) { modalHeader() - Divider() routes() } .background(Color.Background125) @@ -26,6 +25,8 @@ struct ChainSelectView: View { grid() case .whatIsANetwork: WhatIsNetworkView() + case let .networkDetail(chain): + NetworkDetailView(viewModel: .init(chain: chain, router: router)) } } @@ -33,14 +34,48 @@ struct ChainSelectView: View { private func grid() -> some View { let collumns = Array(repeating: GridItem(.flexible()), count: 4) - ScrollView { - LazyVGrid(columns: collumns) { - ForEach(ChainsPresets.ethChains, id: \.self) { chain in - gridElement(for: chain) + VStack { + VStack { + LazyVGrid(columns: collumns) { + ForEach(ChainPresets.ethChains, id: \.self) { chain in + gridElement(for: chain) + } } + .padding(.horizontal) + .padding(.vertical) + } + + + Divider() + + VStack(spacing: 0) { + + Text("Your connected wallet may not support some of the networks available for this dApp") + .fixedSize(horizontal: false, vertical: true) + .font(.small500) + .foregroundColor(.Foreground300) + .multilineTextAlignment(.center) + + Button { + router.setRoute(Router.NetworkSwitchSubpage.whatIsANetwork) + } label: { + HStack { + Image.QuestionMarkCircle + .resizable() + .frame(width: 12, height: 12) + + Text("What is a network") + } + .foregroundColor(.Blue100) + .font(.small500) + } + .padding(.vertical, Spacing.xs) + .background(Color.clear) + .contentShape(Rectangle()) } .padding(.horizontal) - .padding(.vertical) + .padding(.top, Spacing.xs) + .padding(.bottom, Spacing.xl) } } @@ -48,11 +83,19 @@ struct ChainSelectView: View { let isSelected = chain.id == store.selectedChain?.id let currentChains = viewModel.getChains() - let isChainApproved = true // store.session != nil ? currentChains.contains(chain) : true + let currentMethods = viewModel.getMethods() + let needToSendSwitchRequest = currentMethods.contains("wallet_switchEthereumChain") + let isChainApproved = store.session != nil ? currentChains.contains(chain) : true return Button(action: { - Task { - await self.viewModel.switchChain(chain) + if store.session == nil { + store.selectedChain = chain + router.setRoute(Router.ConnectingSubpage.connectWallet) + } else if isChainApproved && !needToSendSwitchRequest { + store.selectedChain = chain + router.setRoute(Router.AccountSubpage.profile) + } else { + router.setRoute(Router.NetworkSwitchSubpage.networkDetail(chain)) } }, label: { Text(chain.chainName) @@ -67,7 +110,25 @@ struct ChainSelectView: View { }, isSelected: isSelected )) - .disabled(isSelected || !isChainApproved) + .disabled({ + if isSelected { + return true + } + + if store.session == nil { + return false + } + + if needToSendSwitchRequest { + return false + } + + if !currentChains.contains(chain) { + return true + } + + return false + }()) } private func modalHeader() -> some View { @@ -78,8 +139,6 @@ struct ChainSelectView: View { case .selectChain: if router.previousRoute as? Router.AccountSubpage == .profile { backButton() - } else { - helpButton() } default: backButton() @@ -107,14 +166,6 @@ struct ChainSelectView: View { .cornerRadius(30, corners: [.topLeft, .topRight]) } - private func helpButton() -> some View { - Button(action: { - router.setRoute(Router.NetworkSwitchSubpage.whatIsANetwork) - }, label: { - Image.QuestionMarkCircle - }) - } - private func backButton() -> some View { Button { router.goBack() @@ -141,6 +192,8 @@ extension Router.NetworkSwitchSubpage { return "Select network" case .whatIsANetwork: return "What is a network?" + case let .networkDetail(chain): + return chain.chainName } } } diff --git a/Sources/Web3Modal/Screens/ChainSwitch/NetworkDetail/NetworkDetailView.swift b/Sources/Web3Modal/Screens/ChainSwitch/NetworkDetail/NetworkDetailView.swift new file mode 100644 index 0000000..b920d24 --- /dev/null +++ b/Sources/Web3Modal/Screens/ChainSwitch/NetworkDetail/NetworkDetailView.swift @@ -0,0 +1,70 @@ +import SwiftUI +import Web3ModalUI + +struct NetworkDetailView: View { + @EnvironmentObject var store: Store + + @ObservedObject var viewModel: NetworkDetailViewModel + + + var body: some View { + VStack { + content() + .onAppear { + viewModel.handle(.onAppear) + } + } + } + + @ViewBuilder + func content() -> some View { + VStack(spacing: 0) { + chainImage() + .padding(.top, 20) + .padding(.bottom, Spacing.l) + } + .padding(.horizontal) + .padding(.bottom, Spacing.xl * 2) + } + + func chainImage() -> some View { + VStack(spacing: Spacing.xs) { + ZStack { + Image( + uiImage: store.chainImages[viewModel.chain.imageId] ?? UIImage() + ) + .resizable() + .frame(width: 80, height: 80) + .clipShape(Polygon(count: 6, relativeCornerRadius: 0.25)) + .cornerRadius(Radius.m) + + if !viewModel.switchFailed { + DrawingProgressView(shape: .hexagon, color: .Blue100, lineWidth: 3, isAnimating: .constant(true)) + .frame(width: 90, height: 90) + } + } + .padding(.bottom, Spacing.s) + + Text(!viewModel.switchFailed ? "Approve in wallet" : "Switch declined") + .font(.paragraph500) + .foregroundColor(.Foreground100) + + Text(!viewModel.switchFailed ? "Accept connection request in your wallet" : "Switch can be declined if chain is not supported by a wallet or previous request is still active") + .font(.small500) + .foregroundColor(.Foreground200) + .multilineTextAlignment(.center) + + if viewModel.switchFailed { + Button { + viewModel.handle(.didTapRetry) + } label: { + Text("Try again") + .font(.small600) + .foregroundColor(.Blue100) + } + .padding(Spacing.xl) + .buttonStyle(W3MButtonStyle(size: .m, variant: .accent, leftIcon: Image.Retry)) + } + } + } +} diff --git a/Sources/Web3Modal/Screens/ChainSwitch/NetworkDetail/NetworkDetailViewModel.swift b/Sources/Web3Modal/Screens/ChainSwitch/NetworkDetail/NetworkDetailViewModel.swift new file mode 100644 index 0000000..37b656d --- /dev/null +++ b/Sources/Web3Modal/Screens/ChainSwitch/NetworkDetail/NetworkDetailViewModel.swift @@ -0,0 +1,222 @@ +import Combine +import SwiftUI +import WalletConnectSign +import WalletConnectUtils + +final class NetworkDetailViewModel: ObservableObject { + enum Event { + case onAppear + case didTapRetry + } + + @Published var switchFailed: Bool = false + var triedAddingChain: Bool = false + + let chain: Chain + let router: Router + let store: Store + + private var disposeBag = Set() + + init( + chain: Chain, + router: Router, + store: Store = .shared + ) { + self.chain = chain + self.router = router + self.store = store + + Task { @MainActor [weak self] in + for await (event, _, _) in Web3Modal.instance.sessionEventPublisher.values { + guard let self = self else { return } + + switch event.name { + case "chainChanged": + guard let chainReference = try? event.data.get(Int.self) else { + return + } + + self.store.selectedChain = ChainPresets.ethChains.first(where: { $0.chainReference == String(chainReference) }) + self.router.setRoute(Router.AccountSubpage.profile) + + case "accountsChanged": + + guard let account = try? event.data.get([String].self) else { + return + } + + let chainReference = account[0].split(separator: ":")[1] + + self.store.selectedChain = ChainPresets.ethChains.first(where: { $0.chainReference == String(chainReference) }) + self.router.setRoute(Router.AccountSubpage.profile) + default: + break + } + } + + for await response in Web3Modal.instance.sessionResponsePublisher.values { + guard let self = self else { return } + + switch response.result { + case .response: + self.store.selectedChain = chain + self.router.setRoute(Router.AccountSubpage.profile) + case .error: + + if self.triedAddingChain == false { + guard let from = store.selectedChain else { + return + } + + self.triedAddingChain = true + try? await self.addEthChain(from: from, to: chain) + } else { + self.switchFailed = true + self.objectWillChange.send() + } + } + } + } + } + + func handle(_ event: Event) { + switch event { + case .onAppear: + Task { @MainActor in + // Switch chain + await switchChain(chain) + } + case .didTapRetry: + + triedAddingChain = false + switchFailed = false + Task { @MainActor in + // Retry switch chain + await switchChain(chain) + } + } + } + + @MainActor + func switchChain(_ to: Chain) async { + guard let from = store.selectedChain else { return } + + do { + try await switchEthChain(from: from, to: to) + } catch { + print(error) + } + } + + @MainActor + private func switchEthChain( + from: Chain, + to: Chain + ) async throws { + guard let session = store.session else { return } + guard let chainIdNumber = Int(to.chainReference) else { return } + + let chainHex = String(format: "%X", chainIdNumber) + try await Web3Modal.instance.request(params: + .init( + topic: session.topic, + method: EthUtils.walletSwitchEthChain, + params: AnyCodable([AnyCodable(ChainSwitchParams(chainId: "0x\(chainHex)"))]), + chainId: .init(from.id)! + ) + ) + + // TODO: Nice to have: Somehow open the wallet with switch confirmation dialog + } + + @MainActor + private func addEthChain( + from: Chain, + to: Chain + ) async throws { + guard let session = store.session else { return } + + try await Web3Modal.instance.request(params: + .init( + topic: session.topic, + method: EthUtils.walletAddEthChain, + params: AnyCodable([AnyCodable(createAddEthChainParams(chain: to))]), + chainId: .init(from.id)! + ) + ) + } + + func createAddEthChainParams(chain: Chain) -> ChainAddParams? { + guard let chainIdNumber = Int(chain.chainReference) else { return nil } + + let chainHex = String(format: "%X", chainIdNumber) + + return ChainAddParams( + chainId: "0x\(chainHex)", + blockExplorerUrls: [ + chain.blockExplorerUrl + ], + chainName: chain.chainName, + nativeCurrency: .init( + name: chain.token.name, + symbol: chain.token.symbol, + decimals: chain.token.decimal + ), + rpcUrls: [ + chain.rpcUrl + ], + iconUrls: [ + chain.imageId + ] + ) + } + + struct ChainAddParams: Codable { + let chainId: String + let blockExplorerUrls: [String] + let chainName: String + let nativeCurrency: NativeCurrency + let rpcUrls: [String] + let iconUrls: [String] + + struct NativeCurrency: Codable { + let name: String + let symbol: String + let decimals: Int + } + } + + struct ChainSwitchParams: Codable { + let chainId: String + } +} + +private extension AnyPublisher { + enum AsyncError: Error { + case finishedWithoutValue + } + + func async() async throws -> Output { + try await withCheckedThrowingContinuation { continuation in + var cancellable: AnyCancellable? + var finishedWithoutValue = true + cancellable = first() + .receive(on: DispatchQueue.main) + .sink { result in + switch result { + case .finished: + if finishedWithoutValue { + continuation.resume(throwing: AsyncError.finishedWithoutValue) + } + case let .failure(error): + continuation.resume(throwing: error) + } + cancellable?.cancel() + } receiveValue: { value in + finishedWithoutValue = false + continuation.resume(with: .success(value)) + } + } + } +} diff --git a/Sources/Web3Modal/Screens/ConnectWallet/AllWalletsView.swift b/Sources/Web3Modal/Screens/ConnectWallet/AllWalletsView.swift index 10380ad..2aa021f 100644 --- a/Sources/Web3Modal/Screens/ConnectWallet/AllWalletsView.swift +++ b/Sources/Web3Modal/Screens/ConnectWallet/AllWalletsView.swift @@ -63,17 +63,19 @@ struct AllWalletsView: View { if interactor.isLoading || interactor.page < interactor.totalPage { ForEach(1 ... 8, id: \.self) { _ in - Button(action: {}, label: { Text("Wallet") }) + Button(action: {}, label: { Text("Loading") }) .buttonStyle(W3MCardSelectStyle( variant: .wallet, imageContent: { Color.Overgray005.modifier(ShimmerBackground()) }, - isLoading: $interactor.isLoading + isLoading: .constant(true) )) } .onAppear { - if !interactor.isLoading { fetchWallets() } +// if !interactor.isLoading { + fetchWallets() +// } } } } diff --git a/Sources/Web3Modal/Screens/ConnectWallet/WalletDetail/WalletDetail.swift b/Sources/Web3Modal/Screens/ConnectWallet/WalletDetail/WalletDetail.swift index b68961b..e387033 100644 --- a/Sources/Web3Modal/Screens/ConnectWallet/WalletDetail/WalletDetail.swift +++ b/Sources/Web3Modal/Screens/ConnectWallet/WalletDetail/WalletDetail.swift @@ -73,12 +73,11 @@ struct WalletDetail: View { @ViewBuilder func content() -> some View { VStack(spacing: 0) { - walletImage() + + walletImage() .padding(.top, 40) .padding(.bottom, Spacing.l) - - appStoreRow() .opacity(viewModel.preferredPlatform != .native ? 0 : 1) } @@ -88,12 +87,17 @@ struct WalletDetail: View { func walletImage() -> some View { VStack(spacing: Spacing.xs) { - Image( - uiImage: store.walletImages[viewModel.wallet.imageId] ?? UIImage() - ) - .resizable() - .frame(width: 80, height: 80) - .cornerRadius(Radius.m) + ZStack { + Image( + uiImage: store.walletImages[viewModel.wallet.imageId] ?? UIImage() + ) + .resizable() + .frame(width: 80, height: 80) + .cornerRadius(Radius.m) + + DrawingProgressView(shape: .roundedRectangleAbsolute(cornerRadius: 20), color: .Blue100, lineWidth: 3, isAnimating: .constant(true)) + .frame(width: 100, height: 100) + } .padding(.bottom, Spacing.s) Text("Continue in \(viewModel.wallet.name)") diff --git a/Sources/Web3Modal/Screens/ConnectWallet/WhatIsWalletView.swift b/Sources/Web3Modal/Screens/ConnectWallet/WhatIsWalletView.swift index 225c785..0cd4289 100644 --- a/Sources/Web3Modal/Screens/ConnectWallet/WhatIsWalletView.swift +++ b/Sources/Web3Modal/Screens/ConnectWallet/WhatIsWalletView.swift @@ -45,7 +45,7 @@ struct WhatIsWalletView: View { }) { HStack { Image.Wallet - Text("Get a Wallet") + Text("Get a wallet") } } } @@ -57,16 +57,16 @@ struct WhatIsWalletView: View { func sections() -> [HelpSection] { [ - HelpSection( - title: "A home for your digital assets", - description: "A wallet lets you store, send and receive digital assets like cryptocurrencies and NFTs.", - assets: [.imageDeFi, .imageNft, .imageEth] - ), HelpSection( title: "One login for all of web3", description: "Log in to any app by connecting your wallet. Say goodbye to countless passwords!", assets: [.imageLogin, .imageProfile, .imageLock] ), + HelpSection( + title: "A home for your digital assets", + description: "A wallet lets you store, send and receive digital assets like cryptocurrencies and NFTs.", + assets: [.imageDeFi, .imageNft, .imageEth] + ), HelpSection( title: "Your gateway to a new web", description: "With your wallet, you can explore and interact with DeFi, NFTs, DAOs, and much more.", diff --git a/Sources/Web3Modal/Sheets/Web3ModalViewModel.swift b/Sources/Web3Modal/Sheets/Web3ModalViewModel.swift index a5327ae..ca58a51 100644 --- a/Sources/Web3Modal/Sheets/Web3ModalViewModel.swift +++ b/Sources/Web3Modal/Sheets/Web3ModalViewModel.swift @@ -24,14 +24,7 @@ class Web3ModalViewModel: ObservableObject { self.w3mApiInteractor = w3mApiInteractor self.signInteractor = signInteractor self.blockchainApiInteractor = blockchainApiInteractor - - signInteractor.sessionResponsePublisher - .receive(on: DispatchQueue.main) - .sink { response in - print(response) - } - .store(in: &disposeBag) - + signInteractor.sessionSettlePublisher .receive(on: DispatchQueue.main) .sink { session in @@ -44,7 +37,7 @@ class Web3ModalViewModel: ObservableObject { if let blockchain = session.accounts.first?.blockchain, - let matchingChain = ChainsPresets.ethChains.first(where: { $0.chainNamespace == blockchain.namespace && $0.chainReference == blockchain.reference }) + let matchingChain = ChainPresets.ethChains.first(where: { $0.chainNamespace == blockchain.namespace && $0.chainReference == blockchain.reference }) { store.selectedChain = matchingChain } @@ -99,8 +92,9 @@ class Web3ModalViewModel: ObservableObject { return } - store.selectedChain = ChainsPresets.ethChains.first(where: { $0.chainReference == String(chainReference) }) - + store.selectedChain = ChainPresets.ethChains.first(where: { $0.chainReference == String(chainReference) }) + self.fetchBalance() + self.fetchIdentity() default: return } @@ -132,178 +126,6 @@ class Web3ModalViewModel: ObservableObject { } } - func switchChain(_ to: Chain) async { - guard let from = store.selectedChain else { return } - - if self.store.session == nil { - DispatchQueue.main.async { - self.store.selectedChain = to - self.router.setRoute(Router.ConnectingSubpage.connectWallet) - } - } - - do { - try await switchEthChain(from: from, to: to) - } catch { - print(error.localizedDescription) - DispatchQueue.main.async { - self.store.toast = .init(style: .error, message: "Failed to switchEthChain trying addEthChain instead") - } - // TODO: Call addChain only if the error code is 4902 - - do { - try await addEthChain(from: from, to: to) - } catch { - DispatchQueue.main.async { - self.store.toast = .init(style: .error, message: "Failed to addEthChain") - } - } - } - - DispatchQueue.main.async { - if self.store.session != nil { - self.router.setRoute(Router.AccountSubpage.profile) - } else { - self.router.setRoute(Router.ConnectingSubpage.connectWallet) - } - } - } - - @discardableResult - private func switchEthChain( - from: Chain, - to: Chain - ) async throws -> String? { - guard let session = store.session else { return nil } - guard let chainIdNumber = Int(to.chainReference) else { return nil } - - let chainHex = String(format: "%X", chainIdNumber) - - try await Web3Modal.instance.request(params: - .init( - topic: session.topic, - method: EthUtils.walletSwitchEthChain, - params: AnyCodable([AnyCodable(ChainSwitchParams(chainId: "0x\(chainHex)"))]), - chainId: .init(from.id)! - ) - ) - - // TODO: Nice to have: Somehow open the wallet with switch confirmation dialog - - let result = try await withCheckedThrowingContinuation { continuation in - var cancellable: AnyCancellable? - cancellable = Web3Modal.instance.sessionResponsePublisher - .sink { response in - defer { cancellable?.cancel() } - switch response.result { - case .response(let value): - do { - let string = try value.get(String.self) - continuation.resume(returning: string) - } catch { - continuation.resume(throwing: error) - } - case .error(let error): - continuation.resume(throwing: error) - } - } - } - - DispatchQueue.main.async { - self.store.selectedChain = to - self.fetchBalance() - } - - return result - } - - @discardableResult - private func addEthChain( - from: Chain, - to: Chain - ) async throws -> String? { - guard let session = store.session else { return nil } - - try await Web3Modal.instance.request(params: - .init( - topic: session.topic, - method: EthUtils.walletAddEthChain, - params: AnyCodable([AnyCodable(createAddEthChainParams(chain: to))]), - chainId: .init(from.id)! - ) - ) - - let result = try await withCheckedThrowingContinuation { continuation in - var cancellable: AnyCancellable? - cancellable = Web3Modal.instance.sessionResponsePublisher - .sink { response in - defer { cancellable?.cancel() } - switch response.result { - case .response(let value): - do { - let string = try value.get(String.self) - continuation.resume(returning: string) - } catch { - continuation.resume(throwing: error) - } - case .error(let error): - continuation.resume(throwing: error) - } - } - } - - DispatchQueue.main.async { - self.store.selectedChain = to - self.fetchBalance() - } - - return result - } - - func createAddEthChainParams(chain: Chain) -> ChainAddParams? { - guard let chainIdNumber = Int(chain.chainReference) else { return nil } - - let chainHex = String(format: "%X", chainIdNumber) - - return ChainAddParams( - chainId: "0x\(chainHex)", - blockExplorerUrls: [ - chain.blockExplorerUrl - ], - chainName: chain.chainName, - nativeCurrency: .init( - name: chain.token.name, - symbol: chain.token.symbol, - decimals: chain.token.decimal - ), - rpcUrls: [ - chain.rpcUrl - ], - iconUrls: [ - chain.imageId - ] - ) - } - - struct ChainAddParams: Codable { - let chainId: String - let blockExplorerUrls: [String] - let chainName: String - let nativeCurrency: NativeCurrency - let rpcUrls: [String] - let iconUrls: [String] - - struct NativeCurrency: Codable { - let name: String - let symbol: String - let decimals: Int - } - } - - struct ChainSwitchParams: Codable { - let chainId: String - } - func getChains() -> [Chain] { guard let namespaces = store.session?.namespaces.values else { return [] @@ -329,10 +151,27 @@ class Web3ModalViewModel: ObservableObject { return chains .compactMap { chain in - ChainsPresets.ethChains.first(where: { chain.reference == $0.chainReference && chain.namespace == $0.chainNamespace }) + ChainPresets.ethChains.first(where: { chain.reference == $0.chainReference && chain.namespace == $0.chainNamespace }) } } + func getMethods() -> [String] { + + guard let session = store.session else { + return [] + } + + let methods = session.namespaces.values + .compactMap { $0.methods } + .flatMap { $0 } + + let requiredMethods = session.requiredNamespaces.values + .compactMap { $0.methods } + .flatMap { $0 } + + return (methods + requiredMethods) + } + func isChainIdCAIP2Compliant(chainId: String) -> Bool { let elements = chainId.split(separator: ":") guard elements.count == 2 else { return false } diff --git a/Sources/Web3ModalUI/Components/W3MListItemButtonStyle.swift b/Sources/Web3ModalUI/Components/W3MListItemButtonStyle.swift index 43afcd5..d419d28 100644 --- a/Sources/Web3ModalUI/Components/W3MListItemButtonStyle.swift +++ b/Sources/Web3ModalUI/Components/W3MListItemButtonStyle.swift @@ -43,9 +43,11 @@ public struct W3MListItemButtonStyle: ButtonStyle { Group { if isLoading { - CircleProgressView( + DrawingProgressView( + shape: .circle, color: .Blue100, lineWidth: 2 * scale, + duration: 1, isAnimating: $isLoading ) .frame(width: 15 * scale, height: 15 * scale) diff --git a/Sources/Web3ModalUI/Miscellaneous/CircleProgressView.swift b/Sources/Web3ModalUI/Miscellaneous/CircleProgressView.swift deleted file mode 100644 index 273df6e..0000000 --- a/Sources/Web3ModalUI/Miscellaneous/CircleProgressView.swift +++ /dev/null @@ -1,257 +0,0 @@ -import UIKit -import SwiftUI - -public struct CircleProgressView: UIViewRepresentable { - - var color: Color - var lineWidth: CGFloat - - @Binding var isAnimating: Bool - - public init(color: Color, lineWidth: CGFloat, isAnimating: Binding) { - self.color = color - self.lineWidth = lineWidth - self._isAnimating = isAnimating - } - - public func makeUIView(context: Context) -> CircleProgressUIView { - let view = CircleProgressUIView( - colors: [UIColor(color)], - lineWidth: lineWidth - ) - - view.isAnimating = true - - return view - } - - public func updateUIView(_ uiView: CircleProgressUIView, context: Context) { - - } -} - -struct CircleProgressView_Preview: PreviewProvider { - static var previews: some View { - CircleProgressView( - color: .blue, - lineWidth: 10, - isAnimating: .constant(true) - ) - .frame(width: 100, height: 100) - } -} - -public class CircleProgressUIView: UIView { - - // MARK: - Properties - let colors: [UIColor] - let lineWidth: CGFloat - - private lazy var shapeLayer: ProgressShapeLayer = { - return ProgressShapeLayer(strokeColor: colors.first!, lineWidth: lineWidth) - }() - - var isAnimating: Bool = false { - didSet { - if isAnimating { - self.animateStroke() - self.animateRotation() - } else { - self.shapeLayer.removeFromSuperlayer() - self.layer.removeAllAnimations() - self.removeFromSuperview() - } - } - } - - // MARK: - Initialization - init(frame: CGRect, - colors: [UIColor], - lineWidth: CGFloat - ) { - self.colors = colors - self.lineWidth = lineWidth - - super.init(frame: frame) - - self.backgroundColor = .clear - } - - convenience init(colors: [UIColor], lineWidth: CGFloat) { - self.init(frame: .zero, colors: colors, lineWidth: lineWidth) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) is not supported") - } - - public override func layoutSubviews() { - super.layoutSubviews() - - self.layer.cornerRadius = self.frame.width / 2 - - let path = UIBezierPath(ovalIn: - CGRect( - x: 0, - y: 0, - width: self.bounds.width, - height: self.bounds.width - ) - ).cgPath - - shapeLayer.path = path - } - - // MARK: - Animations - func animateStroke() { - - let startAnimation = StrokeAnimation( - type: .start, - beginTime: 0.25, - fromValue: 0.0, - toValue: 1.0, - duration: 0.75 - ) - - let endAnimation = StrokeAnimation( - type: .end, - fromValue: 0.0, - toValue: 1.0, - duration: 0.75 - ) - - let strokeAnimationGroup = CAAnimationGroup() - strokeAnimationGroup.duration = 1 - strokeAnimationGroup.repeatDuration = .infinity - strokeAnimationGroup.animations = [startAnimation, endAnimation] - - shapeLayer.add(strokeAnimationGroup, forKey: nil) - - let colorAnimation = StrokeColorAnimation( - colors: colors.map { $0.cgColor }, - duration: strokeAnimationGroup.duration * Double(colors.count) - ) - - shapeLayer.add(colorAnimation, forKey: nil) - - self.layer.addSublayer(shapeLayer) - } - - func animateRotation() { - let rotationAnimation = RotationAnimation( - direction: .z, - fromValue: 0, - toValue: CGFloat.pi * 2, - duration: 2, - repeatCount: .greatestFiniteMagnitude - ) - - self.layer.add(rotationAnimation, forKey: nil) - } -} - -class ProgressShapeLayer: CAShapeLayer { - - public init(strokeColor: UIColor, lineWidth: CGFloat) { - super.init() - - self.strokeColor = strokeColor.cgColor - self.lineWidth = lineWidth - self.fillColor = UIColor.clear.cgColor - self.lineCap = .round - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -class RotationAnimation: CABasicAnimation { - - enum Direction: String { - case x, y, z - } - - override init() { - super.init() - } - - public init( - direction: Direction, - fromValue: CGFloat, - toValue: CGFloat, - duration: Double, - repeatCount: Float - ) { - - super.init() - - self.keyPath = "transform.rotation.\(direction.rawValue)" - - self.fromValue = fromValue - self.toValue = toValue - - self.duration = duration - - self.repeatCount = repeatCount - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - -} - -class StrokeAnimation: CABasicAnimation { - - override init() { - super.init() - } - - init(type: StrokeType, - beginTime: Double = 0.0, - fromValue: CGFloat, - toValue: CGFloat, - duration: Double) { - - super.init() - - self.keyPath = type == .start ? "strokeStart" : "strokeEnd" - - self.beginTime = beginTime - self.fromValue = fromValue - self.toValue = toValue - self.duration = duration - self.timingFunction = .init(name: .easeInEaseOut) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - enum StrokeType { - case start - case end - } -} - -class StrokeColorAnimation: CAKeyframeAnimation { - - override init() { - super.init() - } - - init(colors: [CGColor], duration: Double) { - super.init() - - self.keyPath = "strokeColor" - self.values = colors - self.duration = duration - self.repeatCount = .greatestFiniteMagnitude - self.timingFunction = .init(name: .easeInEaseOut) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} diff --git a/Sources/Web3ModalUI/Miscellaneous/DrawingProgressView.swift b/Sources/Web3ModalUI/Miscellaneous/DrawingProgressView.swift new file mode 100644 index 0000000..3e788ff --- /dev/null +++ b/Sources/Web3ModalUI/Miscellaneous/DrawingProgressView.swift @@ -0,0 +1,337 @@ +import SwiftUI +import UIKit + +public struct DrawingProgressView: UIViewRepresentable { + let shape: DrawingProgressUIView.ShapePath + let color: Color + let lineWidth: CGFloat + let duration: Double + + @Binding var isAnimating: Bool + + public init( + shape: DrawingProgressUIView.ShapePath, + color: Color, + lineWidth: CGFloat, + duration: Double = 1.5, + isAnimating: Binding + ) { + self.shape = shape + self.color = color + self.lineWidth = lineWidth + self.duration = duration + self._isAnimating = isAnimating + } + + public func makeUIView(context: Context) -> DrawingProgressUIView { + let view = DrawingProgressUIView( + shape: shape, + colors: [UIColor(color)], + lineWidth: lineWidth, + duration: duration + ) + + view.isAnimating = true + + switch shape { + case .circle, .roundedRectangleRelative, .roundedRectangleAbsolute: + view.transform = .identity.rotated(by: -CGFloat.pi / 2) + case .hexagon: + break + } + + return view + } + + public func updateUIView(_ uiView: DrawingProgressUIView, context: Context) {} +} + +struct DrawingProgressView_Preview: PreviewProvider { + static var previews: some View { + VStack { + DrawingProgressView( + shape: .circle, + color: .blue, + lineWidth: 5, + isAnimating: .constant(true) + ) + .frame(width: 100, height: 100) + + DrawingProgressView( + shape: .roundedRectangleAbsolute(cornerRadius: 5), + color: .blue, + lineWidth: 5, + isAnimating: .constant(true) + ) + .frame(width: 100, height: 100) + + DrawingProgressView( + shape: .roundedRectangleRelative(relativeCornerRadius: 0.25), + color: .blue, + lineWidth: 5, + isAnimating: .constant(true) + ) + .frame(width: 100, height: 100) + + DrawingProgressView( + shape: .hexagon, + color: .blue, + lineWidth: 5, + isAnimating: .constant(true) + ) + .frame(width: 100, height: 100) + } + } +} + +public class DrawingProgressUIView: UIView { + public enum ShapePath { + case circle + case roundedRectangleRelative(relativeCornerRadius: Double) + case roundedRectangleAbsolute(cornerRadius: Double) + case hexagon + } + + // MARK: - Properties + + let shape: ShapePath + let colors: [UIColor] + let lineWidth: CGFloat + let duration: Double + + private lazy var shapeLayer: ProgressShapeLayer = .init(strokeColor: colors.first!, lineWidth: lineWidth) + + var isAnimating: Bool = false { + didSet { + if self.isAnimating { + self.animateStroke() + } else { + self.shapeLayer.removeFromSuperlayer() + self.layer.removeAllAnimations() + self.removeFromSuperview() + } + } + } + + // MARK: - Initialization + + init( + shape: ShapePath, + frame: CGRect, + colors: [UIColor], + lineWidth: CGFloat, + duration: Double + ) { + self.shape = shape + self.colors = colors + self.lineWidth = lineWidth + self.duration = duration + + super.init(frame: frame) + + self.backgroundColor = .clear + } + + convenience init( + shape: ShapePath, + colors: [UIColor], + lineWidth: CGFloat, + duration: Double + ) { + self.init( + shape: shape, + frame: .zero, + colors: colors, + lineWidth: lineWidth, + duration: duration + ) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override public func layoutSubviews() { + super.layoutSubviews() + + + let path: CGPath + + switch self.shape { + case .circle: + path = UIBezierPath(ovalIn: frame).cgPath + case let .roundedRectangleRelative(relativeCornerRadius): + path = CGPath( + roundedRect: self.frame, + cornerWidth: self.frame.width * relativeCornerRadius, + cornerHeight: self.frame.height * relativeCornerRadius, + transform: nil + ) + case let .roundedRectangleAbsolute(cornerRadius): + path = CGPath( + roundedRect: self.frame, + cornerWidth: cornerRadius, + cornerHeight: cornerRadius, + transform: nil + ) + case .hexagon: + path = Polygon(count: 6, relativeCornerRadius: 0.25).path(in: frame).cgPath + } + + self.shapeLayer.path = path + } + + // MARK: - Animations + + func animateStroke() { + let beginTime = 0.25 + + let startAnimation = StrokeAnimation( + type: .start, + beginTime: beginTime, + fromValue: 0.0, + toValue: 1.0, + duration: duration - beginTime + ) + + let endAnimation = StrokeAnimation( + type: .end, + fromValue: 0.0, + toValue: 1.0, + duration: duration - beginTime + ) + + let strokeAnimationGroup = CAAnimationGroup() + strokeAnimationGroup.duration = duration + strokeAnimationGroup.repeatDuration = .infinity + strokeAnimationGroup.animations = [startAnimation, endAnimation] + + self.shapeLayer.add(strokeAnimationGroup, forKey: nil) + + let colorAnimation = StrokeColorAnimation( + colors: colors.map { $0.cgColor }, + duration: strokeAnimationGroup.duration * Double(self.colors.count) + ) + + self.shapeLayer.add(colorAnimation, forKey: nil) + + self.layer.addSublayer(self.shapeLayer) + } + + func animateRotation() { + let rotationAnimation = RotationAnimation( + direction: .z, + fromValue: 0, + toValue: CGFloat.pi * 2, + duration: 2, + repeatCount: .greatestFiniteMagnitude + ) + + self.layer.add(rotationAnimation, forKey: nil) + } +} + +class ProgressShapeLayer: CAShapeLayer { + public init(strokeColor: UIColor, lineWidth: CGFloat) { + super.init() + + self.strokeColor = strokeColor.cgColor + self.lineWidth = lineWidth + self.fillColor = UIColor.clear.cgColor + self.lineCap = .square + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +class RotationAnimation: CABasicAnimation { + enum Direction: String { + case x, y, z + } + + override init() { + super.init() + } + + public init( + direction: Direction, + fromValue: CGFloat, + toValue: CGFloat, + duration: Double, + repeatCount: Float + ) { + super.init() + + self.keyPath = "transform.rotation.\(direction.rawValue)" + + self.fromValue = fromValue + self.toValue = toValue + + self.duration = duration + + self.repeatCount = repeatCount + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +class StrokeAnimation: CABasicAnimation { + override init() { + super.init() + } + + init(type: StrokeType, + beginTime: Double = 0.0, + fromValue: CGFloat, + toValue: CGFloat, + duration: Double) + { + super.init() + + self.keyPath = type == .start ? "strokeStart" : "strokeEnd" + + self.beginTime = beginTime + self.fromValue = fromValue + self.toValue = toValue + self.duration = duration + self.timingFunction = .init(name: .easeInEaseOut) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + enum StrokeType { + case start + case end + } +} + +class StrokeColorAnimation: CAKeyframeAnimation { + override init() { + super.init() + } + + init(colors: [CGColor], duration: Double) { + super.init() + + self.keyPath = "strokeColor" + self.values = colors + self.duration = duration + self.repeatCount = .greatestFiniteMagnitude + self.timingFunction = .init(name: .easeInEaseOut) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Sources/Web3ModalUI/Miscellaneous/Toast.swift b/Sources/Web3ModalUI/Miscellaneous/Toast.swift index 39fa674..26eada8 100644 --- a/Sources/Web3ModalUI/Miscellaneous/Toast.swift +++ b/Sources/Web3ModalUI/Miscellaneous/Toast.swift @@ -3,7 +3,7 @@ import SwiftUI public struct Toast: Equatable { let style: ToastStyle let message: String - var duration: Double = 3 + var duration: Double = 1.5 var width: Double = .infinity public init(style: ToastStyle, message: String, duration: Double = 3, width: Double = .infinity) { diff --git a/Tests/Web3ModalTests/AccountButtonSnapshotTests.swift b/Tests/Web3ModalTests/AccountButtonSnapshotTests.swift new file mode 100644 index 0000000..9cc6d2e --- /dev/null +++ b/Tests/Web3ModalTests/AccountButtonSnapshotTests.swift @@ -0,0 +1,13 @@ +import SnapshotTesting +import SwiftUI +@testable import Web3Modal +import XCTest + +final class AccountButtonSnapshotTests: XCTestCase { + + func test_snapshots() throws { + let view = AccountButtonPreviewView() + assertSnapshot(matching: view, as: .image(layout: .device(config: .iPhone13), traits: .init(userInterfaceStyle: .dark))) + assertSnapshot(matching: view, as: .image(layout: .device(config: .iPhone13), traits: .init(userInterfaceStyle: .light))) + } +} diff --git a/Tests/Web3ModalTests/NetworkButtonSnapshotTests.swift b/Tests/Web3ModalTests/NetworkButtonSnapshotTests.swift new file mode 100644 index 0000000..a938c2f --- /dev/null +++ b/Tests/Web3ModalTests/NetworkButtonSnapshotTests.swift @@ -0,0 +1,13 @@ +import SnapshotTesting +import SwiftUI +@testable import Web3Modal +import XCTest + +final class NetworkButtonSnapshotTests: XCTestCase { + + func test_snapshots() throws { + let view = NetworkButtonPreviewView() + assertSnapshot(matching: view, as: .image(layout: .device(config: .iPhone13), traits: .init(userInterfaceStyle: .dark))) + assertSnapshot(matching: view, as: .image(layout: .device(config: .iPhone13), traits: .init(userInterfaceStyle: .light))) + } +} diff --git a/Tests/Web3ModalTests/__Snapshots__/AccountButtonSnapshotTests/test_snapshots.1.png b/Tests/Web3ModalTests/__Snapshots__/AccountButtonSnapshotTests/test_snapshots.1.png new file mode 100644 index 0000000..2da5d9a Binary files /dev/null and b/Tests/Web3ModalTests/__Snapshots__/AccountButtonSnapshotTests/test_snapshots.1.png differ diff --git a/Tests/Web3ModalTests/__Snapshots__/AccountButtonSnapshotTests/test_snapshots.2.png b/Tests/Web3ModalTests/__Snapshots__/AccountButtonSnapshotTests/test_snapshots.2.png new file mode 100644 index 0000000..d6b25cf Binary files /dev/null and b/Tests/Web3ModalTests/__Snapshots__/AccountButtonSnapshotTests/test_snapshots.2.png differ diff --git a/Tests/Web3ModalTests/__Snapshots__/NetworkButtonSnapshotTests/test_snapshots.1.png b/Tests/Web3ModalTests/__Snapshots__/NetworkButtonSnapshotTests/test_snapshots.1.png new file mode 100644 index 0000000..41097a0 Binary files /dev/null and b/Tests/Web3ModalTests/__Snapshots__/NetworkButtonSnapshotTests/test_snapshots.1.png differ diff --git a/Tests/Web3ModalTests/__Snapshots__/NetworkButtonSnapshotTests/test_snapshots.2.png b/Tests/Web3ModalTests/__Snapshots__/NetworkButtonSnapshotTests/test_snapshots.2.png new file mode 100644 index 0000000..55d41ae Binary files /dev/null and b/Tests/Web3ModalTests/__Snapshots__/NetworkButtonSnapshotTests/test_snapshots.2.png differ diff --git a/Tests/Web3ModalTests/__Snapshots__/QRCodeViewSnapshotTests/test_snapshots.1.png b/Tests/Web3ModalTests/__Snapshots__/QRCodeViewSnapshotTests/test_snapshots.1.png index d92755c..9257389 100644 Binary files a/Tests/Web3ModalTests/__Snapshots__/QRCodeViewSnapshotTests/test_snapshots.1.png and b/Tests/Web3ModalTests/__Snapshots__/QRCodeViewSnapshotTests/test_snapshots.1.png differ diff --git a/Tests/Web3ModalTests/__Snapshots__/QRCodeViewSnapshotTests/test_snapshots.2.png b/Tests/Web3ModalTests/__Snapshots__/QRCodeViewSnapshotTests/test_snapshots.2.png index 9f196fc..692fe3e 100644 Binary files a/Tests/Web3ModalTests/__Snapshots__/QRCodeViewSnapshotTests/test_snapshots.2.png and b/Tests/Web3ModalTests/__Snapshots__/QRCodeViewSnapshotTests/test_snapshots.2.png differ diff --git a/Tests/Web3ModalUITests/__Snapshots__/W3MActionEntrySnapshotTests/test_snapshots.1.png b/Tests/Web3ModalUITests/__Snapshots__/W3MActionEntrySnapshotTests/test_snapshots.1.png index ae97e26..f0b510a 100644 Binary files a/Tests/Web3ModalUITests/__Snapshots__/W3MActionEntrySnapshotTests/test_snapshots.1.png and b/Tests/Web3ModalUITests/__Snapshots__/W3MActionEntrySnapshotTests/test_snapshots.1.png differ diff --git a/Tests/Web3ModalUITests/__Snapshots__/W3MActionEntrySnapshotTests/test_snapshots.2.png b/Tests/Web3ModalUITests/__Snapshots__/W3MActionEntrySnapshotTests/test_snapshots.2.png index 58a7d70..c7e3aaf 100644 Binary files a/Tests/Web3ModalUITests/__Snapshots__/W3MActionEntrySnapshotTests/test_snapshots.2.png and b/Tests/Web3ModalUITests/__Snapshots__/W3MActionEntrySnapshotTests/test_snapshots.2.png differ diff --git a/Tests/Web3ModalUITests/__Snapshots__/W3MButtonSnapshotTests/test_snapshots.1.png b/Tests/Web3ModalUITests/__Snapshots__/W3MButtonSnapshotTests/test_snapshots.1.png index cba2dbe..3d1ecd7 100644 Binary files a/Tests/Web3ModalUITests/__Snapshots__/W3MButtonSnapshotTests/test_snapshots.1.png and b/Tests/Web3ModalUITests/__Snapshots__/W3MButtonSnapshotTests/test_snapshots.1.png differ diff --git a/Tests/Web3ModalUITests/__Snapshots__/W3MButtonSnapshotTests/test_snapshots.2.png b/Tests/Web3ModalUITests/__Snapshots__/W3MButtonSnapshotTests/test_snapshots.2.png index 04a9928..ee6af7e 100644 Binary files a/Tests/Web3ModalUITests/__Snapshots__/W3MButtonSnapshotTests/test_snapshots.2.png and b/Tests/Web3ModalUITests/__Snapshots__/W3MButtonSnapshotTests/test_snapshots.2.png differ diff --git a/Tests/Web3ModalUITests/__Snapshots__/W3MCardSelectSnapshotTests/test_snapshots.1.png b/Tests/Web3ModalUITests/__Snapshots__/W3MCardSelectSnapshotTests/test_snapshots.1.png index ef1465f..6de71c3 100644 Binary files a/Tests/Web3ModalUITests/__Snapshots__/W3MCardSelectSnapshotTests/test_snapshots.1.png and b/Tests/Web3ModalUITests/__Snapshots__/W3MCardSelectSnapshotTests/test_snapshots.1.png differ diff --git a/Tests/Web3ModalUITests/__Snapshots__/W3MCardSelectSnapshotTests/test_snapshots.2.png b/Tests/Web3ModalUITests/__Snapshots__/W3MCardSelectSnapshotTests/test_snapshots.2.png index 5879e57..a75af50 100644 Binary files a/Tests/Web3ModalUITests/__Snapshots__/W3MCardSelectSnapshotTests/test_snapshots.2.png and b/Tests/Web3ModalUITests/__Snapshots__/W3MCardSelectSnapshotTests/test_snapshots.2.png differ diff --git a/Tests/Web3ModalUITests/__Snapshots__/W3MChipButtonSnapshotTests/test_snapshots.1.png b/Tests/Web3ModalUITests/__Snapshots__/W3MChipButtonSnapshotTests/test_snapshots.1.png index 9d715d3..5f6548b 100644 Binary files a/Tests/Web3ModalUITests/__Snapshots__/W3MChipButtonSnapshotTests/test_snapshots.1.png and b/Tests/Web3ModalUITests/__Snapshots__/W3MChipButtonSnapshotTests/test_snapshots.1.png differ diff --git a/Tests/Web3ModalUITests/__Snapshots__/W3MChipButtonSnapshotTests/test_snapshots.2.png b/Tests/Web3ModalUITests/__Snapshots__/W3MChipButtonSnapshotTests/test_snapshots.2.png index 88c4fb5..f0e12b5 100644 Binary files a/Tests/Web3ModalUITests/__Snapshots__/W3MChipButtonSnapshotTests/test_snapshots.2.png and b/Tests/Web3ModalUITests/__Snapshots__/W3MChipButtonSnapshotTests/test_snapshots.2.png differ diff --git a/Tests/Web3ModalUITests/__Snapshots__/W3MListItemSnapshotTests/test_snapshots.1.png b/Tests/Web3ModalUITests/__Snapshots__/W3MListItemSnapshotTests/test_snapshots.1.png index 5cd3a63..a4ae219 100644 Binary files a/Tests/Web3ModalUITests/__Snapshots__/W3MListItemSnapshotTests/test_snapshots.1.png and b/Tests/Web3ModalUITests/__Snapshots__/W3MListItemSnapshotTests/test_snapshots.1.png differ diff --git a/Tests/Web3ModalUITests/__Snapshots__/W3MListItemSnapshotTests/test_snapshots.2.png b/Tests/Web3ModalUITests/__Snapshots__/W3MListItemSnapshotTests/test_snapshots.2.png index 8b2b5d5..966ca39 100644 Binary files a/Tests/Web3ModalUITests/__Snapshots__/W3MListItemSnapshotTests/test_snapshots.2.png and b/Tests/Web3ModalUITests/__Snapshots__/W3MListItemSnapshotTests/test_snapshots.2.png differ diff --git a/Tests/Web3ModalUITests/__Snapshots__/W3MListItemSnapshotTests/test_snapshots.3.png b/Tests/Web3ModalUITests/__Snapshots__/W3MListItemSnapshotTests/test_snapshots.3.png index f710875..50b3eee 100644 Binary files a/Tests/Web3ModalUITests/__Snapshots__/W3MListItemSnapshotTests/test_snapshots.3.png and b/Tests/Web3ModalUITests/__Snapshots__/W3MListItemSnapshotTests/test_snapshots.3.png differ diff --git a/Tests/Web3ModalUITests/__Snapshots__/W3MListSelectSnapshotTests/test_snapshots.1.png b/Tests/Web3ModalUITests/__Snapshots__/W3MListSelectSnapshotTests/test_snapshots.1.png index 89136a9..2d760af 100644 Binary files a/Tests/Web3ModalUITests/__Snapshots__/W3MListSelectSnapshotTests/test_snapshots.1.png and b/Tests/Web3ModalUITests/__Snapshots__/W3MListSelectSnapshotTests/test_snapshots.1.png differ diff --git a/Tests/Web3ModalUITests/__Snapshots__/W3MListSelectSnapshotTests/test_snapshots.2.png b/Tests/Web3ModalUITests/__Snapshots__/W3MListSelectSnapshotTests/test_snapshots.2.png index 255ec28..b4885b5 100644 Binary files a/Tests/Web3ModalUITests/__Snapshots__/W3MListSelectSnapshotTests/test_snapshots.2.png and b/Tests/Web3ModalUITests/__Snapshots__/W3MListSelectSnapshotTests/test_snapshots.2.png differ diff --git a/Tests/Web3ModalUITests/__Snapshots__/W3MListSelectSnapshotTests/test_snapshots.3.png b/Tests/Web3ModalUITests/__Snapshots__/W3MListSelectSnapshotTests/test_snapshots.3.png index e7c4e33..b37eb77 100644 Binary files a/Tests/Web3ModalUITests/__Snapshots__/W3MListSelectSnapshotTests/test_snapshots.3.png and b/Tests/Web3ModalUITests/__Snapshots__/W3MListSelectSnapshotTests/test_snapshots.3.png differ diff --git a/Tests/Web3ModalUITests/__Snapshots__/W3MTagSnapshotTests/test_snapshots.1.png b/Tests/Web3ModalUITests/__Snapshots__/W3MTagSnapshotTests/test_snapshots.1.png index 594f86d..82f3c97 100644 Binary files a/Tests/Web3ModalUITests/__Snapshots__/W3MTagSnapshotTests/test_snapshots.1.png and b/Tests/Web3ModalUITests/__Snapshots__/W3MTagSnapshotTests/test_snapshots.1.png differ diff --git a/Tests/Web3ModalUITests/__Snapshots__/W3MTagSnapshotTests/test_snapshots.2.png b/Tests/Web3ModalUITests/__Snapshots__/W3MTagSnapshotTests/test_snapshots.2.png index 3c5d647..efba887 100644 Binary files a/Tests/Web3ModalUITests/__Snapshots__/W3MTagSnapshotTests/test_snapshots.2.png and b/Tests/Web3ModalUITests/__Snapshots__/W3MTagSnapshotTests/test_snapshots.2.png differ diff --git a/run_tests.sh b/run_tests.sh index fea0b90..e00c9da 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -18,7 +18,7 @@ if [ -z "$SCHEME" ]; then fi # Create ephemeral simulator -DEVICE_ID=$(xcrun simctl create "EphemeralSim$SCHEME" "iPhone 14") +DEVICE_ID=$(xcrun simctl create "EphemeralSim$SCHEME" "iPhone 15 Pro") echo "Created ephemeral simulator with id: $DEVICE_ID" # Set XCBuild defaults @@ -101,4 +101,4 @@ update_xctestrun() { else echo "No value provided for $KEY" fi -} \ No newline at end of file +}