From 501f2f46dee4f48399eb43ef907c4c907a27d9d3 Mon Sep 17 00:00:00 2001 From: Maksym Rachytskyy Date: Tue, 19 Mar 2024 17:42:32 +0200 Subject: [PATCH] wal2-68: add search token by id functionality --- ConcordiumWallet.xcodeproj/project.pbxproj | 10 +++ .../TokenLookup/CIS2Service.swift | 39 +++++++++ .../CIS2TokenSelectView.swift | 83 ++++++++++++------- .../CIS2TokenSelectViewModel.swift | 37 +-------- .../SearchTokenViewModel.swift | 68 +++++++++++++++ 5 files changed, 172 insertions(+), 65 deletions(-) create mode 100644 ConcordiumWallet/Views/AccountsView/AccountDetails/TokenSelectionView/SearchTokenViewModel.swift diff --git a/ConcordiumWallet.xcodeproj/project.pbxproj b/ConcordiumWallet.xcodeproj/project.pbxproj index 64995ba1..6c56239f 100644 --- a/ConcordiumWallet.xcodeproj/project.pbxproj +++ b/ConcordiumWallet.xcodeproj/project.pbxproj @@ -413,6 +413,10 @@ 07E8573526F1D24F001FB2D2 /* RealmHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E8573326F1D24F001FB2D2 /* RealmHelper.swift */; }; 07E8573626F1D24F001FB2D2 /* RealmHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E8573326F1D24F001FB2D2 /* RealmHelper.swift */; }; 07E8573726F1D24F001FB2D2 /* RealmHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E8573326F1D24F001FB2D2 /* RealmHelper.swift */; }; + 080F53A32BA9E38800BADD38 /* SearchTokenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 080F53A22BA9E38800BADD38 /* SearchTokenViewModel.swift */; }; + 080F53A42BA9E38800BADD38 /* SearchTokenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 080F53A22BA9E38800BADD38 /* SearchTokenViewModel.swift */; }; + 080F53A52BA9E38800BADD38 /* SearchTokenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 080F53A22BA9E38800BADD38 /* SearchTokenViewModel.swift */; }; + 080F53A62BA9E38800BADD38 /* SearchTokenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 080F53A22BA9E38800BADD38 /* SearchTokenViewModel.swift */; }; 08F7ABD32B962CCA008588D2 /* CIS2TokenSelectViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08F7ABD22B962CCA008588D2 /* CIS2TokenSelectViewModel.swift */; }; 08F7ABD42B962CCA008588D2 /* CIS2TokenSelectViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08F7ABD22B962CCA008588D2 /* CIS2TokenSelectViewModel.swift */; }; 08F7ABD52B962CCA008588D2 /* CIS2TokenSelectViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08F7ABD22B962CCA008588D2 /* CIS2TokenSelectViewModel.swift */; }; @@ -2948,6 +2952,7 @@ 07E6E27327B558A30083A852 /* intro_flow_onboarding_en_1.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = intro_flow_onboarding_en_1.html; sourceTree = ""; }; 07E6E27527B558A30083A852 /* intro_flow_style.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; path = intro_flow_style.css; sourceTree = ""; }; 07E8573326F1D24F001FB2D2 /* RealmHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RealmHelper.swift; sourceTree = ""; }; + 080F53A22BA9E38800BADD38 /* SearchTokenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTokenViewModel.swift; sourceTree = ""; }; 08F7ABD22B962CCA008588D2 /* CIS2TokenSelectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIS2TokenSelectViewModel.swift; sourceTree = ""; }; 17A41E4A266789F10079BD3A /* StagingNet.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = StagingNet.entitlements; sourceTree = ""; }; 17A41E4B26678FF80079BD3A /* Concordium ID.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Concordium ID.entitlements"; sourceTree = ""; }; @@ -3716,6 +3721,7 @@ children = ( 0154A6772A8CEA0500C33954 /* CIS2TokenSelectView.swift */, 08F7ABD22B962CCA008588D2 /* CIS2TokenSelectViewModel.swift */, + 080F53A22BA9E38800BADD38 /* SearchTokenViewModel.swift */, ); path = TokenSelectionView; sourceTree = ""; @@ -6736,6 +6742,7 @@ 01145A3D2B03A4F8008331F5 /* Double+Rounding.swift in Sources */, 8DD48C7A2953136E00CA9085 /* EarnView.swift in Sources */, 7F48C1DA244DCD0B00997684 /* LetterSpacingLabel.swift in Sources */, + 080F53A32BA9E38800BADD38 /* SearchTokenViewModel.swift in Sources */, FABA9D95291888E40094012B /* SubmittedSeedAccountView.swift in Sources */, 8992BB212840D056005279C9 /* ShowAlertProtocol+ForceUpdate.swift in Sources */, 7F48C1DB244DCD0B00997684 /* IdentityConfirmedPresenter.swift in Sources */, @@ -7270,6 +7277,7 @@ 017A5F412A49C32B00E80B56 /* WalletConnectError.swift in Sources */, 5243C034278E2384005CAC8D /* SanityChecker.swift in Sources */, 7F85B787246A9A7C00ED09B8 /* LetterSpacingLabel.swift in Sources */, + 080F53A52BA9E38800BADD38 /* SearchTokenViewModel.swift in Sources */, 7F85B788246A9A7C00ED09B8 /* IdentityConfirmedPresenter.swift in Sources */, 89D467DB289B8CAA007D3FC8 /* PrivateIDObjectDataWrapper.swift in Sources */, 6461F5DF29FB2A6C00975BC5 /* TermsAndConditionsViewModel.swift in Sources */, @@ -7863,6 +7871,7 @@ 017A5F422A49C32C00E80B56 /* WalletConnectError.swift in Sources */, 7F85B881246A9AB600ED09B8 /* AccountTransactionsDataPresenter.swift in Sources */, 5243C035278E2384005CAC8D /* SanityChecker.swift in Sources */, + 080F53A62BA9E38800BADD38 /* SearchTokenViewModel.swift in Sources */, 7F85B882246A9AB600ED09B8 /* Metadata.swift in Sources */, 89D467DC289B8CAA007D3FC8 /* PrivateIDObjectDataWrapper.swift in Sources */, 6461F5E029FB2A6C00975BC5 /* TermsAndConditionsViewModel.swift in Sources */, @@ -8360,6 +8369,7 @@ 7FF07AC423EADBB100F1FC04 /* UITableView+Extensions.swift in Sources */, 7FF07AC123EADBB100F1FC04 /* UIColor+Additions.swift in Sources */, 5237188025753C4700B2BC31 /* MakeGenerateAccountsResponseElement.swift in Sources */, + 080F53A42BA9E38800BADD38 /* SearchTokenViewModel.swift in Sources */, C938F533241FB9B100ECAD47 /* UITextField+Extensions.swift in Sources */, 7FD878592406AF7C00509BDB /* LoginCoordinator.swift in Sources */, 5211BAB7250126B000EE1658 /* ShieldedAccountEncryptionStatus.swift in Sources */, diff --git a/ConcordiumWallet/Views/AccountsView/AccountDetails/TokenLookup/CIS2Service.swift b/ConcordiumWallet/Views/AccountsView/AccountDetails/TokenLookup/CIS2Service.swift index e0826ff4..e3f4d51e 100644 --- a/ConcordiumWallet/Views/AccountsView/AccountDetails/TokenLookup/CIS2Service.swift +++ b/ConcordiumWallet/Views/AccountsView/AccountDetails/TokenLookup/CIS2Service.swift @@ -26,6 +26,8 @@ protocol CIS2ServiceProtocol { func fetchTokensBalance(contractIndex: String, contractSubindex: String, accountAddress: String, tokenId: String) async throws -> [CIS2TokenBalance] func deleteTokenFromCache(_ token: CIS2TokenSelectionRepresentable) throws func fetchTokensMetadataDetails(url: URL) async throws -> CIS2TokenMetadataDetails + + func getTokenMetadataPair(metadata: CIS2TokensMetadata) async throws -> [(CIS2TokensMetadataItem, CIS2TokenMetadataDetails)] } class CIS2Service: CIS2ServiceProtocol { @@ -196,3 +198,40 @@ extension CIS2Service { return metadata } } + +extension CIS2Service { + /// Retrieves a pair of metadata items and their corresponding token metadata details. + /// + /// This function asynchronously retrieves pairs of metadata items and their corresponding token metadata details from the provided `CIS2TokensMetadata`. + /// It utilizes the `fetchTokensMetadataDetails` method of the `service` object to fetch token metadata details for each metadata item's URL. + /// If a metadata item's URL is invalid or fetching the details fails, it will be skipped in the result. + /// + /// - Parameter metadata: The `CIS2TokensMetadata` containing the metadata items. + /// + /// - Returns: An array of tuples, each containing a `CIS2TokensMetadataItem` and its corresponding `CIS2TokenMetadataDetails`. + /// + func getTokenMetadataPair(metadata: CIS2TokensMetadata) async throws -> [(CIS2TokensMetadataItem, CIS2TokenMetadataDetails)] { + var allData = [(CIS2TokensMetadataItem, CIS2TokenMetadataDetails?)]() + + try await withThrowingTaskGroup(of: (CIS2TokensMetadataItem, CIS2TokenMetadataDetails?).self) { [weak self] group in + guard let self = self else { return } + for metadata in metadata.metadata { + if let url = URL(string: metadata.metadataURL) { + group.addTask { + let result = try? await self.fetchTokensMetadataDetails(url: url) + return (metadata, result) + } + } + } + + for try await data in group { + allData.append(data) + } + } + + return allData.compactMap { (metadataItem, tokenMetadataDetails) -> (CIS2TokensMetadataItem, CIS2TokenMetadataDetails)? in + guard let tokenMetadataDetails = tokenMetadataDetails else { return nil } + return (metadataItem, tokenMetadataDetails) + } + } +} diff --git a/ConcordiumWallet/Views/AccountsView/AccountDetails/TokenSelectionView/CIS2TokenSelectView.swift b/ConcordiumWallet/Views/AccountsView/AccountDetails/TokenSelectionView/CIS2TokenSelectView.swift index 2abc5adb..c8e48a78 100644 --- a/ConcordiumWallet/Views/AccountsView/AccountDetails/TokenSelectionView/CIS2TokenSelectView.swift +++ b/ConcordiumWallet/Views/AccountsView/AccountDetails/TokenSelectionView/CIS2TokenSelectView.swift @@ -11,6 +11,7 @@ struct CIS2TokenSelectView: View { @State var selectedItems: Set @StateObject var viewModel: CIS2TokenSelectViewModel + @StateObject var searchTokenViewModel: SearchTokenViewModel var popView: () -> Void var showDetails: (_ token: CIS2TokenSelectionRepresentable) -> Void @@ -19,11 +20,7 @@ struct CIS2TokenSelectView: View { private let accountAddress: String private let contractIndex: String - - private var filteredTokens: [CIS2TokenSelectionRepresentable] { - viewModel.tokens.filter { tokenIndex.isEmpty ? true : $0.tokenId.contains(tokenIndex) } - } - + init( tokens: [CIS2Token], accountAdress: String, @@ -42,6 +39,7 @@ struct CIS2TokenSelectView: View { _selectedItems = State(initialValue: Set(service.observedTokens(for: accountAdress, filteredBy: contractIndex))) _viewModel = .init(wrappedValue: CIS2TokenSelectViewModel(allContractTokens: tokens, accountAdress: accountAdress, contractIndex: contractIndex, service: service)) + _searchTokenViewModel = .init(wrappedValue: SearchTokenViewModel(accountAdress: accountAdress, contractIndex: contractIndex, service: service)) } var body: some View { @@ -53,9 +51,15 @@ struct CIS2TokenSelectView: View { .foregroundColor(.gray) .padding(.leading, 8) TextField("Search for token ID", text: $tokenIndex) + .onChange(of: tokenIndex) { _ in searchTokenViewModel.tokens = [] } + .onSubmit { + searchTokenViewModel.runSearch(tokenIndex) + } .textInputAutocapitalization(.never) - .keyboardType(.numberPad) + /// we should allow user to type in letters since `token_id` is represented in hex + .keyboardType(.default) .padding(8) + .submitLabel(.search) } .background( RoundedRectangle(cornerRadius: 8) @@ -66,34 +70,19 @@ struct CIS2TokenSelectView: View { GeometryReader { proxy in ScrollView { LazyVStack { - if !viewModel.isLoading && viewModel.tokens.isEmpty && viewModel.currentPage != 1 { - ZStack { - Text("No tokens found.") - } - .frame(width: proxy.size.width, height: proxy.size.height) - } - - if filteredTokens.isEmpty && !tokenIndex.isEmpty { - ZStack { - Text("No tokens matching given predicate.") - } - .frame(width: proxy.size.width, height: proxy.size.height) + if tokenIndex.isEmpty { + AllTokensListView(proxy) } else { - ForEach(filteredTokens, id: \.self) { model in - CIS2TokenView(model: model) - .onTapGesture { showDetails(model) } - .padding(.vertical, 8) - .padding(.horizontal, 16) - } + SearchTokensListView(proxy) } - - LoadingStateView(proxy.size) } } } .refreshable { - viewModel.loadInitial() + if tokenIndex.isEmpty { + viewModel.loadInitial() + } } .overlay( RoundedRectangle(cornerRadius: 8) @@ -136,7 +125,44 @@ struct CIS2TokenSelectView: View { } } - @ViewBuilder + func AllTokensListView(_ proxy: GeometryProxy) -> some View { + Group { + if !viewModel.isLoading && viewModel.tokens.isEmpty && viewModel.currentPage != 1 { + ZStack { + Text("No tokens found.") + } + .frame(width: proxy.size.width, height: proxy.size.height) + } else { + ForEach(viewModel.tokens, id: \.self) { model in + CIS2TokenView(model: model) + .onTapGesture { showDetails(model) } + .padding(.vertical, 8) + .padding(.horizontal, 16) + } + LoadingStateView(proxy.size) + } + } + } + + func SearchTokensListView(_ proxy: GeometryProxy) -> some View { + Group { + if searchTokenViewModel.tokens.isEmpty { + ZStack { + Text("No tokens matching given predicate.") + } + .frame(width: proxy.size.width, height: proxy.size.height) + } else { + ProgressView().opacity(searchTokenViewModel.isSearching ? 1.0 : 0.0) + ForEach(searchTokenViewModel.tokens, id: \.self) { model in + CIS2TokenView(model: model) + .onTapGesture { showDetails(model) } + .padding(.vertical, 8) + .padding(.horizontal, 16) + } + } + } + } + func LoadingStateView(_ size: CGSize) -> some View { ZStack { switch(viewModel.isLoading, viewModel.hasMore) { @@ -153,7 +179,6 @@ struct CIS2TokenSelectView: View { .padding(16) } - @ViewBuilder func CIS2TokenView(model: CIS2TokenSelectionRepresentable) -> some View { HStack { AsyncImage( diff --git a/ConcordiumWallet/Views/AccountsView/AccountDetails/TokenSelectionView/CIS2TokenSelectViewModel.swift b/ConcordiumWallet/Views/AccountsView/AccountDetails/TokenSelectionView/CIS2TokenSelectViewModel.swift index 47bb0c64..5baa2afa 100644 --- a/ConcordiumWallet/Views/AccountsView/AccountDetails/TokenSelectionView/CIS2TokenSelectViewModel.swift +++ b/ConcordiumWallet/Views/AccountsView/AccountDetails/TokenSelectionView/CIS2TokenSelectViewModel.swift @@ -60,7 +60,7 @@ final class CIS2TokenSelectViewModel: ObservableObject { } let metadata = try await service.fetchTokensMetadata(contractIndex: contractIndex, contractSubindex: "0", tokenId: ids.map { $0.token }.joined(separator: ",")) - let metadataPairs = try await getTokenMetadataPair(metadata: metadata) + let metadataPairs = try await service.getTokenMetadataPair(metadata: metadata) let balances = try await service.fetchTokensBalance(contractIndex: contractIndex, contractSubindex: "0", accountAddress: accountAddress, tokenId: ids.map { $0.token }.joined(separator: ",")) let representables = metadataPairs.map { (metadataItem, details) in @@ -97,39 +97,4 @@ final class CIS2TokenSelectViewModel: ObservableObject { } } } - - /// Retrieves a pair of metadata items and their corresponding token metadata details. - /// - /// This function asynchronously retrieves pairs of metadata items and their corresponding token metadata details from the provided `CIS2TokensMetadata`. - /// It utilizes the `fetchTokensMetadataDetails` method of the `service` object to fetch token metadata details for each metadata item's URL. - /// If a metadata item's URL is invalid or fetching the details fails, it will be skipped in the result. - /// - /// - Parameter metadata: The `CIS2TokensMetadata` containing the metadata items. - /// - /// - Returns: An array of tuples, each containing a `CIS2TokensMetadataItem` and its corresponding `CIS2TokenMetadataDetails`. - /// - func getTokenMetadataPair(metadata: CIS2TokensMetadata) async throws -> [(CIS2TokensMetadataItem, CIS2TokenMetadataDetails)] { - var allData = [(CIS2TokensMetadataItem, CIS2TokenMetadataDetails?)]() - - try await withThrowingTaskGroup(of: (CIS2TokensMetadataItem, CIS2TokenMetadataDetails?).self) { [weak self] group in - guard let self = self else { return } - for metadata in metadata.metadata { - if let url = URL(string: metadata.metadataURL) { - group.addTask { - let result = try? await self.service.fetchTokensMetadataDetails(url: url) - return (metadata, result) - } - } - } - - for try await data in group { - allData.append(data) - } - } - - return allData.compactMap { (metadataItem, tokenMetadataDetails) -> (CIS2TokensMetadataItem, CIS2TokenMetadataDetails)? in - guard let tokenMetadataDetails = tokenMetadataDetails else { return nil } - return (metadataItem, tokenMetadataDetails) - } - } } diff --git a/ConcordiumWallet/Views/AccountsView/AccountDetails/TokenSelectionView/SearchTokenViewModel.swift b/ConcordiumWallet/Views/AccountsView/AccountDetails/TokenSelectionView/SearchTokenViewModel.swift new file mode 100644 index 00000000..091d891b --- /dev/null +++ b/ConcordiumWallet/Views/AccountsView/AccountDetails/TokenSelectionView/SearchTokenViewModel.swift @@ -0,0 +1,68 @@ +// +// SearchTokenViewModel.swift +// ConcordiumWallet +// +// Created by Maksym Rachytskyy on 19.03.2024. +// Copyright © 2024 concordium. All rights reserved. +// + +import Foundation +import BigInt + +final class SearchTokenViewModel: ObservableObject { + @Published var tokens: [CIS2TokenSelectionRepresentable] = [] + @Published var isSearching: Bool = false + + private let service: CIS2ServiceProtocol + private let accountAddress: String + private let contractIndex: String + + init( + accountAdress: String, + contractIndex: String, + service: CIS2ServiceProtocol + ){ + self.service = service + self.accountAddress = accountAdress + self.contractIndex = contractIndex + } + + func runSearch(_ tokenIndex: String) { + isSearching = true + Task { + do { + let data = try await searchTokenData(by: tokenIndex) + await MainActor.run { + tokens = data + isSearching = false + } + } catch { + await MainActor.run { + isSearching = false + } + } + } + } + + func searchTokenData(by tokenId: String) async throws -> [CIS2TokenSelectionRepresentable] { + let metadata = try await service.fetchTokensMetadata(contractIndex: contractIndex, contractSubindex: "0", tokenId: tokenId) + let metadataPairs = try await service.getTokenMetadataPair(metadata: metadata) + let balances = try await service.fetchTokensBalance(contractIndex: contractIndex, contractSubindex: "0", accountAddress: accountAddress, tokenId: tokenId) + + return metadataPairs.map { (metadataItem, details) in + CIS2TokenSelectionRepresentable( + contractName: metadata.contractName, + tokenId: metadataItem.tokenId, + balance: BigInt(balances.first(where: { $0.tokenId == metadataItem.tokenId })?.balance ?? "") ?? .zero, + contractIndex: contractIndex, + name: details.name, + symbol: details.symbol, + decimals: details.decimals ?? 6, + description: details.description, + thumbnail: details.thumbnail?.url, + display: details.display?.url, + unique: details.unique ?? false, + accountAddress: accountAddress) + } + } +}