diff --git a/.github/workflows/auto-deploy.yml b/.github/workflows/auto-deploy.yml index 872838cf0b..896ebcd584 100644 --- a/.github/workflows/auto-deploy.yml +++ b/.github/workflows/auto-deploy.yml @@ -12,6 +12,6 @@ jobs: if: ${{ contains(github.event.pull_request.labels.*.name, 'auto:qa-deploy') && !contains(github.event.pull_request.labels.*.name, 'no-qa') }} with: with_high_priority: ${{ contains(github.event.pull_request.labels.*.name, 'high') }} - move_to_rft: true + move_to_rft: false lane: "feature_test" secrets: inherit diff --git a/.github/workflows/key-app-kit-unit-test.yml b/.github/workflows/key-app-kit-unit-test.yml index 6d42ee065a..a7c788b0a7 100644 --- a/.github/workflows/key-app-kit-unit-test.yml +++ b/.github/workflows/key-app-kit-unit-test.yml @@ -47,6 +47,7 @@ jobs: -parallel-testing-enabled YES \ -resultBundlePath TestResults \ -only-testing:AnalyticsManagerUnitTests \ + -only-testing:BankTransferTests \ -only-testing:CountriesAPIUnitTests \ -only-testing:JSBridgeTests \ -only-testing:KeyAppKitCoreTests \ diff --git a/.swiftformat b/.swiftformat index 704476daf6..803b612f68 100644 --- a/.swiftformat +++ b/.swiftformat @@ -80,4 +80,4 @@ --wraptypealiases preserve --xcodeindentation enabled --yodaswap always ---enable blankLineAfterImports,isEmpty +--enable blankLineAfterImports,isEmpty \ No newline at end of file diff --git a/Packages/KeyAppKit/.swiftpm/xcode/xcshareddata/xcschemes/BankTransferTests.xcscheme b/Packages/KeyAppKit/.swiftpm/xcode/xcshareddata/xcschemes/BankTransferTests.xcscheme new file mode 100644 index 0000000000..7316160b9c --- /dev/null +++ b/Packages/KeyAppKit/.swiftpm/xcode/xcshareddata/xcschemes/BankTransferTests.xcscheme @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/KeyAppKit/.swiftpm/xcode/xcshareddata/xcschemes/KeyAppKit-Package.xcscheme b/Packages/KeyAppKit/.swiftpm/xcode/xcshareddata/xcschemes/KeyAppKit-Package.xcscheme index 131be7f02f..791aa237c4 100644 --- a/Packages/KeyAppKit/.swiftpm/xcode/xcshareddata/xcschemes/KeyAppKit-Package.xcscheme +++ b/Packages/KeyAppKit/.swiftpm/xcode/xcshareddata/xcschemes/KeyAppKit-Package.xcscheme @@ -496,6 +496,20 @@ ReferencedContainer = "container:"> + + + + + + + + Self { + .init( + userId: userId ?? self.userId, + mobileVerified: mobileVerified ?? self.mobileVerified, + kycStatus: kycStatus ?? self.kycStatus, + mobileNumber: mobileNumber ?? self.mobileNumber, + wallet: wallet ?? self.wallet + ) + } +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Models/UserWallet.swift b/Packages/KeyAppKit/Sources/BankTransfer/Models/UserWallet.swift new file mode 100644 index 0000000000..0999f18244 --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Models/UserWallet.swift @@ -0,0 +1,54 @@ +public struct UserWallet: Codable { + public let walletId: String + public var accounts: UserAccounts +} + +public struct UserAccounts: Codable { + public var eur: EURUserAccount? + public var usdc: USDCUserAccount? +} + +public struct EURUserAccount: Codable { + public let accountID: String + public let currency: String + public let createdAt: String + public let enriched: Bool + public let iban: String? + public let bic: String? + public let bankAccountHolderName: String? + public let availableBalance: Int? + + public init( + accountID: String, + currency: String, + createdAt: String, + enriched: Bool, + availableBalance: Int?, + iban: String? = nil, + bic: String? = nil, + bankAccountHolderName: String? = nil + ) { + self.accountID = accountID + self.currency = currency + self.createdAt = createdAt + self.enriched = enriched + self.availableBalance = availableBalance + self.iban = iban + self.bic = bic + self.bankAccountHolderName = bankAccountHolderName + } +} + +public struct USDCUserAccount: Codable { + public let accountID: String + public let currency: String + public let createdAt: String + public let enriched: Bool + public let blockchainDepositAddress: String? + public var availableBalance: Int // Available balance in cents with fee + public var totalBalance: Int // Total balnce without Fee + + mutating func setAvailableBalance(_ amount: Int) { + availableBalance = amount + } +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Repository/BankTransferUserDataRepository.swift b/Packages/KeyAppKit/Sources/BankTransfer/Repository/BankTransferUserDataRepository.swift new file mode 100644 index 0000000000..4cecbded55 --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Repository/BankTransferUserDataRepository.swift @@ -0,0 +1,25 @@ +public protocol BankTransferUserDataRepository { + associatedtype WithdrawalInfo + + func getUserId() async -> String? + + func getKYCStatus() async throws -> StrigaKYC + + func getRegistrationData() async throws -> BankTransferRegistrationData + + func createUser(registrationData: BankTransferRegistrationData) async throws -> StrigaCreateUserResponse + + func verifyMobileNumber(userId: String, verificationCode code: String) async throws + func resendSMS(userId: String) async throws + + func getKYCToken(userId: String) async throws -> String + + func updateLocally(registrationData: BankTransferRegistrationData) async throws + func updateLocally(userData: UserData) async throws + func clearCache() async + + func getWallet(userId: String) async throws -> UserWallet? + + func getWithdrawalInfo(userId: String) async throws -> WithdrawalInfo? + func save(_ info: WithdrawalInfo) async throws +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Repository/DataProviders/CommonInfoLocalProvider.swift b/Packages/KeyAppKit/Sources/BankTransfer/Repository/DataProviders/CommonInfoLocalProvider.swift new file mode 100644 index 0000000000..cc0d0db0fb --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Repository/DataProviders/CommonInfoLocalProvider.swift @@ -0,0 +1,53 @@ +import Foundation + +public protocol CommonInfoLocalProvider { + func getCommonInfo() async -> UserCommonInfo? + func save(commonInfo: UserCommonInfo) async throws + + func clear() async +} + +public actor CommonInfoLocalProviderImpl { + private let cacheFile: URL = { + let arrayPaths = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) + let cacheDirectoryPath = arrayPaths[0] + return cacheDirectoryPath.appendingPathComponent("/bank-transfer-common.data") + }() + + // MARK: - Initializer + + public init() { + // migration + Task { + await migrate() + } + } + + // MARK: - Migration + + private func migrate() { + // Migration + let migrationKey = "CommonInfoLocalProviderImpl.migration1" + if !UserDefaults.standard.bool(forKey: migrationKey) { + clear() + UserDefaults.standard.set(true, forKey: migrationKey) + } + } +} + +extension CommonInfoLocalProviderImpl: CommonInfoLocalProvider { + public func getCommonInfo() async -> UserCommonInfo? { + guard let data = try? Data(contentsOf: cacheFile) else { return nil } + let cachedData = (try? JSONDecoder().decode(UserCommonInfo.self, from: data)) + return cachedData + } + + public func save(commonInfo: UserCommonInfo) async throws { + let data = try JSONEncoder().encode(commonInfo) + try data.write(to: cacheFile) + } + + public func clear() { + try? FileManager.default.removeItem(at: cacheFile) + } +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Service/BankTransferService.swift b/Packages/KeyAppKit/Sources/BankTransfer/Service/BankTransferService.swift new file mode 100644 index 0000000000..191920815b --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Service/BankTransferService.swift @@ -0,0 +1,37 @@ +import Combine +import KeyAppKitCore + +public protocol BankTransferService where Provider: BankTransferUserDataRepository { + associatedtype Provider + typealias WithdrawalInfo = Provider.WithdrawalInfo + + var state: AnyPublisher, Never> { get } + + func reload() async + + // MARK: - Registration: Local actions + + func updateLocally(data: BankTransferRegistrationData) async throws + func clearCache() async + + // MARK: - Registration: Remote actions + + func getRegistrationData() async throws -> BankTransferRegistrationData + func createUser(data: BankTransferRegistrationData) async throws + + func verify(OTP: String) async throws + func resendSMS() async throws + + func getKYCToken() async throws -> String + + func getWithdrawalInfo() async throws -> WithdrawalInfo? + func saveWithdrawalInfo(info: WithdrawalInfo) async throws +} + +public class AnyBankTransferService { + public var value: BankTransferServiceImpl + + public init(value: BankTransferServiceImpl) { + self.value = value + } +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Service/BankTransferServiceImpl.swift b/Packages/KeyAppKit/Sources/BankTransfer/Service/BankTransferServiceImpl.swift new file mode 100644 index 0000000000..ffd5d2e40c --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Service/BankTransferServiceImpl.swift @@ -0,0 +1,179 @@ +import Combine +import Foundation +import KeyAppKitCore + +/// Default implementation of `BankTransferService` +public final class BankTransferServiceImpl: BankTransferService { + public typealias Provider = T + + /// Repository that handle CRUD action for UserData + public let repository: Provider + + /// Subject that holds State with UserData stream + public let subject = CurrentValueSubject, Never>( + AsyncValueState(status: .initializing, value: .empty) + ) + + // MARK: - Initializers + + public init(repository: Provider) { + self.repository = repository + } + + // MARK: - Private + + /// Used to cache last KYC status + private var cachedKYC: StrigaKYC? // It has StrigaKYCStatus type because it's used in BankTransferService protocol +} + +public extension BankTransferServiceImpl { + var state: AnyPublisher, Never> { + subject.eraseToAnyPublisher() + } + + func reload() async { + // mark as loading + subject.send( + .init( + status: .fetching, + value: subject.value.value, + error: subject.value.error + ) + ) + + do { + // registered user + if let userId = await repository.getUserId() { + return try await handleRegisteredUser(userId: userId) + } + + // unregistered user + else { + return try await handleUnregisteredUser() + } + } catch { + return handleError(error: error) + } + } + + // MARK: - Registration + + func getRegistrationData() async throws -> BankTransferRegistrationData { + try await repository.getRegistrationData() + } + + func updateLocally(data: BankTransferRegistrationData) async throws { + try await repository.updateLocally(registrationData: data) + } + + func createUser(data: BankTransferRegistrationData) async throws { + let response = try await repository.createUser(registrationData: data) + subject.send( + .init( + status: subject.value.status, + value: subject.value.value.updated( + userId: response.userId, + kycStatus: response.KYC.status, + mobileNumber: data.mobileNumber + ), + error: subject.value.error + ) + ) + } + + func verify(OTP: String) async throws { + guard let userId = subject.value.value.userId else { throw BankTransferError.missingUserId } + try await repository.verifyMobileNumber(userId: userId, verificationCode: OTP) + + subject.send( + .init( + status: .ready, + value: subject.value.value.updated( + mobileVerified: true + ), + error: nil + ) + ) + } + + func resendSMS() async throws { + guard let userId = subject.value.value.userId else { throw BankTransferError.missingUserId } + try await repository.resendSMS(userId: userId) + } + + func getKYCToken() async throws -> String { + guard let userId = subject.value.value.userId else { throw BankTransferError.missingUserId } + return try await repository.getKYCToken(userId: userId) + } + + func clearCache() async { + await repository.clearCache() + } + + // MARK: - Helpers + + private func handleRegisteredUser(userId: String) async throws { + // check kyc status + var kycStatus: StrigaKYC + if let cachedKYC { + kycStatus = cachedKYC + } else { + kycStatus = try await repository.getKYCStatus() + // If status is approved -- cache response, ignore other fields + cachedKYC = kycStatus.status == .approved ? kycStatus : nil + } + // get user details + let registrationData = try await repository.getRegistrationData() + + var wallet: UserWallet? + if kycStatus.status == .approved { + wallet = try? await repository.getWallet(userId: userId) + } + + // update + subject.send( + .init( + status: .ready, + value: subject.value.value.updated( + userId: userId, + mobileVerified: kycStatus.mobileVerified, + kycStatus: kycStatus.status, + mobileNumber: registrationData.mobileNumber, + wallet: wallet + ), + error: nil + ) + ) + + try? await repository.updateLocally(userData: subject.value.value) + } + + private func handleUnregisteredUser() async throws { + subject.send( + .init( + status: .ready, + value: .empty, + error: nil + ) + ) + } + + private func handleError(error: Error) { + subject.send( + .init( + status: .ready, + value: .empty, + error: error + ) + ) + } + + func getWithdrawalInfo() async throws -> Provider.WithdrawalInfo? { + guard let userId = subject.value.value.userId else { throw BankTransferError.missingUserId } + return try await repository.getWithdrawalInfo(userId: userId) + } + + func saveWithdrawalInfo(info: Provider.WithdrawalInfo) async throws { + try await repository.save(info) + } +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Extensions/NSError+Extensions.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Extensions/NSError+Extensions.swift new file mode 100644 index 0000000000..e460388c7b --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Extensions/NSError+Extensions.swift @@ -0,0 +1,8 @@ +import Foundation + +extension NSError { + var isNetworkConnectionError: Bool { + code == NSURLErrorNetworkConnectionLost || code == NSURLErrorNotConnectedToInternet || code == + NSURLErrorDataNotAllowed || code == NSURLErrorTimedOut + } +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Mock/MockStrigaLocalProvider.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Mock/MockStrigaLocalProvider.swift new file mode 100644 index 0000000000..40d23acf73 --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Mock/MockStrigaLocalProvider.swift @@ -0,0 +1,102 @@ +import Foundation + +public actor MockStrigaLocalProvider: StrigaLocalProvider { + // MARK: - Properties + + private var useCase: MockStrigaUseCase + private var cachedRegistrationData: StrigaUserDetailsResponse? + + private var userId: String? + + // MARK: - Initializer + + public init( + useCase: MockStrigaUseCase, + hasCachedInput: Bool + ) { + self.useCase = useCase + if hasCachedInput { + var kyc: StrigaKYCStatus = .notStarted + if useCase == .registeredAndVerifiedUser { + kyc = .approved + } + + cachedRegistrationData = StrigaUserDetailsResponse( + firstName: "Tester", + lastName: "Tester1", + email: "test@test.test", + mobile: .init( + countryCode: "+84", + number: "776059617" + ), + dateOfBirth: .init( + year: "1984", + month: "03", + day: "12" + ), + address: .init( + addressLine1: "Test ts str1", + addressLine2: nil, + city: "Ant", + postalCode: "12345", + state: "Ant", + country: "fr" + ), + occupation: .accounting, + sourceOfFunds: .personalSavings, + placeOfBirth: "FRA", + KYC: StrigaKYC( + status: kyc, + mobileVerified: false + ) + ) + } + } + + // MARK: - Methods + + public func getUserId() async -> String? { + userId + } + + public func saveUserId(_ id: String) async { + userId = id + } + + public func getCachedRegistrationData() async -> StrigaUserDetailsResponse? { + cachedRegistrationData + } + + public func save(registrationData: StrigaUserDetailsResponse) async throws { + cachedRegistrationData = registrationData + } + + public func getWhitelistedUserDestinations() async throws -> [StrigaWhitelistAddressResponse] { + fatalError() + } + + public func getCachedUserData() async -> UserData? { + fatalError() + } + + public func save(userData _: UserData) async throws { +// fatalError() + } + + public func save(whitelisted _: [StrigaWhitelistAddressResponse]) async throws { + fatalError() + } + + public func getCachedWithdrawalInfo() async -> StrigaWithdrawalInfo? { + fatalError() + } + + public func save(withdrawalInfo _: StrigaWithdrawalInfo) async throws { + fatalError() + } + + public func clear() async { + cachedRegistrationData = nil + useCase = .unregisteredUser + } +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Mock/MockStrigaMetadataProvider.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Mock/MockStrigaMetadataProvider.swift new file mode 100644 index 0000000000..d0ae582200 --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Mock/MockStrigaMetadataProvider.swift @@ -0,0 +1,28 @@ +import Foundation + +public class MockStrigaMetadataProvider: StrigaMetadataProvider { + private var metadata: StrigaMetadata + + public init(useCase: MockStrigaUseCase, mockUserId: String) { + let userId: String? + switch useCase { + case .unregisteredUser: + userId = nil + case .registeredUserWithUnverifiedOTP: + userId = mockUserId + case .registeredUserWithoutKYC: + userId = mockUserId + case .registeredAndVerifiedUser: + userId = mockUserId + } + metadata = .init(userId: userId, email: "elon.musk@gmail.com", phoneNumber: "+84773497461") + } + + public func getStrigaMetadata() async -> StrigaMetadata? { + metadata + } + + public func updateMetadata(withUserId userId: String) async { + metadata = .init(userId: userId, email: metadata.email, phoneNumber: metadata.phoneNumber) + } +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Mock/MockStrigaRemoteProvider.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Mock/MockStrigaRemoteProvider.swift new file mode 100644 index 0000000000..8aa89f0673 --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Mock/MockStrigaRemoteProvider.swift @@ -0,0 +1,224 @@ +import Foundation + +public final class MockStrigaRemoteProvider: StrigaRemoteProvider { + // MARK: - Properties + + private var useCase: MockStrigaUseCase + private let mockUserId: String + private let mockKYCToken: String + + // MARK: - Initializer + + public init( + useCase: MockStrigaUseCase, + mockUserId: String, + mockKYCToken: String + ) { + self.useCase = useCase + self.mockUserId = mockUserId + self.mockKYCToken = mockKYCToken + } + + // MARK: - Methods + + public func getKYCStatus(userId _: String) async throws -> StrigaKYC { + // Fake network request + try await Task.sleep(nanoseconds: 1_000_000_000) + + let mobileVerified: Bool + + switch useCase { + case .unregisteredUser, .registeredUserWithUnverifiedOTP: + mobileVerified = false + case .registeredUserWithoutKYC, .registeredAndVerifiedUser: + mobileVerified = true + } + + // return value + switch useCase { + case .unregisteredUser, .registeredUserWithUnverifiedOTP, .registeredUserWithoutKYC: + return .init(status: .notStarted, mobileVerified: mobileVerified) + case .registeredAndVerifiedUser: + return .init(status: .approved, mobileVerified: mobileVerified) + } + } + + public func getUserDetails(userId _: String) async throws -> StrigaUserDetailsResponse { + // Fake network request + try await Task.sleep(nanoseconds: 1_000_000_000) + + // return value + return try .init( + firstName: "Remote", + lastName: "Provider", + email: "remote.provider@mocking.com", + mobile: .init(countryCode: "1", number: "5853042520"), + dateOfBirth: .init(year: "1986", month: "12", day: "1"), + address: .init( + addressLine1: "Remote street 12", + addressLine2: nil, + city: "Remote Provider", + postalCode: "12345", + state: "Remote Provider", + country: "USA" + ), + occupation: nil, + sourceOfFunds: nil, + placeOfBirth: nil, + KYC: await getKYCStatus(userId: mockUserId) + ) + } + + public func createUser(model: StrigaCreateUserRequest) async throws -> StrigaCreateUserResponse { + // Fake network request + try await Task.sleep(nanoseconds: 2_000_000_000) + + // return value + useCase = .registeredUserWithUnverifiedOTP + + // return value + return .init( + userId: mockUserId, + email: model.email, + KYC: .init( + status: .notStarted + ) + ) + } + + public func verifyMobileNumber(userId _: String, verificationCode _: String) async throws { + // Fake network request + try await Task.sleep(nanoseconds: 1_000_000_000) + + // all goods + // return value + useCase = .registeredUserWithoutKYC + } + + var invokedResendSMS = false + var invokedResendSMSCount = 0 + var invokedResendSMSParameters: (userId: String, Void)? + var invokedResendSMSParametersList = [(userId: String, Void)]() + + public func resendSMS(userId _: String) async throws { + try await Task.sleep(nanoseconds: 1_000_000_000) + // all goods + } + + public func getKYCToken(userId _: String) async throws -> String { + // Fake network request + try await Task.sleep(nanoseconds: 1_000_000_000) + + // return value + switch useCase { + case .unregisteredUser, .registeredUserWithUnverifiedOTP, .registeredAndVerifiedUser: + return "" + case .registeredUserWithoutKYC: + return mockKYCToken + } + } + + public func getAllWalletsByUser(userId _: String, startDate _: Date, endDate _: Date, + page _: Int) async throws -> StrigaGetAllWalletsResponse + { + fatalError("Implementing") + } + + public func enrichAccount(userId _: String, accountId _: String) async throws -> T where T: Decodable { + fatalError("Implementing") + } + + public func initiateOnChainWalletSend( + userId _: String, + sourceAccountId _: String, + whitelistedAddressId _: String, + amount _: String, + accountCreation _: Bool + ) async throws -> StrigaWalletSendResponse { + fatalError("Implementing") + } + + public func transactionResendOTP(userId _: String, + challengeId _: String) async throws -> StrigaTransactionResendOTPResponse + { + fatalError("Implementing") + } + + public func transactionConfirmOTP(userId _: String, challengeId _: String, code _: String, + ip _: String) async throws -> StrigaTransactionConfirmOTPResponse + { + fatalError("Implementing") + } + + public func getWhitelistedUserDestinations() async throws -> [StrigaWhitelistAddressResponse] { + fatalError("Implementing") + } + + public func initiateOnchainFeeEstimate( + userId _: String, + sourceAccountId _: String, + whitelistedAddressId _: String, + amount _: String + ) async throws -> FeeEstimateResponse { + FeeEstimateResponse( + totalFee: "909237719334000", + networkFee: "909237719334000", + ourFee: "909237719334000", + theirFee: "0", + feeCurrency: "USDC", + gasLimit: "21000", + gasPrice: "18.313" + ) + } + + public func getWhitelistedUserDestinations( + userId _: String, + currency _: String?, + label _: String?, + page _: String? + ) async throws -> StrigaWhitelistAddressesResponse { + fatalError() + } + + public func whitelistDestinationAddress( + userId _: String, + address _: String, + currency _: String, + network _: String, + label _: String? + ) async throws -> StrigaWhitelistAddressResponse { + fatalError() + } + + public func exchangeRates() async throws -> StrigaExchangeRatesResponse { + ["USDCEUR": StrigaExchangeRates(price: "0.9", buy: "0.9", sell: "0.88", + timestamp: Int(Date().timeIntervalSince1970), currency: "Euros")] + } + + public func getAccountStatement( + userId _: String, + accountId _: String, + startDate _: Date, + endDate _: Date, + page _: Int + ) async throws -> StrigaGetAccountStatementResponse { + StrigaGetAccountStatementResponse(transactions: [ + StrigaGetAccountStatementResponse.Transaction( + id: "a25e0dd1-8f4f-441d-a671-2f7d1e9738e6", + txType: "SEPA_PAYIN_COMPLETED", + bankingSenderBic: "BUKBGB22", + bankingSenderIban: "GB29NWBK60161331926819" + ), + ]) + } + + public func initiateSEPAPayment( + userId _: String, + accountId _: String, + amount _: String, + iban _: String, + bic _: String + ) async throws -> StrigaInitiateSEPAPaymentResponse { + fatalError() + } +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Mock/MockStrigaUseCase.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Mock/MockStrigaUseCase.swift new file mode 100644 index 0000000000..2e9e072896 --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Mock/MockStrigaUseCase.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum MockStrigaUseCase: Equatable { + case unregisteredUser + case registeredUserWithUnverifiedOTP + case registeredUserWithoutKYC + case registeredAndVerifiedUser +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaCreateUserRequest.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaCreateUserRequest.swift new file mode 100644 index 0000000000..52945213f0 --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaCreateUserRequest.swift @@ -0,0 +1,66 @@ +import Foundation + +public struct StrigaCreateUserRequest: Encodable { + let firstName: String + let lastName: String + let email: String + let mobile: Mobile + let dateOfBirth: DateOfBirth? + let address: Address? + let occupation: StrigaUserIndustry? + let sourceOfFunds: StrigaSourceOfFunds? + let ipAddress: String? + let placeOfBirth: String? + let expectedIncomingTxVolumeYearly: String? + let expectedOutgoingTxVolumeYearly: String? + let selfPepDeclaration: Bool? + let purposeOfAccount: String? + + struct Mobile: Encodable { + let countryCode: String + let number: String + + var isEmpty: Bool { + countryCode.isEmpty || number.isEmpty + } + } + + struct DateOfBirth: Encodable { + let year: Int? + let month: Int? + let day: Int? + + init(year: Int?, month: Int?, day: Int?) { + self.year = year + self.month = month + self.day = day + } + + init(year: String?, month: String?, day: String?) { + if let year { + self.year = Int(year) + } else { + self.year = nil + } + if let month { + self.month = Int(month) + } else { + self.month = nil + } + if let day { + self.day = Int(day) + } else { + self.day = nil + } + } + } + + struct Address: Encodable { + let addressLine1: String? + let addressLine2: String? + let city: String? + let postalCode: String? + let state: String? + let country: String? + } +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaCreateUserResponse.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaCreateUserResponse.swift new file mode 100644 index 0000000000..46d5b864ce --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaCreateUserResponse.swift @@ -0,0 +1,17 @@ +import Foundation + +public struct StrigaCreateUserResponse: Decodable { + let userId: String + let email: String + let KYC: KYC +} + +public extension StrigaCreateUserResponse { + struct KYC: Codable { + public let status: StrigaKYCStatus + + public init(status: StrigaKYCStatus) { + self.status = status + } + } +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaEndpoint.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaEndpoint.swift new file mode 100644 index 0000000000..b813e05c5a --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaEndpoint.swift @@ -0,0 +1,437 @@ +import Foundation +import KeyAppKitCore +import KeyAppNetworking +import SolanaSwift +import TweetNacl + +/// Endpoint type for striga +struct StrigaEndpoint: HTTPEndpoint { + // MARK: - Properties + + let baseURL: String + let path: String + let method: KeyAppNetworking.HTTPMethod + let keyPair: KeyPair + let header: [String: String] + let body: String? + + // MARK: - Initializer + + private init( + baseURL: String, + path: String, + method: HTTPMethod, + keyPair: KeyPair, + body: Encodable?, + timestamp: NSDate = NSDate() + ) throws { + self.baseURL = baseURL + self.path = path + self.method = method + self.keyPair = keyPair + header = try [ + "Content-Type": "application/json", + "User-PublicKey": keyPair.publicKey.base58EncodedString, + "Signed-Message": keyPair.getSignedTimestampMessage(timestamp: timestamp), + ] + self.body = body?.encoded + } + + // MARK: - Factory methods + + static func getKYC( + baseURL: String, + keyPair: KeyPair, + userId: String, + timestamp: NSDate = NSDate() + ) throws -> Self { + try .init( + baseURL: baseURL, + path: "/striga/api/v1/user/kyc/\(userId)", + method: .get, + keyPair: keyPair, + body: nil, + timestamp: timestamp + ) + } + + static func verifyMobileNumber( + baseURL: String, + keyPair: KeyPair, + userId: String, + verificationCode: String, + timestamp: NSDate = NSDate() + ) throws -> Self { + try .init( + baseURL: baseURL, + path: "/striga/api/v1/user/verify-mobile", + method: .post, + keyPair: keyPair, + body: [ + "userId": userId, + "verificationCode": verificationCode, + ], + timestamp: timestamp + ) + } + + static func initiateOnchainFeeEstimate( + baseURL: String, + keyPair: KeyPair, + userId: String, + sourceAccountId: String, + whitelistedAddressId: String, + amount: String, + timestamp: NSDate = NSDate() + ) throws -> Self { + try .init( + baseURL: baseURL, + path: "striga/api/v1/wallets/send/initiate/onchain/fee-estimate", + method: .post, + keyPair: keyPair, + body: [ + "userId": userId, + "sourceAccountId": sourceAccountId, + "whitelistedAddressId": whitelistedAddressId, + "amount": amount, + ], + timestamp: timestamp + ) + } + + static func getUserDetails( + baseURL: String, + keyPair: KeyPair, + userId: String, + timestamp: NSDate = NSDate() + ) throws -> Self { + try .init( + baseURL: baseURL, + path: "/striga/api/v1/user/\(userId)", + method: .get, + keyPair: keyPair, + body: nil, + timestamp: timestamp + ) + } + + static func createUser( + baseURL: String, + keyPair: KeyPair, + body: StrigaCreateUserRequest, + timestamp: NSDate = NSDate() + ) throws -> Self { + try .init( + baseURL: baseURL, + path: "/api/v1/user/create", + method: .post, + keyPair: keyPair, + body: body, + timestamp: timestamp + ) + } + + static func resendSMS( + baseURL: String, + keyPair: KeyPair, + userId: String, + timestamp: NSDate = NSDate() + ) throws -> Self { + try .init( + baseURL: baseURL, + path: "/striga/api/v1/user/resend-sms", + method: .post, + keyPair: keyPair, + body: [ + "userId": userId, + ], + timestamp: timestamp + ) + } + + static func initiateOnChainWalletSend( + baseURL: String, + keyPair: KeyPair, + userId: String, + sourceAccountId: String, + whitelistedAddressId: String, + amount: String, + accountCreation: Bool = false, + timestamp: NSDate = NSDate() + ) throws -> Self { + try .init( + baseURL: baseURL, + path: "/api/v1/wallets/send/initiate/onchain", + method: .post, + keyPair: keyPair, + body: [ + "userId": .init(userId), + "sourceAccountId": .init(sourceAccountId), + "whitelistedAddressId": .init(whitelistedAddressId), + "amount": .init(amount), + "accountCreation": .init(accountCreation), + ] as [String: KeyAppNetworking.AnyEncodable], + timestamp: timestamp + ) + } + + static func getKYCToken( + baseURL: String, + keyPair: KeyPair, + userId: String, + timestamp: NSDate = NSDate() + ) throws -> Self { + try .init( + baseURL: baseURL, + path: "/striga/api/v1/user/kyc/start", + method: .post, + keyPair: keyPair, + body: [ + "userId": userId, + ], + timestamp: timestamp + ) + } + + static func getAllWallets( + baseURL: String, + keyPair: KeyPair, + userId: String, + startDate: Date, + endDate: Date, + page: Int, + timestamp: NSDate = NSDate() + ) throws -> Self { + try .init( + baseURL: baseURL, + path: "/striga/api/v1/wallets/get/all", + method: .post, + keyPair: keyPair, + body: [ + "userId": .init(userId), + "startDate": .init(startDate.millisecondsSince1970), + "endDate": .init(endDate.millisecondsSince1970), + "page": .init(page), + ] as [String: KeyAppNetworking.AnyEncodable], + timestamp: timestamp + ) + } + + static func enrichAccount( + baseURL: String, + keyPair: KeyPair, + userId: String, + accountId: String, + timestamp: NSDate = NSDate() + ) throws -> Self { + try .init( + baseURL: baseURL, + path: "/striga/api/v1/wallets/account/enrich", + method: .post, + keyPair: keyPair, + body: [ + "userId": userId, + "accountId": accountId, + ], + timestamp: timestamp + ) + } + + static func transactionResendOTP( + baseURL: String, + keyPair: KeyPair, + userId: String, + challengeId: String, + timestamp: NSDate = NSDate() + ) throws -> Self { + try .init( + baseURL: baseURL, + path: "/striga/api/v1/wallets/transaction/resend-otp", + method: .post, + keyPair: keyPair, + body: [ + "userId": userId, + "challengeId": challengeId, + ], + timestamp: timestamp + ) + } + + static func transactionConfirmOTP( + baseURL: String, + keyPair: KeyPair, + userId: String, + challengeId: String, + verificationCode: String, + ip: String, + timestamp: NSDate = NSDate() + ) throws -> Self { + try .init( + baseURL: baseURL, + path: "/striga/api/v1/wallets/transaction/confirm", + method: .post, + keyPair: keyPair, + body: [ + "userId": userId, + "challengeId": challengeId, + "verificationCode": verificationCode, + "ip": ip, + ], + timestamp: timestamp + ) + } + + static func whitelistDestinationAddress( + baseURL: String, + keyPair: KeyPair, + userId: String, + address: String, + currency: String, + network: String, + label: String?, + timestamp: NSDate = NSDate() + ) throws -> Self { + try .init( + baseURL: baseURL, + path: "/striga/api/v1/wallets/whitelist-address", + method: .post, + keyPair: keyPair, + body: [ + "userId": userId, + "address": address, + "currency": currency, + "network": network, + "label": label, + ], + timestamp: timestamp + ) + } + + static func getWhitelistedUserDestinations( + baseURL: String, + keyPair: KeyPair, + userId: String, + currency: String?, + label _: String?, + page _: String?, + timestamp: NSDate = NSDate() + ) throws -> Self { + try .init( + baseURL: baseURL, + path: "/striga/api/v1/wallets/get/whitelisted-addresses", + method: .post, + keyPair: keyPair, + body: [ + "userId": userId, + "currency": currency, +// "label": label, +// "page": page + ], + timestamp: timestamp + ) + } + + static func exchangeRates( + baseURL: String, + keyPair: KeyPair, + timestamp: NSDate = NSDate() + ) throws -> Self { + try StrigaEndpoint( + baseURL: baseURL, + path: "/striga/api/v1/trade/rates", + method: .post, + keyPair: keyPair, + body: nil, + timestamp: timestamp + ) + } + + static func initiateSEPAPayment( + baseURL: String, + keyPair: KeyPair, + userId: String, + sourceAccountId: String, + amount: String, + iban: String, + bic: String, + timestamp: NSDate = NSDate() + ) throws -> Self { + try StrigaEndpoint( + baseURL: baseURL, + path: "/striga/api/v1/wallets/send/initiate/bank", + method: .post, + keyPair: keyPair, + body: [ + "userId": .init(userId), + "sourceAccountId": .init(sourceAccountId), + "amount": .init(amount), + "destination": .init(["iban": .init(iban), + "bic": .init(bic)] as [String: KeyAppNetworking.AnyEncodable]), + ] as [String: KeyAppNetworking.AnyEncodable], + timestamp: timestamp + ) + } + + static func getAccountStatement( + baseURL: String, + keyPair: KeyPair, + userId: String, + accountId: String, + startDate: Date, + endDate: Date, + page: Int, + timestamp: NSDate = NSDate() + ) throws -> Self { + try StrigaEndpoint( + baseURL: baseURL, + path: "/striga/api/v1/wallets/get/account/statement", + method: .post, + keyPair: keyPair, + body: [ + "userId": .init(userId), + "accountId": .init(accountId), + "startDate": .init(startDate.millisecondsSince1970), + "endDate": .init(endDate.millisecondsSince1970), + "page": .init(page), + ] as [String: KeyAppNetworking.AnyEncodable], + timestamp: timestamp + ) + } +} + +extension KeyPair { + func getSignedTimestampMessage(timestamp: NSDate = NSDate()) throws -> String { + // get timestamp + let timestamp = "\(Int(timestamp.timeIntervalSince1970) * 1000)" + + // form message + guard + let data = timestamp.data(using: .utf8), + let signedTimestampMessage = try? NaclSign.signDetached( + message: data, + secretKey: secretKey + ).base64EncodedString() + else { + throw BankTransferError.invalidKeyPair + } + // return unixtime:signature_of_unixtime_by_user_privatekey_in_base64_format + return [timestamp, signedTimestampMessage].joined(separator: ":") + } +} + +// MARK: - Encoding + +private extension Encodable { + /// Encoded string for request as a json string + var encoded: String? { + encoded(strategy: .useDefaultKeys) + } + + func encoded(strategy: JSONEncoder.KeyEncodingStrategy) -> String? { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + encoder.keyEncodingStrategy = strategy + guard let data = try? encoder.encode(self) else { return nil } + return String(data: data, encoding: .utf8) + } +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaEnrichedAccountResponse.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaEnrichedAccountResponse.swift new file mode 100644 index 0000000000..16fe059023 --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaEnrichedAccountResponse.swift @@ -0,0 +1,27 @@ +import Foundation + +public struct StrigaEnrichedEURAccountResponse: Codable, Equatable { + public let currency: String + public let status: String + public let internalAccountId: String + public let bankCountry: String + public let bankAddress: String + public let iban: String + public let bic: String + public let accountNumber: String + public let bankName: String + public let bankAccountHolderName: String + public let provider: String + public let domestic: Bool +} + +public struct StrigaEnrichedUSDCAccountResponse: Codable, Equatable { + public let blockchainDepositAddress: String + public let blockchainNetwork: BlockchainNetwork + + public struct BlockchainNetwork: Codable, Equatable { + public let name: String + public let type: String + public let contractAddress: String + } +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaExchangeRatesResponse.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaExchangeRatesResponse.swift new file mode 100644 index 0000000000..853b93fc8b --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaExchangeRatesResponse.swift @@ -0,0 +1,11 @@ +import Foundation + +public typealias StrigaExchangeRatesResponse = [String: StrigaExchangeRates] + +public struct StrigaExchangeRates: Codable { + public let price: String + public let buy: String + public let sell: String + public let timestamp: Int + public let currency: String +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaGetAccountStatementResponse.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaGetAccountStatementResponse.swift new file mode 100644 index 0000000000..a021219a64 --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaGetAccountStatementResponse.swift @@ -0,0 +1,12 @@ +import Foundation + +public struct StrigaGetAccountStatementResponse: Codable { + public let transactions: [Transaction] + + public struct Transaction: Codable { + let id: String + let txType: String + let bankingSenderBic: String? + let bankingSenderIban: String? + } +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaGetAllWalletsResponse.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaGetAllWalletsResponse.swift new file mode 100644 index 0000000000..25b07618ce --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaGetAllWalletsResponse.swift @@ -0,0 +1,73 @@ +import Foundation + +// MARK: - StrigaGetAllWalletsResponse + +public struct StrigaGetAllWalletsResponse: Codable { + public let wallets: [StrigaWallet] + public let count, total: Int +} + +// MARK: - StrigaWallet + +public struct StrigaWallet: Codable { + public let walletID: String + public let accounts: StrigaWalletAccounts + public let syncedOwnerID, ownerType, createdAt, comment: String + + enum CodingKeys: String, CodingKey { + case walletID = "walletId" + case accounts + case syncedOwnerID = "syncedOwnerId" + case ownerType, createdAt, comment + } +} + +// MARK: - StrigaWalletAccounts + +public struct StrigaWalletAccounts: Codable { + public let eur: StrigaWalletAccount? + public let usdc: StrigaWalletAccount? + + enum CodingKeys: String, CodingKey { + case eur = "EUR" + case usdc = "USDC" + } +} + +// MARK: - StrigaWalletAccount + +public struct StrigaWalletAccount: Codable { + public let accountID, parentWalletID, currency, ownerID: String + public let ownerType, createdAt: String + public let availableBalance: StrigaWalletAccountAvailableBalance + public let linkedCardID: String + public let linkedBankAccountID: String? + public let status: String + public let permissions: [String] + public let enriched: Bool + public let blockchainDepositAddress: String? + public let blockchainNetwork: StrigaWalletAccountBlockchainNetwork? + + enum CodingKeys: String, CodingKey { + case accountID = "accountId" + case parentWalletID = "parentWalletId" + case currency + case ownerID = "ownerId" + case ownerType, createdAt, availableBalance + case linkedCardID = "linkedCardId" + case linkedBankAccountID = "linkedBankAccountId" + case status, permissions, enriched, blockchainDepositAddress, blockchainNetwork + } +} + +// MARK: - StrigaWalletAccountAvailableBalance + +public struct StrigaWalletAccountAvailableBalance: Codable { + public let amount, currency: String +} + +// MARK: - StrigaWalletAccountBlockchainNetwork + +public struct StrigaWalletAccountBlockchainNetwork: Codable { + public let name, type, contractAddress: String +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaInitiateSEPAPaymentResponse.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaInitiateSEPAPaymentResponse.swift new file mode 100644 index 0000000000..50e5a41dab --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaInitiateSEPAPaymentResponse.swift @@ -0,0 +1,6 @@ +import Foundation + +public struct StrigaInitiateSEPAPaymentResponse: Codable { + let challengeId: String + let dateExpires: String +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaKYC.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaKYC.swift new file mode 100644 index 0000000000..dc205de06d --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaKYC.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct StrigaKYC: Codable { + public let status: StrigaKYCStatus + public let mobileVerified: Bool + + public init(status: StrigaKYCStatus, mobileVerified: Bool) { + self.status = status + self.mobileVerified = mobileVerified + } +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaKYCStatus.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaKYCStatus.swift new file mode 100644 index 0000000000..42ee433577 --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaKYCStatus.swift @@ -0,0 +1,11 @@ +import Foundation + +public enum StrigaKYCStatus: String, Codable { + case notStarted = "NOT_STARTED" + case initiated = "INITIATED" // The "Start KYC" endpoint has been called and the SumSub token has been fetched + case pendingReview = "PENDING_REVIEW" // Documents have been submitted and are pending review + case onHold = "ON_HOLD" // Requires manual review from the compliance team + case approved = "APPROVED" // User approved + case rejected = "REJECTED" // User rejected - Can be final or not + case rejectedFinal = "REJECTED_FINAL" +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaOTPResponse.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaOTPResponse.swift new file mode 100644 index 0000000000..5480735d3b --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaOTPResponse.swift @@ -0,0 +1,5 @@ +import Foundation + +public struct StrigaResendOTPResponse: Codable { + let dateExpires: String +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaProviderError.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaProviderError.swift new file mode 100644 index 0000000000..369af3e4c2 --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaProviderError.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum StrigaProviderError: Error { + case invalidRequest(String) + case invalidResponse + case invalidRateTokens + case missingAccountId +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaSourceOfFunds.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaSourceOfFunds.swift new file mode 100644 index 0000000000..f8bda824d2 --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaSourceOfFunds.swift @@ -0,0 +1,16 @@ +public enum StrigaSourceOfFunds: String, CaseIterable, Codable { + case personalSavings = "PERSONAL_SAVINGS" + case familySavings = "FAMILY_SAVINGS" + case labourContract = "LABOUR_CONTRACT" + case civilContract = "CIVIL_CONTRACT" + case rent = "RENT" + case fundsFromOtherAuxiliarySources = "FUNDS_FROM_OTHER_AUXILIARY_SOURCES" + case saleOfMovableAssets = "SALE_OF_MOVABLE_ASSETS" + case saleOfReal = "SALE_OF_REAL_ESTATE" + case ordinaryBusinessActivity = "ORDINARY_BUSINESS_ACTIVITY" + case dividends = "DIVIDENDS" + case loanFromFinancialInstitutionsCreditUnions = "LOAN_FROM_FINANCIAL_INSTITUTIONS_CREDIT_UNIONS" + case loadFromThirdParties = "LOAN_FROM_THIRD_PARTIES" + case inheritance = "INHERITANCE" + case saleOfComponySharesBusiness = "SALE_OF_COMPANY_SHARES_BUSINESS" +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaTransactionConfirmOTPResponse.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaTransactionConfirmOTPResponse.swift new file mode 100644 index 0000000000..5430325c2b --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaTransactionConfirmOTPResponse.swift @@ -0,0 +1,17 @@ +import Foundation + +public struct StrigaTransactionConfirmOTPResponse: Codable { + let id: String + let amount: String + let feeSats: String + let invoice: String + let payeeNode: String + let network: Network + + struct Network: Codable { + let bech32: String + let pubKeyHash: Int + let scriptHash: Int + let validWitnessVersions: [Int] + } +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaTransactionResendOTPResponse.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaTransactionResendOTPResponse.swift new file mode 100644 index 0000000000..4bc95deaa4 --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaTransactionResendOTPResponse.swift @@ -0,0 +1,7 @@ +import Foundation + +public struct StrigaTransactionResendOTPResponse: Codable { + let challengeId: String + let dateExpires: String + let attempts: Int +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaUserDetailsResponse.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaUserDetailsResponse.swift new file mode 100644 index 0000000000..d1fffa3416 --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaUserDetailsResponse.swift @@ -0,0 +1,126 @@ +import Foundation + +public struct StrigaUserDetailsResponse: BankTransferRegistrationData { + public let firstName: String + public let lastName: String + public let email: String + public let mobile: Mobile + public let dateOfBirth: DateOfBirth? + public let address: Address? + public let occupation: StrigaUserIndustry? + public let sourceOfFunds: StrigaSourceOfFunds? + public let placeOfBirth: String? + public let KYC: StrigaKYC + + public var mobileNumber: String? { + mobile.countryCode + mobile.number + } + + public struct Mobile: Codable { + public let countryCode: String + public let number: String + + public init(countryCode: String, number: String) { + self.countryCode = countryCode + self.number = number + } + + public var isEmpty: Bool { + countryCode.isEmpty || number.isEmpty + } + } + + public struct DateOfBirth: Codable { + public let year: String? + public let month: String? + public let day: String? + + public init(year: String?, month: String?, day: String?) { + self.year = year + self.month = month + self.day = day + } + } + + public struct Address: Codable { + public let addressLine1: String? + public let addressLine2: String? + public let city: String? + public let postalCode: String? + public let state: String? + public let country: String? + + public init( + addressLine1: String?, + addressLine2: String?, + city: String?, + postalCode: String?, + state: String?, + country: String? + ) { + self.addressLine1 = addressLine1 + self.addressLine2 = addressLine2 + self.city = city + self.postalCode = postalCode + self.state = state + self.country = country + } + } + + public init( + firstName: String, + lastName: String, + email: String, + mobile: Mobile, + dateOfBirth: DateOfBirth? = nil, + address: Address? = nil, + occupation: StrigaUserIndustry? = nil, + sourceOfFunds: StrigaSourceOfFunds? = nil, + placeOfBirth: String? = nil, + KYC: StrigaKYC + ) { + self.firstName = firstName + self.lastName = lastName + self.email = email + self.mobile = mobile + self.dateOfBirth = dateOfBirth + self.address = address + self.occupation = occupation + self.sourceOfFunds = sourceOfFunds + self.placeOfBirth = placeOfBirth + self.KYC = KYC + } + + public static var empty: Self { + StrigaUserDetailsResponse( + firstName: "", lastName: "", email: "", mobile: Mobile(countryCode: "", number: ""), + KYC: StrigaKYC(status: .notStarted, mobileVerified: false) + ) + } + + public func updated( + firstName: String? = nil, + lastName: String? = nil, + email: String? = nil, + mobile: Mobile? = nil, + dateOfBirth: DateOfBirth?? = nil, + address: Address?? = nil, + occupation: StrigaUserIndustry?? = nil, + sourceOfFunds: StrigaSourceOfFunds?? = nil, + placeOfBirth: String?? = nil, + KYC: StrigaKYC? = nil + ) -> Self { + StrigaUserDetailsResponse( + firstName: firstName ?? self.firstName, + lastName: lastName ?? self.lastName, + email: email ?? self.email, + mobile: mobile ?? self.mobile, + dateOfBirth: dateOfBirth ?? self.dateOfBirth, + address: address ?? self.address, + occupation: occupation ?? self.occupation, + sourceOfFunds: sourceOfFunds ?? self.sourceOfFunds, + placeOfBirth: placeOfBirth ?? self.placeOfBirth, + KYC: KYC ?? self.KYC + ) + } +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaUserGetTokenResponse.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaUserGetTokenResponse.swift new file mode 100644 index 0000000000..7831274e08 --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaUserGetTokenResponse.swift @@ -0,0 +1,19 @@ +import Foundation + +public struct StrigaUserGetTokenResponse: Decodable { + // MARK: - Properties + + public let provider: String + public let token: String + public let userId: String + public let verificationLink: String + + // MARK: - Initializer + + public init(provider: String, token: String, userId: String, verificationLink: String) { + self.provider = provider + self.token = token + self.userId = userId + self.verificationLink = verificationLink + } +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaUserIndustry.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaUserIndustry.swift new file mode 100644 index 0000000000..142f3d39e6 --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaUserIndustry.swift @@ -0,0 +1,28 @@ +public enum StrigaUserIndustry: String, CaseIterable, Codable { + case accounting = "ACCOUNTING" + case selfEmployed = "SELF_EMPLOYED" + case audit = "AUDIT" + case finance = "FINANCE" + case publicSectorAdministration = "PUBLIC_SECTOR_ADMINISTRATION" + case artEntertainment = "ART_ENTERTAINMENT" + case autoAviation = "AUTO_AVIATION" + case bankingLending = "BANKING_LENDING" + case businessConsultancyLegal = "BUSINESS_CONSULTANCY_LEGAL" + case constructionRepair = "CONSTRUCTION_REPAIR" + case educationProfessionalServices = "EDUCATION_PROFESSIONAL_SERVICES" + case informationalTechnologies = "INFORMATIONAL_TECHNOLOGIES" + case tobaccoAlcohol = "TOBACCO_ALCOHOL" + case gamingGambling = "GAMING_GAMBLING" + case medicalServices = "MEDICAL_SERVICES" + case manufacturing = "MANUFACTURING" + case prMarketing = "PR_MARKETING" + case preciousGoodsJewelry = "PRECIOUS_GOODS_JEWELRY" + case nonGovernmentalOrganization = "NON_GOVERNMENTAL_ORGANIZATION" + case insuranceSecurity = "INSURANCE_SECURITY" + case retailWholesale = "RETAIL_WHOLESALE" + case travelTourism = "TRAVEL_TOURISM" + case freelancer = "FREELANCER" + case student = "STUDENT" + case retired = "RETIRED" + case unemployed = "UNEMPLOYED" +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaWalletSendResponse.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaWalletSendResponse.swift new file mode 100644 index 0000000000..324c3bfcea --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaWalletSendResponse.swift @@ -0,0 +1,43 @@ +import Foundation + +public struct StrigaWalletSendResponse: Codable { + let challengeId: String + let dateExpires: String + let transaction: Transaction + let feeEstimate: FeeEstimate + + struct Transaction: Codable { + let syncedOwnerId: String + let sourceAccountId: String + let parentWalletId: String + let currency: String + let amount: String + let status: String + let txType: TxType + let blockchainDestinationAddress: String + let blockchainNetwork: BlockchainNetwork + let transactionCurrency: String + + struct BlockchainNetwork: Codable { + let name: String + } + + enum TxType: String, Codable { + case initiated = "ON_CHAIN_WITHDRAWAL_INITIATED" + case denied = "ON_CHAIN_WITHDRAWAL_DENIED" + case pending = "ON_CHAIN_WITHDRAWAL_PENDING" + case confirmed = "ON_CHAIN_WITHDRAWAL_CONFIRMED" + case failed = "ON_CHAIN_WITHDRAWAL_FAILED" + } + } + + struct FeeEstimate: Codable { + let totalFee: String + let networkFee: String + let ourFee: String + let theirFee: String + let feeCurrency: String + let gasLimit: String + let gasPrice: String + } +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaWhitelistAddressResponse.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaWhitelistAddressResponse.swift new file mode 100644 index 0000000000..294a845fb8 --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaWhitelistAddressResponse.swift @@ -0,0 +1,24 @@ +import Foundation + +public struct StrigaWhitelistAddressResponse: Codable { + var id: String + var status: String + var address: String + var currency: String + var label: String? + var network: Network + + public struct Network: Codable { + var name: String + var type: String + var contractAddress: String + } +} + +public struct StrigaWhitelistAddressesResponse: Codable { + var addresses: [StrigaWhitelistAddressResponse] +} + +public enum StrigaWhitelistAddressError: String { + case alreadyWhitelisted = "00013" +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaWithdrawalInfo.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaWithdrawalInfo.swift new file mode 100644 index 0000000000..e127e6db19 --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Models/StrigaWithdrawalInfo.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct StrigaWithdrawalInfo: Codable, Equatable { + public let IBAN: String? + public let BIC: String? + public let receiver: String + + public init(IBAN: String? = nil, BIC: String? = nil, receiver: String) { + self.IBAN = IBAN + self.BIC = BIC + self.receiver = receiver + } +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Repository/DataProviders/StrigaLocalProvider.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Repository/DataProviders/StrigaLocalProvider.swift new file mode 100644 index 0000000000..1593d245c9 --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Repository/DataProviders/StrigaLocalProvider.swift @@ -0,0 +1,107 @@ +import Foundation +import SolanaSwift + +public protocol StrigaLocalProvider { + func getCachedRegistrationData() async -> StrigaUserDetailsResponse? + func save(registrationData: StrigaUserDetailsResponse) async throws + + func getCachedUserData() async -> UserData? + func save(userData: UserData) async throws + func getWhitelistedUserDestinations() async throws -> [StrigaWhitelistAddressResponse] + func save(whitelisted: [StrigaWhitelistAddressResponse]) async throws + + func getCachedWithdrawalInfo() async -> StrigaWithdrawalInfo? + func save(withdrawalInfo: StrigaWithdrawalInfo) async throws + + func clear() async +} + +public actor StrigaLocalProviderImpl { + // MARK: - Initializer + + public init() { + // migration + Task { + await migrate() + } + } + + // MARK: - Migration + + private func migrate() { + // Migration + let migrationKey = "StrigaLocalProviderImpl.migration13" + if !UserDefaults.standard.bool(forKey: migrationKey) { + clear() + UserDefaults.standard.set(true, forKey: migrationKey) + } + } +} + +extension StrigaLocalProviderImpl: StrigaLocalProvider { + public func getCachedWithdrawalInfo() async -> StrigaWithdrawalInfo? { + get(from: cacheFileFor(.withdrawalInfo)) + } + + public func save(withdrawalInfo: StrigaWithdrawalInfo) async throws { + try await save(model: withdrawalInfo, in: cacheFileFor(.withdrawalInfo)) + } + + public func getCachedRegistrationData() -> StrigaUserDetailsResponse? { + get(from: cacheFileFor(.registration)) + } + + public func save(registrationData: StrigaUserDetailsResponse) async throws { + try await save(model: registrationData, in: cacheFileFor(.registration)) + } + + public func getCachedUserData() async -> UserData? { + get(from: cacheFileFor(.account)) + } + + public func save(userData: UserData) async throws { + try await save(model: userData, in: cacheFileFor(.account)) + } + + public func getWhitelistedUserDestinations() async throws -> [StrigaWhitelistAddressResponse] { + get(from: cacheFileFor(.whitelisted)) ?? [] + } + + public func save(whitelisted: [StrigaWhitelistAddressResponse]) async throws { + try await save(model: whitelisted, in: cacheFileFor(.whitelisted)) + } + + public func clear() { + for name in CacheFileName.allCases { + try? FileManager.default.removeItem(at: cacheFileFor(name)) + } + } + + // MARK: - Helpers + + private func get(from file: URL) -> T? { + guard let data = try? Data(contentsOf: file) else { return nil } + let cachedData = (try? JSONDecoder().decode(T.self, from: data)) + return cachedData + } + + private func save(model: T, in file: URL) async throws { + let data = try JSONEncoder().encode(model) + try data.write(to: file) + } + + // Cache files + + enum CacheFileName: String, CaseIterable { + case registration + case account + case whitelisted + case withdrawalInfo + } + + private func cacheFileFor(_ name: CacheFileName) -> URL { + let arrayPaths = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) + let cacheDirectoryPath = arrayPaths[0] + return cacheDirectoryPath.appendingPathComponent("/\(name).data") + } +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Repository/DataProviders/StrigaMetadataProvider.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Repository/DataProviders/StrigaMetadataProvider.swift new file mode 100644 index 0000000000..0ed60fd3bf --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Repository/DataProviders/StrigaMetadataProvider.swift @@ -0,0 +1,22 @@ +import Foundation + +public protocol StrigaMetadataProvider { + func getStrigaMetadata() async -> StrigaMetadata? + func updateMetadata(withUserId userId: String) async +} + +public struct StrigaMetadata { + public let userId: String? + public let email: String + public let phoneNumber: String + + public init( + userId: String? = nil, + email: String, + phoneNumber: String + ) { + self.userId = userId + self.email = email + self.phoneNumber = phoneNumber + } +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Repository/DataProviders/StrigaRemoteProvider.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Repository/DataProviders/StrigaRemoteProvider.swift new file mode 100644 index 0000000000..1d86b14e02 --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Repository/DataProviders/StrigaRemoteProvider.swift @@ -0,0 +1,122 @@ +import Foundation + +public protocol StrigaRemoteProvider: AnyObject { + func getKYCStatus(userId: String) async throws -> StrigaKYC + func getUserDetails(userId: String) async throws -> StrigaUserDetailsResponse + func createUser(model: StrigaCreateUserRequest) async throws -> StrigaCreateUserResponse + func verifyMobileNumber(userId: String, verificationCode: String) async throws + func resendSMS(userId: String) async throws + + func getKYCToken(userId: String) async throws -> String + + func getAllWalletsByUser(userId: String, startDate: Date, endDate: Date, page: Int) async throws + -> StrigaGetAllWalletsResponse + + /// - Send funds on chain to a whitelisted destination on the blockchain + /// - Parameter userId: The Id of the user who is sending this transaction + /// - Parameter sourceAccountId: The Id of the account to debit + /// - Parameter whitelistedAddressId: The Id of the whitelisted destination + /// - Parameter amount: The amount denominated in the smallest divisible unit of the sending currency. For example: + /// cents or satoshis + /// - Parameter accountCreation: True if you need to create account, By default False + /// - SeeAlso: [Initiate Onchain Withdrawal](https://docs.striga.com/reference/initiate-onchain-withdrawal) + func initiateOnChainWalletSend( + userId: String, + sourceAccountId: String, + whitelistedAddressId: String, + amount: String, + accountCreation: Bool + ) async throws -> StrigaWalletSendResponse + + func enrichAccount(userId: String, accountId: String) async throws -> T + + /// Resend OTP for transaction + /// - Parameter userId: The Id of the user who is sending this transaction + /// - Parameter challangeId: The challengeId that you received when initiating the transaction + /// - SeeAlso: [Resend OTP for transaction](https://docs.striga.com/reference/resend-otp-for-transaction) + func transactionResendOTP(userId: String, challengeId: String) async throws -> StrigaTransactionResendOTPResponse + + /// Your API calls will appear here. Make a request to get started! + /// - Parameter userId: The Id of the user who is sending this transaction + /// - Parameter challangeId: The challengeId that you received when initiating the transaction + /// - Parameter code: 6 characters code. Default code for sandbox "123456". + /// - Parameter ip: IP address collected as the IP address from which the End User is making the withdrawal request. + /// IMPORTANT - This will be a required parameter from the 15th of June 2023 and is optional until then. + /// - SeeAlso: [Confirm transaction with OTP](https://docs.striga.com/reference/confirm-transaction-with-otp) + func transactionConfirmOTP( + userId: String, + challengeId: String, + code: String, + ip: String + ) async throws -> StrigaTransactionConfirmOTPResponse + + /// Get a fee estimate for an onchain withdrawal without triggering a withdrawal + /// - Parameter userId: The Id of the user who is sending this transaction + /// - Parameter sourceAccountId: The Id of the account to debit + /// - Parameter whitelistedAddressId: The Id of the whitelisted destination + /// - Parameter amount: The amount denominated in the smallest divisible unit of the sending currency. For example: + /// cents or satoshis + /// - SeeAlso: [Get Onchain Withdrawal Fee + /// Estimate](https://docs.striga.com/reference/get-onchain-withdrawal-fee-estimates) + func initiateOnchainFeeEstimate( + userId: String, + sourceAccountId: String, + whitelistedAddressId: String, + amount: String + ) async throws -> FeeEstimateResponse + + /// Get a list of whitelisted addresses for a user + /// - Parameter userId: The Id of the user whose destination addresses are to be fetched + /// - Parameter currency: Optional currency to filter by + /// - Parameter label: An optional label to filter by + /// - Parameter page: Optional page number for pagination + /// - SeeAlso: [Get Whitelisted User Destination + /// Addresses](https://docs.striga.com/reference/get-whitelisted-user-destination-addresses) + func getWhitelistedUserDestinations( + userId: String, + currency: String?, + label: String?, + page: String? + ) async throws -> StrigaWhitelistAddressesResponse + + /// Whitelist Destination Address + /// - Parameter userId: The Id of the user who is sending this transaction + /// - Parameter address: A string of the destination. Must be a valid address on the network you want to whitelist + /// for. + /// - Parameter currency: The currency you want this whitelisted address to receive. + /// - Parameter network: A network from the default networks specified in the "Moving Money Around" section + /// - Parameter label: An optional label to tag your address. Must be unique. A string upto 30 characters. + /// - SeeAlso: [Whitelist Destination Address](https://docs.striga.com/reference/initiate-onchain-withdrawal-1) + func whitelistDestinationAddress( + userId: String, + address: String, + currency: String, + network: String, + label: String? + ) async throws -> StrigaWhitelistAddressResponse + + /// Exchange Rates + /// - SeeAlso: [Exchange Rates](https://www.sandbox.striga.com/api/v1/trade/rates) + func exchangeRates() async throws -> StrigaExchangeRatesResponse + + /// Get Account Statement + /// - Parameter userId: The Id of the user who is sending this transaction + /// - Parameter accountId: Unique Id of an account belonging to any wallet of this user + /// - Parameter startDate: Format as a UNIX Epoch timestamp with ms precision + /// - Parameter endDate: Format as a UNIX Epoch timestamp with ms precision + /// - Parameter page: Page number + /// - SeeAlso: [Get Account Statement](https://docs.striga.com/reference/get-account-statement) + func getAccountStatement(userId: String, accountId: String, startDate: Date, endDate: Date, page: Int) async throws + -> StrigaGetAccountStatementResponse + + /// Initiate SEPA Payment + /// - Parameter userId: The Id of the user who is sending this transaction + /// - Parameter accountId: The Id of the account to debit + /// - Parameter amount: The amount denominated in the smallest divisible unit of the sending currency. For example: + /// cents + /// - Parameter iban: IBAN of the recipient - MUST be in the name of the account holder + /// - Parameter bic: BIC of the recipient - MUST be in the name of the account holder + /// - SeeAlso: [Initiate SEPA Payment](https://docs.striga.com/reference/initiate-sepa-payment) + func initiateSEPAPayment(userId: String, accountId: String, amount: String, iban: String, bic: String) async throws + -> StrigaInitiateSEPAPaymentResponse +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Repository/DataProviders/StrigaRemoteProviderImpl.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Repository/DataProviders/StrigaRemoteProviderImpl.swift new file mode 100644 index 0000000000..0c168b1b02 --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Repository/DataProviders/StrigaRemoteProviderImpl.swift @@ -0,0 +1,322 @@ +import Foundation +import KeyAppNetworking +import SolanaSwift +import TweetNacl + +public final class StrigaRemoteProviderImpl { + // Dependencies + private let httpClient: IHTTPClient + private let keyPair: KeyPair? + private let baseURL: String + + // MARK: - Init + + public init( + baseURL: String, + solanaKeyPair keyPair: KeyPair?, + httpClient: IHTTPClient = HTTPClient() + ) { + self.baseURL = baseURL + self.httpClient = httpClient + self.keyPair = keyPair + } +} + +// MARK: - StrigaProvider + +extension StrigaRemoteProviderImpl: StrigaRemoteProvider { + public func getKYCStatus(userId: String) async throws -> StrigaKYC { + guard let keyPair else { throw BankTransferError.invalidKeyPair } + let endpoint = try StrigaEndpoint.getKYC(baseURL: baseURL, keyPair: keyPair, userId: userId) + return try await httpClient.request(endpoint: endpoint, responseModel: StrigaKYC.self) + } + + public func getUserDetails( + userId: String + ) async throws -> StrigaUserDetailsResponse { + guard let keyPair else { throw BankTransferError.invalidKeyPair } + let endpoint = try StrigaEndpoint.getUserDetails(baseURL: baseURL, keyPair: keyPair, userId: userId) + return try await httpClient.request(endpoint: endpoint, responseModel: StrigaUserDetailsResponse.self) + } + + public func createUser( + model: StrigaCreateUserRequest + ) async throws -> StrigaCreateUserResponse { + guard let keyPair else { throw BankTransferError.invalidKeyPair } + let endpoint = try StrigaEndpoint.createUser(baseURL: baseURL, keyPair: keyPair, body: model) + do { + return try await httpClient.request(endpoint: endpoint, responseModel: StrigaCreateUserResponse.self) + } catch let HTTPClientError.invalidResponse(response, data) where response?.statusCode == 400 { + if let error = try? JSONDecoder().decode(StrigaRemoteProviderError.self, from: data) { + throw BankTransferError(rawValue: Int(error.errorCode ?? "") ?? -1) ?? HTTPClientError + .invalidResponse(response, data) + } else { + throw HTTPClientError.invalidResponse(response, data) + } + } + } + + public func verifyMobileNumber( + userId: String, + verificationCode: String + ) async throws { + guard let keyPair else { throw BankTransferError.invalidKeyPair } + let endpoint = try StrigaEndpoint.verifyMobileNumber( + baseURL: baseURL, + keyPair: keyPair, + userId: userId, + verificationCode: verificationCode + ) + do { + let response = try await httpClient.request(endpoint: endpoint, responseModel: String.self) + // expect response to be Accepted + guard response == "Accepted" else { + throw HTTPClientError.invalidResponse(nil, response.data(using: .utf8) ?? Data()) + } + return + } catch let HTTPClientError.invalidResponse(response, data) { + let error = try JSONDecoder().decode(StrigaRemoteProviderError.self, from: data) + throw BankTransferError(rawValue: Int(error.errorCode ?? "") ?? -1) ?? HTTPClientError + .invalidResponse(response, data) + } + } + + public func resendSMS(userId: String) async throws { + guard let keyPair else { throw BankTransferError.invalidKeyPair } + let endpoint = try StrigaEndpoint.resendSMS(baseURL: baseURL, keyPair: keyPair, userId: userId) + do { + _ = try await httpClient.request( + endpoint: endpoint, + responseModel: StrigaResendOTPResponse.self + ) + } catch let HTTPClientError.invalidResponse(response, data) { + let error = try JSONDecoder().decode(StrigaRemoteProviderError.self, from: data) + if error.errorCode == "00002" { + throw BankTransferError.mobileAlreadyVerified + } + throw BankTransferError(rawValue: Int(error.errorCode ?? "") ?? -1) ?? HTTPClientError + .invalidResponse(response, data) + } + } + + public func initiateOnChainWalletSend( + userId: String, + sourceAccountId: String, + whitelistedAddressId: String, + amount: String, + accountCreation: Bool + ) async throws -> StrigaWalletSendResponse { + guard let keyPair else { throw BankTransferError.invalidKeyPair } + let endpoint = try StrigaEndpoint.initiateOnChainWalletSend( + baseURL: baseURL, + keyPair: keyPair, + userId: userId, + sourceAccountId: sourceAccountId, + whitelistedAddressId: whitelistedAddressId, + amount: amount, + accountCreation: accountCreation + ) + return try await httpClient.request( + endpoint: endpoint, + responseModel: StrigaWalletSendResponse.self + ) + } + + public func getKYCToken(userId: String) async throws -> String { + guard let keyPair else { throw BankTransferError.invalidKeyPair } + let endpoint = try StrigaEndpoint.getKYCToken(baseURL: baseURL, keyPair: keyPair, userId: userId) + + do { + let response = try await httpClient.request( + endpoint: endpoint, + responseModel: StrigaUserGetTokenResponse.self + ) + return response.token + } catch let HTTPClientError.invalidResponse(response, data) { + if let errorCode = try? JSONDecoder().decode(StrigaRemoteProviderError.self, from: data).errorCode { + for bankTransferError in [BankTransferError.kycVerificationInProgress, .kycAttemptLimitExceeded, + .kycRejectedCantRetry] + { + if String(bankTransferError.rawValue).elementsEqual(errorCode) { + throw bankTransferError + } + } + } + throw HTTPClientError.invalidResponse(response, data) + } + } + + public func getAllWalletsByUser(userId: String, startDate: Date, endDate: Date, + page: Int) async throws -> StrigaGetAllWalletsResponse + { + guard let keyPair else { throw BankTransferError.invalidKeyPair } + let endpoint = try StrigaEndpoint.getAllWallets( + baseURL: baseURL, + keyPair: keyPair, + userId: userId, + startDate: startDate, + endDate: endDate, + page: page + ) + + return try await httpClient.request(endpoint: endpoint, responseModel: StrigaGetAllWalletsResponse.self) + } + + public func enrichAccount(userId: String, accountId: String) async throws -> T { + guard let keyPair else { throw BankTransferError.invalidKeyPair } + let endpoint = try StrigaEndpoint.enrichAccount( + baseURL: baseURL, + keyPair: keyPair, + userId: userId, + accountId: accountId + ) + return try await httpClient.request(endpoint: endpoint, responseModel: T.self) + } + + public func initiateOnchainFeeEstimate( + userId: String, + sourceAccountId: String, + whitelistedAddressId: String, + amount: String + ) async throws -> FeeEstimateResponse { + guard let keyPair else { throw BankTransferError.invalidKeyPair } + let endpoint = try StrigaEndpoint.initiateOnchainFeeEstimate( + baseURL: baseURL, + keyPair: keyPair, + userId: userId, + sourceAccountId: sourceAccountId, + whitelistedAddressId: whitelistedAddressId, + amount: amount + ) + return try await httpClient.request( + endpoint: endpoint, + responseModel: FeeEstimateResponse.self + ) + } + + public func transactionResendOTP( + userId: String, + challengeId: String + ) async throws -> StrigaTransactionResendOTPResponse { + guard let keyPair else { throw BankTransferError.invalidKeyPair } + let endpoint = try StrigaEndpoint.transactionResendOTP( + baseURL: baseURL, + keyPair: keyPair, + userId: userId, + challengeId: challengeId + ) + return try await httpClient.request( + endpoint: endpoint, + responseModel: StrigaTransactionResendOTPResponse.self + ) + } + + public func transactionConfirmOTP( + userId: String, + challengeId: String, + code: String, + ip: String + ) async throws -> StrigaTransactionConfirmOTPResponse { + guard let keyPair else { throw BankTransferError.invalidKeyPair } + let endpoint = try StrigaEndpoint.transactionConfirmOTP( + baseURL: baseURL, + keyPair: keyPair, + userId: userId, + challengeId: challengeId, + verificationCode: code, + ip: ip + ) + return try await httpClient.request( + endpoint: endpoint, + responseModel: StrigaTransactionConfirmOTPResponse.self + ) + } + + public func whitelistDestinationAddress( + userId: String, + address: String, + currency: String, + network: String, + label: String? + ) async throws -> StrigaWhitelistAddressResponse { + guard let keyPair else { throw BankTransferError.invalidKeyPair } + let endpoint = try StrigaEndpoint.whitelistDestinationAddress( + baseURL: baseURL, + keyPair: keyPair, + userId: userId, + address: address, + currency: currency, + network: network, + label: label + ) + return try await httpClient.request( + endpoint: endpoint, + responseModel: StrigaWhitelistAddressResponse.self + ) + } + + public func getWhitelistedUserDestinations( + userId: String, + currency: String?, + label: String?, + page: String? + ) async throws -> StrigaWhitelistAddressesResponse { + guard let keyPair else { throw BankTransferError.invalidKeyPair } + let endpoint = try StrigaEndpoint.getWhitelistedUserDestinations( + baseURL: baseURL, + keyPair: keyPair, + userId: userId, + currency: currency, + label: label, + page: page + ) + return try await httpClient.request( + endpoint: endpoint, + responseModel: StrigaWhitelistAddressesResponse.self + ) + } + + public func exchangeRates() async throws -> StrigaExchangeRatesResponse { + guard let keyPair else { throw BankTransferError.invalidKeyPair } + let endpoint = try StrigaEndpoint.exchangeRates(baseURL: baseURL, keyPair: keyPair) + return try await httpClient.request(endpoint: endpoint, responseModel: StrigaExchangeRatesResponse.self) + } + + public func getAccountStatement(userId: String, accountId: String, startDate: Date, endDate: Date, + page: Int) async throws -> StrigaGetAccountStatementResponse + { + guard let keyPair else { throw BankTransferError.invalidKeyPair } + let endpoint = try StrigaEndpoint.getAccountStatement( + baseURL: baseURL, + keyPair: keyPair, + userId: userId, + accountId: accountId, + startDate: startDate, + endDate: endDate, + page: page + ) + return try await httpClient.request(endpoint: endpoint, responseModel: StrigaGetAccountStatementResponse.self) + } + + public func initiateSEPAPayment(userId: String, accountId: String, amount: String, iban: String, + bic: String) async throws -> StrigaInitiateSEPAPaymentResponse + { + guard let keyPair else { throw BankTransferError.invalidKeyPair } + let endpoint = try StrigaEndpoint.initiateSEPAPayment( + baseURL: baseURL, + keyPair: keyPair, + userId: userId, + sourceAccountId: accountId, + amount: amount, + iban: iban, + bic: bic + ) + return try await httpClient.request(endpoint: endpoint, responseModel: StrigaInitiateSEPAPaymentResponse.self) + } +} + +// MARK: - Error response + +struct StrigaRemoteProviderError: Codable { + let errorCode: String? +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/Striga/Repository/StrigaBankTransferUserDataRepository.swift b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Repository/StrigaBankTransferUserDataRepository.swift new file mode 100644 index 0000000000..e8825473cb --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/Striga/Repository/StrigaBankTransferUserDataRepository.swift @@ -0,0 +1,504 @@ +import Combine +import Foundation +import KeyAppKitCore +import KeyAppKitLogger +import KeyAppNetworking +import SolanaSwift +import TweetNacl + +public final class StrigaBankTransferUserDataRepository: BankTransferUserDataRepository { + public typealias WithdrawalInfo = StrigaWithdrawalInfo + + // MARK: - Properties + + private let localProvider: StrigaLocalProvider + private let remoteProvider: StrigaRemoteProvider + private let metadataProvider: StrigaMetadataProvider + private let solanaKeyPair: KeyPair? + + // TODO: Consider removing commonInfoProvider from StrigaBankTransferUserDataRepository, because when more bank transfer providers will be added, we will need to have more general logic for this + private let commonInfoProvider: CommonInfoLocalProvider + + // MARK: - Initializer + + public init( + localProvider: StrigaLocalProvider, + remoteProvider: StrigaRemoteProvider, + metadataProvider: StrigaMetadataProvider, + commonInfoProvider: CommonInfoLocalProvider, + solanaKeyPair: KeyPair? + ) { + self.localProvider = localProvider + self.remoteProvider = remoteProvider + self.metadataProvider = metadataProvider + self.commonInfoProvider = commonInfoProvider + self.solanaKeyPair = solanaKeyPair + } + + // MARK: - Methods + + public func getUserId() async -> String? { + await metadataProvider.getStrigaMetadata()?.userId + } + + public func getKYCStatus() async throws -> StrigaKYC { + guard let userId = await getUserId() else { + throw BankTransferError.missingUserId + } + return try await remoteProvider.getKYCStatus(userId: userId) + } + + public func createUser(registrationData data: BankTransferRegistrationData) async throws + -> StrigaCreateUserResponse { + // assert response type + guard let data = data as? StrigaUserDetailsResponse else { + throw StrigaProviderError.invalidRequest("Data mismatch") + } + + // create model + let model = StrigaCreateUserRequest( + firstName: data.firstName, + lastName: data.lastName, + email: data.email, + mobile: StrigaCreateUserRequest.Mobile( + countryCode: data.mobile.countryCode, + number: data.mobile.number + ), + dateOfBirth: StrigaCreateUserRequest.DateOfBirth( + year: data.dateOfBirth?.year, + month: data.dateOfBirth?.month, + day: data.dateOfBirth?.day + ), + address: StrigaCreateUserRequest.Address( + addressLine1: data.address?.addressLine1, + addressLine2: data.address?.addressLine2, + city: data.address?.city, + postalCode: data.address?.postalCode, + state: data.address?.state, + country: data.address?.country + ), + occupation: data.occupation, + sourceOfFunds: data.sourceOfFunds, + ipAddress: nil, + placeOfBirth: data.placeOfBirth, + expectedIncomingTxVolumeYearly: .expectedIncomingTxVolumeYearly, + expectedOutgoingTxVolumeYearly: .expectedOutgoingTxVolumeYearly, + selfPepDeclaration: false, + purposeOfAccount: .purposeOfAccount + ) + // send createUser + let response = try await remoteProvider.createUser(model: model) + + // save registration data + try await localProvider.save(registrationData: data) + + // save userId + await metadataProvider.updateMetadata(withUserId: response.userId) + + // return + return response + } + + public func updateLocally(registrationData data: BankTransferRegistrationData) async throws { + // assert response type + guard let data = data as? StrigaUserDetailsResponse else { + throw StrigaProviderError.invalidRequest("Data mismatch") + } + + try? await localProvider.save(registrationData: data) + + // Update common info with latest striga information + let commonInfo = UserCommonInfo( + firstName: data.firstName, + lastName: data.lastName, + placeOfBirth: data.placeOfBirth, + dateOfBirth: DateOfBirth( + year: data.dateOfBirth?.year, + month: data.dateOfBirth?.month, + day: data.dateOfBirth?.day + ) + ) + try? await commonInfoProvider.save(commonInfo: commonInfo) + } + + public func updateLocally(userData data: UserData) async throws { + try? await localProvider.save(userData: data) + } + + public func verifyMobileNumber(userId: String, verificationCode code: String) async throws { + try await remoteProvider.verifyMobileNumber(userId: userId, verificationCode: code) + } + + public func resendSMS(userId: String) async throws { + try await remoteProvider.resendSMS(userId: userId) + } + + public func getKYCToken(userId: String) async throws -> String { + try await remoteProvider.getKYCToken(userId: userId) + } + + public func getRegistrationData() async throws -> BankTransferRegistrationData { + // get cached data from local provider + if let cachedData = await localProvider.getCachedRegistrationData() { + if let userId = await getUserId(), + cachedData.mobileNumber == nil, + let response = try? await remoteProvider.getUserDetails(userId: userId) + { + // save to local provider + try await localProvider.save(registrationData: response) + + // return + return response + } + + // if not response cached data + return cachedData + } else if let userId = await getUserId(), + let response = try? await remoteProvider.getUserDetails(userId: userId) + { + // Make request for userDetails if there is a userId and no cached data + try await localProvider.save(registrationData: response) + return response + } + + // get metadata + guard let metadata = await metadataProvider.getStrigaMetadata() + else { + throw BankTransferError.missingMetadata + } + + // return empty data + return StrigaUserDetailsResponse( + firstName: "", + lastName: "", + email: metadata.email, + mobile: .init( + countryCode: "", + number: "" + ), + KYC: .init( + status: .notStarted, + mobileVerified: false + ) + ) + } + + public func clearCache() async { + await localProvider.clear() + await commonInfoProvider.clear() + } + + public func getWallet(userId: String) async throws -> UserWallet? { + var wallet: UserWallet? = await localProvider.getCachedUserData()?.wallet + do { + var userWallet = try await remoteProvider.getAllWalletsByUser( + userId: userId, + startDate: Date(timeIntervalSince1970: 1_687_564_800), + endDate: Date(), + page: 1 + ).wallets.map { + UserWallet($0, cached: wallet) + }.first + + if let account = userWallet?.accounts.usdc { + do { + let fee = try await getFeeFor(userId: userId, account: account) + let avBalance = account.availableBalance - fee + userWallet?.accounts.usdc?.setAvailableBalance(max(0, avBalance)) + } catch { + Logger.log( + event: "Striga get estimated fee", + message: error.localizedDescription, + logLevel: KeyAppKitLoggerLogLevel.warning + ) + } + wallet = userWallet + } + } catch { + Logger.log( + event: "Striga get all wallets", + message: error.localizedDescription, + logLevel: KeyAppKitLoggerLogLevel.warning + ) + } + + if let eur = wallet?.accounts.eur, !eur.enriched { + do { + let response: StrigaEnrichedEURAccountResponse = try await enrichAccount( + userId: userId, + accountId: eur.accountID + ) + wallet?.accounts.eur = EURUserAccount( + accountID: eur.accountID, + currency: eur.currency, + createdAt: eur.createdAt, + enriched: true, + availableBalance: eur.availableBalance, + iban: response.iban, + bic: response.bic, + bankAccountHolderName: response.bankAccountHolderName + ) + } catch { + // Skip error, do not block the flow + Logger.log( + event: "Striga EUR Enrichment", + message: error.localizedDescription, + logLevel: KeyAppKitLoggerLogLevel.warning + ) + } + } + + do { + // Whitelist address + try await addWhitelistIfNeeded( + for: userId, + account: wallet?.accounts.usdc + ) + } catch { + Logger.log( + event: "Striga add to Whitelist", + message: error.localizedDescription, + logLevel: KeyAppKitLoggerLogLevel.warning + ) + } + + if let usdc = wallet?.accounts.usdc, !usdc.enriched { + do { + let response: StrigaEnrichedUSDCAccountResponse = try await enrichAccount( + userId: userId, + accountId: usdc.accountID + ) + var fee = 0 + do { + fee = try await getFeeFor(userId: userId, account: usdc) + } catch { + Logger.log( + event: "Striga get estimated fee", + message: error.localizedDescription, + logLevel: KeyAppKitLoggerLogLevel.warning + ) + } + wallet?.accounts.usdc = USDCUserAccount( + accountID: usdc.accountID, + currency: usdc.currency, + createdAt: usdc.createdAt, + enriched: true, + blockchainDepositAddress: response.blockchainDepositAddress, + availableBalance: max(0, usdc.availableBalance - fee), + totalBalance: usdc.availableBalance + ) + } catch { + // Skip error, do not block the flow + Logger.log( + event: "Striga USDC Enrichment", + message: error.localizedDescription, + logLevel: KeyAppKitLoggerLogLevel.warning + ) + } + } + return wallet + } + + public func claimVerify( + userId: String, + challengeId: String, + ip: String, + verificationCode code: String + ) async throws { + _ = try await remoteProvider.transactionConfirmOTP(userId: userId, challengeId: challengeId, code: code, ip: ip) + } + + public func claimResendSMS(userId: String, challengeId: String) async throws { + _ = try await remoteProvider.transactionResendOTP(userId: userId, challengeId: challengeId) + } + + public func whitelistIdFor(account: USDCUserAccount) async throws -> String? { + try await localProvider.getWhitelistedUserDestinations() + .filter { response in + response.address == solanaKeyPair?.publicKey.base58EncodedString + && response.currency == account.currency + } + .first?.id + } + + public func addWhitelistIfNeeded(for userId: String, account: USDCUserAccount?) async throws { + guard + let address = solanaKeyPair?.publicKey.base58EncodedString, + let currency = account?.currency, + let account, + try await whitelistIdFor(account: account) == nil + else { return } + do { + _ = try await remoteProvider.whitelistDestinationAddress( + userId: userId, + address: address, + currency: currency, + network: "SOL", + label: "SOL" + ) + } catch let HTTPClientError.invalidResponse(_, data) { + let res = try? JSONDecoder().decode(StrigaRemoteProviderError.self, from: data) + if StrigaWhitelistAddressError(rawValue: res?.errorCode ?? "") != .alreadyWhitelisted { + throw BankTransferError.missingMetadata + } + } + + let whitelisted = try await remoteProvider.getWhitelistedUserDestinations( + userId: userId, + currency: account.currency, + label: "SOL", + page: "0" + ).addresses + try? await localProvider.save(whitelisted: whitelisted) + } + + public func initiateOnchainWithdrawal( + userId: String, + sourceAccountId: String, + whitelistedAddressId: String, + amount: String, + accountCreation: Bool + ) async throws -> StrigaWalletSendResponse { + try await remoteProvider.initiateOnChainWalletSend( + userId: userId, + sourceAccountId: sourceAccountId, + whitelistedAddressId: whitelistedAddressId, + amount: amount, + accountCreation: accountCreation + ) + } + + public func exchangeRates(from fromToken: String, to toToken: String) async throws -> StrigaExchangeRates { + let key = [fromToken, toToken].joined().uppercased() + if let data = try await remoteProvider.exchangeRates()[key] { + return data + } + throw StrigaProviderError.invalidRateTokens + } + + public func initiateSEPAPayment(userId: String, accountId: String, amount: String, iban: String, + bic: String) async throws -> String + { + try await remoteProvider.initiateSEPAPayment( + userId: userId, + accountId: accountId, + amount: amount, + iban: iban, + bic: bic + ).challengeId + } + + // MARK: - Private + + private func enrichAccount(userId: String, accountId: String) async throws -> T { + try await remoteProvider.enrichAccount(userId: userId, accountId: accountId) + } + + private func getFeeFor(userId: String, account: USDCUserAccount) async throws -> Int { + guard let whitelistId = try await whitelistIdFor(account: account) else { + throw BankTransferError.missingUserId + } + let fees = try await remoteProvider.initiateOnchainFeeEstimate( + userId: userId, + sourceAccountId: account.accountID, + whitelistedAddressId: whitelistId, + amount: "\(account.totalBalance)" + ) + return Int(fees.totalFee) ?? 0 + } +} + +// MARK: - Helpers + +private extension String { + static let expectedIncomingTxVolumeYearly = "MORE_THAN_15000_EUR" + static let expectedOutgoingTxVolumeYearly = "MORE_THAN_15000_EUR" + static let purposeOfAccount = "CRYPTO_PAYMENTS" +} + +private extension UserWallet { + init(_ wallet: StrigaWallet, cached: UserWallet?) { + var eur: EURUserAccount? + if let eurAccount = wallet.accounts.eur { + eur = EURUserAccount( + accountID: eurAccount.accountID, + currency: eurAccount.currency, + createdAt: eurAccount.createdAt, + enriched: cached?.accounts.eur?.enriched ?? false, + availableBalance: Int(eurAccount.availableBalance.amount) ?? 0, + iban: cached?.accounts.eur?.iban, + bic: cached?.accounts.eur?.bic, + bankAccountHolderName: cached?.accounts.eur?.bankAccountHolderName + ) + } + var usdc: USDCUserAccount? + if let usdcAccount = wallet.accounts.usdc { + usdc = USDCUserAccount( + accountID: usdcAccount.accountID, + currency: usdcAccount.currency, + createdAt: usdcAccount.createdAt, + enriched: cached?.accounts.usdc?.enriched ?? false, + blockchainDepositAddress: cached?.accounts.usdc?.blockchainDepositAddress, + availableBalance: Int(usdcAccount.availableBalance.amount) ?? 0, + totalBalance: Int(usdcAccount.availableBalance.amount) ?? 0 + ) + } + walletId = wallet.walletID + accounts = UserAccounts(eur: eur, usdc: usdc) + } +} + +public extension StrigaBankTransferUserDataRepository { + // MARK: - Withdrawal + + func getWithdrawalInfo(userId: String) async throws -> StrigaWithdrawalInfo? { + if let cached = await localProvider.getCachedWithdrawalInfo() { + return cached + } else { + guard let accountId = await localProvider.getCachedUserData()?.wallet?.accounts.eur?.accountID else { + throw StrigaProviderError.missingAccountId + } + + let transactions = try await remoteProvider.getAccountStatement( + userId: userId, + accountId: accountId, + startDate: Constants.startDate, + endDate: Date(), + page: 1 + ).transactions + let completedTransaction = transactions.first(where: { $0.txType == Constants.sepaPayoutCompleted }) + let initialTransaction = transactions + .first(where: { $0.id == completedTransaction?.id && $0.txType == Constants.sepaPayoutInitiated }) ?? + transactions.first(where: { $0.txType == Constants.sepaPayinCompleted }) + + let regData = await localProvider.getCachedRegistrationData() + + let info = WithdrawalInfo( + IBAN: initialTransaction?.bankingSenderIban, + BIC: initialTransaction?.bankingSenderBic, + receiver: [regData?.firstName, regData?.lastName].compactMap { $0 }.joined(separator: " ") + ) + if info.IBAN != nil, info.BIC != nil { + try? await save(info) + } + return info + } + } + + func save(_ info: StrigaWithdrawalInfo) async throws { + try await localProvider.save( + withdrawalInfo: .init( + IBAN: info.IBAN, + BIC: info.BIC, + receiver: info.receiver + ) + ) + } +} + +private enum Constants { + static let startDate = Date(timeIntervalSince1970: 1_687_564_800) // 24.06.2023 + static let sepaPayoutCompleted = "SEPA_PAYOUT_COMPLETED" + static let sepaPayoutInitiated = "SEPA_PAYOUT_INITIATED" + static let sepaPayinCompleted = "SEPA_PAYIN_COMPLETED" +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/UserAction/StrigaBankTransferOutgoingUserActionConsumer.swift b/Packages/KeyAppKit/Sources/BankTransfer/UserAction/StrigaBankTransferOutgoingUserActionConsumer.swift new file mode 100644 index 0000000000..18baa94fae --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/UserAction/StrigaBankTransferOutgoingUserActionConsumer.swift @@ -0,0 +1,161 @@ +import Combine +import Foundation +import KeyAppBusiness +import KeyAppKitCore +import SolanaSwift + +public enum OutgoingBankTransferUserActionResult: Codable, Equatable { + case requestWithdrawInfo(receiver: String) + case initiated(challengeId: String, IBAN: String, BIC: String) +} + +public enum OutgoingBankTransferUserActionEvent: UserActionEvent { + case track(OutgoingBankTransferUserAction, UserActionStatus) + case complete(OutgoingBankTransferUserAction, OutgoingBankTransferUserActionResult) + case sendFailure(OutgoingBankTransferUserAction, UserActionError) +} + +public class StrigaBankTransferOutgoingUserActionConsumer: UserActionConsumer { + public typealias Action = OutgoingBankTransferUserAction + public typealias Event = OutgoingBankTransferUserActionEvent + + public let persistence: UserActionPersistentStorage + let database: SynchronizedDatabase = .init() + + private var bankTransferService: AnyBankTransferService + + public init( + persistence: UserActionPersistentStorage, + bankTransferService: AnyBankTransferService + ) { + self.persistence = persistence + self.bankTransferService = bankTransferService + } + + public var onUpdate: AnyPublisher<[any UserAction], Never> { + database + .onUpdate + .map { data in + Array(data.values) + } + .eraseToAnyPublisher() + } + + public func start() {} + + public func process(action: any UserAction) { + guard let action = action as? Action else { return } + + Task { [weak self] in + await self?.database.set(for: action.id, action) + self?.handle(event: Event.track(action, .processing)) + /// Checking if all data is available + guard + let service = self?.bankTransferService.value, + let userId = await service.repository.getUserId(), + let withdrawInfo = try? await service.repository.getWithdrawalInfo(userId: userId), + let iban = withdrawInfo.IBAN, + let bic = withdrawInfo.BIC + else { + Logger.log( + event: "Striga Confirm Action", + message: "Absence of data", + logLevel: .error + ) + let regData = try? await self?.bankTransferService.value.getRegistrationData() + self?.handle(event: Event.complete( + action, + .requestWithdrawInfo(receiver: [regData?.firstName, regData?.lastName].compactMap { $0 } + .joined(separator: " ")) + )) + return + } + + do { + let result = try await service.repository.initiateSEPAPayment( + userId: userId, + accountId: action.accountId, + amount: action.amount, + iban: iban, + bic: bic + ) + self?.handle(event: Event.complete(action, .initiated( + challengeId: result, + IBAN: iban, + BIC: bic + ))) + } catch let error as NSError where error.isNetworkConnectionError { + self?.handle(event: Event.sendFailure(action, .networkFailure)) + } catch { + self?.handle(event: Event.sendFailure(action, .requestFailure(description: error.localizedDescription))) + } + } + } + + public func handle(event: any UserActionEvent) { + guard let event = event as? Event else { return } + handleInternalEvent(event: event) + } + + func handleInternalEvent(event: Event) { + switch event { + case let .complete(action, result): + Task { [weak self] in + guard let self = self else { return } + var userAction = Action( + id: action.id, + accountId: action.accountId, + amount: action.amount, + status: .ready + ) + userAction.result = result + await self.database.set(for: userAction.id, userAction) + } + case let .track(action, status): + Task { [weak self] in + guard let self = self else { return } + let userAction = Action( + id: action.id, + accountId: action.accountId, + amount: action.amount, + status: status + ) + await self.database.set(for: userAction.id, userAction) + } + case let .sendFailure(action, errorModel): + Task { [weak self] in + guard var userAction = await self?.database.get(for: action.id) else { return } + userAction.status = .error(errorModel) + await self?.database.set(for: action.id, userAction) + } + } + } +} + +public struct OutgoingBankTransferUserAction: UserAction, Equatable { + /// Unique internal id to track. + public let id: String + public let accountId: String + public let amount: String // In cents + /// Abstract status. + public var status: UserActionStatus + public var createdDate: Date + public var updatedDate: Date + public var result: OutgoingBankTransferUserActionResult? + + public init( + id: String, + accountId: String, + amount: String, + status: UserActionStatus, + createdDate: Date = Date(), + updatedDate: Date = Date() + ) { + self.id = id + self.accountId = accountId + self.amount = amount + self.status = status + self.createdDate = createdDate + self.updatedDate = updatedDate + } +} diff --git a/Packages/KeyAppKit/Sources/BankTransfer/UserAction/StrigaBankTransferUserActionConsumer.swift b/Packages/KeyAppKit/Sources/BankTransfer/UserAction/StrigaBankTransferUserActionConsumer.swift new file mode 100644 index 0000000000..389ed4023e --- /dev/null +++ b/Packages/KeyAppKit/Sources/BankTransfer/UserAction/StrigaBankTransferUserActionConsumer.swift @@ -0,0 +1,181 @@ +import Combine +import Foundation +import KeyAppBusiness +import KeyAppKitCore +import SolanaSwift + +public struct BankTransferClaimUserActionResult: Codable, Equatable { + public let fromAddress: String + public let challengeId: String + public let token: TokenMetadata +} + +public enum BankTransferClaimUserActionEvent: UserActionEvent { + case track(BankTransferClaimUserAction, UserActionStatus) + case complete(BankTransferClaimUserAction, BankTransferClaimUserActionResult) + case sendFailure(BankTransferClaimUserAction, UserActionError) +} + +public class StrigaBankTransferUserActionConsumer: UserActionConsumer { + public typealias Action = BankTransferClaimUserAction + public typealias Event = BankTransferClaimUserActionEvent + + public let persistence: UserActionPersistentStorage + let database: SynchronizedDatabase = .init() + + private var bankTransferService: AnyBankTransferService + private let solanaAccountService: SolanaAccountsService + + public init( + persistence: UserActionPersistentStorage, + bankTransferService: AnyBankTransferService, + solanaAccountService: SolanaAccountsService + ) { + self.persistence = persistence + self.bankTransferService = bankTransferService + self.solanaAccountService = solanaAccountService + } + + public var onUpdate: AnyPublisher<[any UserAction], Never> { + database + .onUpdate + .map { data in + Array(data.values) + } + .eraseToAnyPublisher() + } + + public func start() {} + + public func process(action: any UserAction) { + guard let action = action as? Action else { return } + + Task { [weak self] in + await self?.database.set(for: action.id, action) + self?.handle(event: Event.track(action, .processing)) + /// Checking if destination is in whitelist + guard + let service = self?.bankTransferService.value, + let userId = await service.repository.getUserId(), + let account = try await service.repository.getWallet(userId: userId)?.accounts.usdc, + let amount = action.amount, + let fromAddress = account.blockchainDepositAddress, + let whitelistedAddressId = try await service.repository.whitelistIdFor(account: account) + else { + Logger.log( + event: "Striga Claim Action", + message: "Needs to whitelist account", + logLevel: .error + ) + self?.handle(event: Event.sendFailure( + action, + UserActionError(domain: "Needs to whitelist account", code: 1, reason: "Needs to whitelist account") + )) + return + } + + let shouldMakeAccount = !(!solanaAccountService.state.value.filter { account in + account.token.address == PublicKey.usdcMint.base58EncodedString + }.isEmpty) + + do { + let result = try await service.repository.initiateOnchainWithdrawal( + userId: userId, + sourceAccountId: account.accountID, + whitelistedAddressId: whitelistedAddressId, + amount: amount, + accountCreation: shouldMakeAccount + ) + self?.handle(event: Event.complete(action, .init( + fromAddress: fromAddress, + challengeId: result.challengeId, + token: TokenMetadata.usdc + ))) + } catch let error as NSError where error.isNetworkConnectionError { + self?.handle(event: Event.sendFailure(action, .networkFailure)) + } catch { + self?.handle(event: Event.sendFailure( + action, + UserActionError.requestFailure(description: error.localizedDescription) + )) + } + } + } + + public func handle(event: any UserActionEvent) { + guard let event = event as? Event else { return } + handleInternalEvent(event: event) + } + + func handleInternalEvent(event: Event) { + switch event { + case let .complete(action, result): + Task { [weak self] in + guard let self = self else { return } + var userAction = Action( + id: action.id, + accountId: action.accountId, + token: action.token, + amount: action.amount, + receivingAddress: action.receivingAddress, + status: .ready + ) + userAction.result = result + await self.database.set(for: userAction.id, userAction) + } + case let .track(action, status): + Task { [weak self] in + guard let self = self else { return } + var userAction = Action( + id: action.id, + accountId: action.accountId, + token: action.token, + amount: action.amount, + receivingAddress: action.receivingAddress, + status: status + ) + await self.database.set(for: userAction.id, userAction) + } + case let .sendFailure(action, errorModel): + Task { [weak self] in + guard var userAction = await self?.database.get(for: action.id) else { return } + userAction.status = .error(errorModel) + await self?.database.set(for: action.id, userAction) + } + } + } +} + +public struct BankTransferClaimUserAction: UserAction { + /// Unique internal id to track. + public var id: String + public var accountId: String + public let token: EthereumToken? + public let amount: String? + public let receivingAddress: String + /// Abstract status. + public var status: UserActionStatus + public var createdDate: Date + public var updatedDate: Date + public var result: BankTransferClaimUserActionResult? + + public init( + id: String, + accountId: String, + token: EthereumToken?, + amount: String?, + receivingAddress: String, + status: UserActionStatus, + createdDate: Date = Date(), + updatedDate: Date = Date() + ) { + self.id = id + self.accountId = accountId + self.token = token + self.amount = amount + self.receivingAddress = receivingAddress + self.status = status + self.createdDate = createdDate + self.updatedDate = updatedDate + } +} diff --git a/Packages/KeyAppKit/Sources/CountriesAPI/CountriesAPI.swift b/Packages/KeyAppKit/Sources/CountriesAPI/CountriesAPI.swift index dd8ed12858..445d1f5c4e 100644 --- a/Packages/KeyAppKit/Sources/CountriesAPI/CountriesAPI.swift +++ b/Packages/KeyAppKit/Sources/CountriesAPI/CountriesAPI.swift @@ -2,6 +2,8 @@ import Foundation public protocol CountriesAPI { func fetchCountries() async throws -> Countries + /// Fetches regions from the server + func fetchRegions() async throws -> [Region] } public final class CountriesAPIImpl: CountriesAPI { @@ -24,4 +26,13 @@ public final class CountriesAPIImpl: CountriesAPI { return countries }.value } + + public func fetchRegions() async throws -> [Region] { + let regionListURL = + URL(string: "https://raw.githubusercontent.com/p2p-org/country-list/main/country-list.json")! + let data = try Data(contentsOf: regionListURL) + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode([Region].self, from: data) + } } diff --git a/Packages/KeyAppKit/Sources/CountriesAPI/Models.swift b/Packages/KeyAppKit/Sources/CountriesAPI/Models.swift index ec65eb53b2..6953f3fa85 100644 --- a/Packages/KeyAppKit/Sources/CountriesAPI/Models.swift +++ b/Packages/KeyAppKit/Sources/CountriesAPI/Models.swift @@ -7,12 +7,20 @@ public struct Country: Codable, Hashable { public let code: String public let dialCode: String public let emoji: String? + public let alpha3Code: String - public init(name: String, code: String, dialCode: String, emoji: String?) { + public init( + name: String, + code: String, + dialCode: String, + emoji: String?, + alpha3Code: String + ) { self.name = name self.code = code self.dialCode = dialCode self.emoji = emoji + self.alpha3Code = alpha3Code } enum CodingKeys: String, CodingKey { @@ -20,7 +28,106 @@ public struct Country: Codable, Hashable { case code = "name_code" case dialCode = "phone_code" case emoji = "flag_emoji" + case alpha3Code = "alpha3_code" + } +} + +public struct Region: Codable, Equatable, Hashable { + public var name: String + public let alpha2: String + public let alpha3: String + public let flagEmoji: String? + public let isStrigaAllowed: Bool + public let isMoonpayAllowed: Bool + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + alpha2 = try container.decode(String.self, forKey: .alpha2) + alpha3 = try container.decode(String.self, forKey: .alpha3) + flagEmoji = try container.decodeIfPresent(String.self, forKey: .flagEmoji)?.stringByDecodingHTMLEntities + isStrigaAllowed = try container.decode(Bool.self, forKey: .isStrigaAllowed) + isMoonpayAllowed = try container.decode(Bool.self, forKey: .isMoonpayAllowed) } } public typealias Countries = [Country] + +private extension String { + /// Returns a new string made by replacing in the `String` + /// all HTML character entity references with the corresponding + /// character. + var stringByDecodingHTMLEntities: String { + // ===== Utility functions ===== + + // Convert the number in the string to the corresponding + // Unicode character, e.g. + // decodeNumeric("64", 10) --> "@" + // decodeNumeric("20ac", 16) --> "€" + func decodeNumeric(_ string: Substring, base: Int) -> Character? { + guard let code = UInt32(string, radix: base), + let uniScalar = UnicodeScalar(code) else { return nil } + return Character(uniScalar) + } + + // Decode the HTML character entity to the corresponding + // Unicode character, return `nil` for invalid input. + // decode("@") --> "@" + // decode("€") --> "€" + // decode("<") --> "<" + // decode("&foo;") --> nil + func decode(_ entity: Substring) -> Character? { + if entity.hasPrefix("&#x") || entity.hasPrefix("&#X") { + return decodeNumeric(entity.dropFirst(3).dropLast(), base: 16) + } else if entity.hasPrefix("&#") { + return decodeNumeric(entity.dropFirst(2).dropLast(), base: 10) + } else { + return characterEntities[entity] + } + } + + // ===== Method starts here ===== + + var result = "" + var position = startIndex + + // Find the next '&' and copy the characters preceding it to `result`: + while let ampRange = self[position...].range(of: "&") { + result.append(contentsOf: self[position ..< ampRange.lowerBound]) + position = ampRange.lowerBound + + // Find the next ';' and copy everything from '&' to ';' into `entity` + guard let semiRange = self[position...].range(of: ";") else { + // No matching ';'. + break + } + let entity = self[position ..< semiRange.upperBound] + position = semiRange.upperBound + + if let decoded = decode(entity) { + // Replace by decoded character: + result.append(decoded) + } else { + // Invalid entity, copy verbatim: + result.append(contentsOf: entity) + } + } + // Copy remaining characters to `result`: + result.append(contentsOf: self[position...]) + return result + } +} + +private let characterEntities: [Substring: Character] = [ + // XML predefined entities: + """: "\"", + "&": "&", + "'": "'", + "<": "<", + ">": ">", + + // HTML character entity references: + " ": "\u{00a0}", + // ... + "♦": "♦", +] diff --git a/Packages/KeyAppKit/Sources/CountriesAPI/Resources/countries.json b/Packages/KeyAppKit/Sources/CountriesAPI/Resources/countries.json index d356fb8cf9..4b8a317f08 100644 --- a/Packages/KeyAppKit/Sources/CountriesAPI/Resources/countries.json +++ b/Packages/KeyAppKit/Sources/CountriesAPI/Resources/countries.json @@ -3,1470 +3,1715 @@ "name": "Andorra", "flag_emoji": "🇦🇩", "name_code": "ad", - "phone_code": "+376" + "phone_code": "+376", + "alpha3_code": "AND" }, { "name": "United Arab Emirates (UAE)", "flag_emoji": "🇦🇪", "name_code": "ae", - "phone_code": "+971" + "phone_code": "+971", + "alpha3_code": "ARE" }, { "name": "Afghanistan", "flag_emoji": "🇦🇫", "name_code": "af", - "phone_code": "+93" + "phone_code": "+93", + "alpha3_code": "AFG" }, { "name": "Antigua and Barbuda", "flag_emoji": "🇦🇬", "name_code": "ag", - "phone_code": "+1268" + "phone_code": "+1268", + "alpha3_code": "ATG" }, { "name": "Anguilla", "flag_emoji": "🇦🇮", "name_code": "ai", - "phone_code": "+1264" + "phone_code": "+1264", + "alpha3_code": "AIA" }, { "name": "Albania", "flag_emoji": "🇦🇱", "name_code": "al", - "phone_code": "+355" + "phone_code": "+355", + "alpha3_code": "ALB" }, { "name": "Armenia", "flag_emoji": "🇦🇲", "name_code": "am", - "phone_code": "+374" + "phone_code": "+374", + "alpha3_code": "ARM" }, { "name": "Angola", "flag_emoji": "🇦🇴", "name_code": "ao", - "phone_code": "+244" + "phone_code": "+244", + "alpha3_code": "AGO" }, { "name": "Antarctica", "flag_emoji": "🇦🇶", "name_code": "aq", - "phone_code": "+672" + "phone_code": "+672", + "alpha3_code": "ATA" }, { "name": "Argentina", "flag_emoji": "🇦🇷", "name_code": "ar", - "phone_code": "+54" + "phone_code": "+54", + "alpha3_code": "ARG" }, { "name": "American Samoa", "flag_emoji": "🇦🇸", "name_code": "as", - "phone_code": "+1684" + "phone_code": "+1684", + "alpha3_code": "ASM" }, { "name": "Austria", "flag_emoji": "🇦🇹", "name_code": "at", - "phone_code": "+43" + "phone_code": "+43", + "alpha3_code": "AUT" }, { "name": "Australia", "flag_emoji": "🇦🇺", "name_code": "au", - "phone_code": "+61" + "phone_code": "+61", + "alpha3_code": "AUS" }, { "name": "Aruba", "flag_emoji": "🇦🇼", "name_code": "aw", - "phone_code": "+297" + "phone_code": "+297", + "alpha3_code": "ABW" }, { "name": "Aland Islands", "flag_emoji": "🇦🇽", "name_code": "ax", - "phone_code": "+358" + "phone_code": "+358", + "alpha3_code": "ALA" }, { "name": "Azerbaijan", "flag_emoji": "🇦🇿", "name_code": "az", - "phone_code": "+994" + "phone_code": "+994", + "alpha3_code": "AZE" }, { "name": "Bosnia And Herzegovina", "flag_emoji": "🇧🇦", "name_code": "ba", - "phone_code": "+387" + "phone_code": "+387", + "alpha3_code": "BIH" }, { "name": "Barbados", "flag_emoji": "🇧🇧", "name_code": "bb", - "phone_code": "+1246" + "phone_code": "+1246", + "alpha3_code": "BRB" }, { "name": "Bangladesh", "flag_emoji": "🇧🇩", "name_code": "bd", - "phone_code": "+880" + "phone_code": "+880", + "alpha3_code": "BGD" }, { "name": "Belgium", "flag_emoji": "🇧🇪", "name_code": "be", - "phone_code": "+32" + "phone_code": "+32", + "alpha3_code": "BEL" }, { "name": "Burkina Faso", "flag_emoji": "🇧🇫", "name_code": "bf", - "phone_code": "+226" + "phone_code": "+226", + "alpha3_code": "BFA" }, { "name": "Bulgaria", "flag_emoji": "🇧🇬", "name_code": "bg", - "phone_code": "+359" + "phone_code": "+359", + "alpha3_code": "BGR" }, { "name": "Bahrain", "flag_emoji": "🇧🇭", "name_code": "bh", - "phone_code": "+973" + "phone_code": "+973", + "alpha3_code": "BHR" }, { "name": "Burundi", "flag_emoji": "🇧🇮", "name_code": "bi", - "phone_code": "+257" + "phone_code": "+257", + "alpha3_code": "BDI" }, { "name": "Benin", "flag_emoji": "🇧🇯", "name_code": "bj", - "phone_code": "+229" + "phone_code": "+229", + "alpha3_code": "BEN" }, { "name": "Saint Barthélemy", "flag_emoji": "🇧🇱", "name_code": "bl", - "phone_code": "+590" + "phone_code": "+590", + "alpha3_code": "BLM" }, { "name": "Bermuda", "flag_emoji": "🇧🇲", "name_code": "bm", - "phone_code": "+1441" + "phone_code": "+1441", + "alpha3_code": "BMU" }, { "name": "Brunei Darussalam", "flag_emoji": "🇧🇳", "name_code": "bn", - "phone_code": "+673" + "phone_code": "+673", + "alpha3_code": "BRN" }, { "name": "Bolivia", "flag_emoji": "🇧🇴", "name_code": "bo", - "phone_code": "+591" + "phone_code": "+591", + "alpha3_code": "BOL" }, { "name": "Brazil", "flag_emoji": "🇧🇷", "name_code": "br", - "phone_code": "+55" + "phone_code": "+55", + "alpha3_code": "BRA" }, { "name": "Bahamas", "flag_emoji": "🇧🇸", "name_code": "bs", - "phone_code": "+1242" + "phone_code": "+1242", + "alpha3_code": "BHS" }, { "name": "Bhutan", "flag_emoji": "🇧🇹", "name_code": "bt", - "phone_code": "+975" + "phone_code": "+975", + "alpha3_code": "BTN" }, { "name": "Botswana", "flag_emoji": "🇧🇼", "name_code": "bw", - "phone_code": "+267" + "phone_code": "+267", + "alpha3_code": "BWA" }, { "name": "Belarus", "flag_emoji": "🇧🇾", "name_code": "by", - "phone_code": "+375" + "phone_code": "+375", + "alpha3_code": "BLR" }, { "name": "Belize", "flag_emoji": "🇧🇿", "name_code": "bz", - "phone_code": "+501" + "phone_code": "+501", + "alpha3_code": "BLZ" }, { "name": "Canada", "flag_emoji": "🇨🇦", "name_code": "ca", - "phone_code": "+1" + "phone_code": "+1", + "alpha3_code": "CAN" }, { "name": "Cocos (keeling) Islands", "flag_emoji": "🇨🇨", "name_code": "cc", - "phone_code": "+61" + "phone_code": "+61", + "alpha3_code": "CCK" }, { "name": "Congo(Dem.Rep.)", "flag_emoji": "🇨🇩", "name_code": "cd", - "phone_code": "+243" + "phone_code": "+243", + "alpha3_code": "COD" }, { "name": "Central African Republic", "flag_emoji": "🇨🇫", "name_code": "cf", - "phone_code": "+236" + "phone_code": "+236", + "alpha3_code": "CAF" }, { "name": "Congo(Rep.)", "flag_emoji": "🇨🇬", "name_code": "cg", - "phone_code": "+242" + "phone_code": "+242", + "alpha3_code": "COG" }, { "name": "Switzerland", "flag_emoji": "🇨🇭", "name_code": "ch", - "phone_code": "+41" + "phone_code": "+41", + "alpha3_code": "CHE" }, { "name": "Côte D'ivoire", "flag_emoji": "🇨🇮", "name_code": "ci", - "phone_code": "+225" + "phone_code": "+225", + "alpha3_code": "CIV" }, { "name": "Cook Islands", "flag_emoji": "🇨🇰", "name_code": "ck", - "phone_code": "+682" + "phone_code": "+682", + "alpha3_code": "COK" }, { "name": "Chile", "flag_emoji": "🇨🇱", "name_code": "cl", - "phone_code": "+56" + "phone_code": "+56", + "alpha3_code": "CHL" }, { "name": "Cameroon", "flag_emoji": "🇨🇲", "name_code": "cm", - "phone_code": "+237" + "phone_code": "+237", + "alpha3_code": "CMR" }, { "name": "China", "flag_emoji": "🇨🇳", "name_code": "cn", - "phone_code": "+86" + "phone_code": "+86", + "alpha3_code": "CHN" }, { "name": "Colombia", "flag_emoji": "🇨🇴", "name_code": "co", - "phone_code": "+57" + "phone_code": "+57", + "alpha3_code": "COL" }, { "name": "Costa Rica", "flag_emoji": "🇨🇷", "name_code": "cr", - "phone_code": "+506" + "phone_code": "+506", + "alpha3_code": "CRI" }, { "name": "Cuba", "flag_emoji": "🇨🇺", "name_code": "cu", - "phone_code": "+53" + "phone_code": "+53", + "alpha3_code": "CUB" }, { "name": "Cape Verde", "flag_emoji": "🇨🇻", "name_code": "cv", - "phone_code": "+238" + "phone_code": "+238", + "alpha3_code": "CPV" }, { "name": "Curaçao", "flag_emoji": "🇨🇼", "name_code": "cw", - "phone_code": "+599" + "phone_code": "+599", + "alpha3_code": "CUW" }, { "name": "Christmas Island", "flag_emoji": "🇨🇽", "name_code": "cx", - "phone_code": "+61" + "phone_code": "+61", + "alpha3_code": "CXR" }, { "name": "Cyprus", "flag_emoji": "🇨🇾", "name_code": "cy", - "phone_code": "+357" + "phone_code": "+357", + "alpha3_code": "CYP" }, { "name": "Czech Republic", "flag_emoji": "🇨🇿", "name_code": "cz", - "phone_code": "+420" + "phone_code": "+420", + "alpha3_code": "CZE" }, { "name": "Germany", "flag_emoji": "🇩🇪", "name_code": "de", - "phone_code": "+49" + "phone_code": "+49", + "alpha3_code": "DEU" }, { "name": "Djibouti", "flag_emoji": "🇩🇯", "name_code": "dj", - "phone_code": "+253" + "phone_code": "+253", + "alpha3_code": "DJI" }, { "name": "Denmark", "flag_emoji": "🇩🇰", "name_code": "dk", - "phone_code": "+45" + "phone_code": "+45", + "alpha3_code": "DNK" }, { "name": "Dominica", "flag_emoji": "🇩🇲", "name_code": "dm", - "phone_code": "+1767" + "phone_code": "+1767", + "alpha3_code": "DMA" }, { "name": "Dominican Republic", "flag_emoji": "🇩🇴", "name_code": "do", - "phone_code": "+1849" + "phone_code": "+1849", + "alpha3_code": "DOM" }, { "name": "Dominican Republic", "flag_emoji": "🇩🇴", "name_code": "do", - "phone_code": "+1809" + "phone_code": "+1809", + "alpha3_code": "DOM" }, { "name": "Dominican Republic", "flag_emoji": "🇩🇴", "name_code": "do", - "phone_code": "+1829" + "phone_code": "+1829", + "alpha3_code": "DOM" }, { "name": "Algeria", "flag_emoji": "🇩🇿", "name_code": "dz", - "phone_code": "+213" + "phone_code": "+213", + "alpha3_code": "DZA" }, { "name": "Ecuador", "flag_emoji": "🇪🇨", "name_code": "ec", - "phone_code": "+593" + "phone_code": "+593", + "alpha3_code": "ECU" }, { "name": "Estonia", "flag_emoji": "🇪🇪", "name_code": "ee", - "phone_code": "+372" + "phone_code": "+372", + "alpha3_code": "EST" }, { "name": "Egypt", "flag_emoji": "🇪🇬", "name_code": "eg", - "phone_code": "+20" + "phone_code": "+20", + "alpha3_code": "EGY" }, { "name": "Eritrea", "flag_emoji": "🇪🇷", "name_code": "er", - "phone_code": "+291" + "phone_code": "+291", + "alpha3_code": "ERI" }, { "name": "Spain", "flag_emoji": "🇪🇸", "name_code": "es", - "phone_code": "+34" + "phone_code": "+34", + "alpha3_code": "ESP" }, { "name": "Ethiopia", "flag_emoji": "🇪🇹", "name_code": "et", - "phone_code": "+251" + "phone_code": "+251", + "alpha3_code": "ETH" }, { "name": "Finland", "flag_emoji": "🇫🇮", "name_code": "fi", - "phone_code": "+358" + "phone_code": "+358", + "alpha3_code": "FIN" }, { "name": "Fiji", "flag_emoji": "🇫🇯", "name_code": "fj", - "phone_code": "+679" + "phone_code": "+679", + "alpha3_code": "FJI" }, { "name": "Falkland Islands (malvinas)", "flag_emoji": "🇫🇰", "name_code": "fk", - "phone_code": "+500" + "phone_code": "+500", + "alpha3_code": "FLK" }, { "name": "Micronesia", "flag_emoji": "🇫🇲", "name_code": "fm", - "phone_code": "+691" + "phone_code": "+691", + "alpha3_code": "FSM" }, { "name": "Faroe Islands", "flag_emoji": "🇫🇴", "name_code": "fo", - "phone_code": "+298" + "phone_code": "+298", + "alpha3_code": "FRO" }, { "name": "France", "flag_emoji": "🇫🇷", "name_code": "fr", - "phone_code": "+33" + "phone_code": "+33", + "alpha3_code": "FRA" }, { "name": "Gabon", "flag_emoji": "🇬🇦", "name_code": "ga", - "phone_code": "+241" + "phone_code": "+241", + "alpha3_code": "GAB" }, { "name": "United Kingdom", "flag_emoji": "🇬🇧", "name_code": "gb", - "phone_code": "+44" + "phone_code": "+44", + "alpha3_code": "GBR" }, { "name": "Grenada", "flag_emoji": "🇬🇩", "name_code": "gd", - "phone_code": "+473" + "phone_code": "+473", + "alpha3_code": "GRD" }, { "name": "Georgia", "flag_emoji": "🇬🇪", "name_code": "ge", - "phone_code": "+995" + "phone_code": "+995", + "alpha3_code": "GEO" }, { "name": "French Guyana", "flag_emoji": "🇬🇫", "name_code": "gf", - "phone_code": "+594" + "phone_code": "+594", + "alpha3_code": "GUF" }, { "name": "Guernsey", "flag_emoji": "🇬🇬", "name_code": "gg", - "phone_code": "+44" + "phone_code": "+44", + "alpha3_code": "GGY" }, { "name": "Ghana", "flag_emoji": "🇬🇭", "name_code": "gh", - "phone_code": "+233" + "phone_code": "+233", + "alpha3_code": "GHA" }, { "name": "Gibraltar", "flag_emoji": "🇬🇮", "name_code": "gi", - "phone_code": "+350" + "phone_code": "+350", + "alpha3_code": "GIB" }, { "name": "Greenland", "flag_emoji": "🇬🇱", "name_code": "gl", - "phone_code": "+299" + "phone_code": "+299", + "alpha3_code": "GRL" }, { "name": "Gambia", "flag_emoji": "🇬🇲", "name_code": "gm", - "phone_code": "+220" + "phone_code": "+220", + "alpha3_code": "GMB" }, { "name": "Guinea", "flag_emoji": "🇬🇳", "name_code": "gn", - "phone_code": "+224" + "phone_code": "+224", + "alpha3_code": "GIN" }, { "name": "Guadeloupe", "flag_emoji": "🇬🇵", "name_code": "gp", - "phone_code": "+590" + "phone_code": "+590", + "alpha3_code": "GLP" }, { "name": "Equatorial Guinea", "flag_emoji": "🇬🇶", "name_code": "gq", - "phone_code": "+240" + "phone_code": "+240", + "alpha3_code": "GNQ" }, { "name": "Greece", "flag_emoji": "🇬🇷", "name_code": "gr", - "phone_code": "+30" + "phone_code": "+30", + "alpha3_code": "GRC" }, { "name": "Guatemala", "flag_emoji": "🇬🇹", "name_code": "gt", - "phone_code": "+502" + "phone_code": "+502", + "alpha3_code": "GTM" }, { "name": "Guam", "flag_emoji": "🇬🇺", "name_code": "gu", - "phone_code": "+1671" + "phone_code": "+1671", + "alpha3_code": "GUM" }, { "name": "Guinea-bissau", "flag_emoji": "🇬🇼", "name_code": "gw", - "phone_code": "+245" + "phone_code": "+245", + "alpha3_code": "GNB" }, { "name": "Guyana", "flag_emoji": "🇬🇾", "name_code": "gy", - "phone_code": "+592" + "phone_code": "+592", + "alpha3_code": "GUY" }, { "name": "Hong Kong", "flag_emoji": "🇭🇰", "name_code": "hk", - "phone_code": "+852" + "phone_code": "+852", + "alpha3_code": "HKG" }, { "name": "Honduras", "flag_emoji": "🇭🇳", "name_code": "hn", - "phone_code": "+504" + "phone_code": "+504", + "alpha3_code": "HND" }, { "name": "Croatia", "flag_emoji": "🇭🇷", "name_code": "hr", - "phone_code": "+385" + "phone_code": "+385", + "alpha3_code": "HRV" }, { "name": "Haiti", "flag_emoji": "🇭🇹", "name_code": "ht", - "phone_code": "+509" + "phone_code": "+509", + "alpha3_code": "HTI" }, { "name": "Hungary", "flag_emoji": "🇭🇺", "name_code": "hu", - "phone_code": "+36" + "phone_code": "+36", + "alpha3_code": "HUN" }, { "name": "Indonesia", "flag_emoji": "🇮🇩", "name_code": "id", - "phone_code": "+62" + "phone_code": "+62", + "alpha3_code": "IDN" }, { "name": "Ireland", "flag_emoji": "🇮🇪", "name_code": "ie", - "phone_code": "+353" + "phone_code": "+353", + "alpha3_code": "IRL" }, { "name": "Israel", "flag_emoji": "🇮🇱", "name_code": "il", - "phone_code": "+972" + "phone_code": "+972", + "alpha3_code": "ISR" }, { "name": "Isle Of Man", "flag_emoji": "🇮🇲", "name_code": "im", - "phone_code": "+44" + "phone_code": "+44", + "alpha3_code": "IMN" }, { "name": "Iceland", "flag_emoji": "🇮🇸", "name_code": "is", - "phone_code": "+354" + "phone_code": "+354", + "alpha3_code": "ISL" }, { "name": "India", "flag_emoji": "🇮🇳", "name_code": "in", - "phone_code": "+91" + "phone_code": "+91", + "alpha3_code": "IND" }, { "name": "British Indian Ocean Territory", "flag_emoji": "🇮🇴", "name_code": "io", - "phone_code": "+246" + "phone_code": "+246", + "alpha3_code": "IOT" }, { "name": "Iraq", "flag_emoji": "🇮🇶", "name_code": "iq", - "phone_code": "+964" + "phone_code": "+964", + "alpha3_code": "IRQ" }, { "name": "Iran", "flag_emoji": "🇮🇷", "name_code": "ir", - "phone_code": "+98" + "phone_code": "+98", + "alpha3_code": "IRN" }, { "name": "Italy", "flag_emoji": "🇮🇹", "name_code": "it", - "phone_code": "+39" + "phone_code": "+39", + "alpha3_code": "ITA" }, { "name": "Jersey", "flag_emoji": "🇯🇪", "name_code": "je", - "phone_code": "+44" + "phone_code": "+44", + "alpha3_code": "JEY" }, { "name": "Jamaica", "flag_emoji": "🇯🇲", "name_code": "jm", - "phone_code": "+1876" + "phone_code": "+1876", + "alpha3_code": "JAM" }, { "name": "Jordan", "flag_emoji": "🇯🇴", "name_code": "jo", - "phone_code": "+962" + "phone_code": "+962", + "alpha3_code": "JOR" }, { "name": "Japan", "flag_emoji": "🇯🇵", "name_code": "jp", - "phone_code": "+81" + "phone_code": "+81", + "alpha3_code": "JPN" }, { "name": "Kenya", "flag_emoji": "🇰🇪", "name_code": "ke", - "phone_code": "+254" + "phone_code": "+254", + "alpha3_code": "KEN" }, { "name": "Kyrgyzstan", "flag_emoji": "🇰🇬", "name_code": "kg", - "phone_code": "+996" + "phone_code": "+996", + "alpha3_code": "KGZ" }, { "name": "Cambodia", "flag_emoji": "🇰🇭", "name_code": "kh", - "phone_code": "+855" + "phone_code": "+855", + "alpha3_code": "KHM" }, { "name": "Kiribati", "flag_emoji": "🇰🇮", "name_code": "ki", - "phone_code": "+686" + "phone_code": "+686", + "alpha3_code": "KIR" }, { "name": "Comoros", "flag_emoji": "🇰🇲", "name_code": "km", - "phone_code": "+269" + "phone_code": "+269", + "alpha3_code": "COM" }, { "name": "Saint Kitts and Nevis", "flag_emoji": "🇰🇳", "name_code": "kn", - "phone_code": "+1869" + "phone_code": "+1869", + "alpha3_code": "KNA" }, { "name": "North Korea", "flag_emoji": "🇰🇵", "name_code": "kp", - "phone_code": "+850" + "phone_code": "+850", + "alpha3_code": "PRK" }, { "name": "South Korea", "flag_emoji": "🇰🇷", "name_code": "kr", - "phone_code": "+82" + "phone_code": "+82", + "alpha3_code": "KOR" }, { "name": "Kuwait", "flag_emoji": "🇰🇼", "name_code": "kw", - "phone_code": "+965" + "phone_code": "+965", + "alpha3_code": "KWT" }, { "name": "Cayman Islands", "flag_emoji": "🇰🇾", "name_code": "ky", - "phone_code": "+1345" + "phone_code": "+1345", + "alpha3_code": "CYM" }, { "name": "Kazakhstan", "flag_emoji": "🇰🇿", "name_code": "kz", - "phone_code": "+7" + "phone_code": "+7", + "alpha3_code": "KAZ" }, { "name": "Lao People's Democratic Republic", "flag_emoji": "🇱🇦", "name_code": "la", - "phone_code": "+856" + "phone_code": "+856", + "alpha3_code": "LAO" }, { "name": "Lebanon", "flag_emoji": "🇱🇧", "name_code": "lb", - "phone_code": "+961" + "phone_code": "+961", + "alpha3_code": "LBN" }, { "name": "Saint Lucia", "flag_emoji": "🇱🇨", "name_code": "lc", - "phone_code": "+1758" + "phone_code": "+1758", + "alpha3_code": "LCA" }, { "name": "Liechtenstein", "flag_emoji": "🇱🇮", "name_code": "li", - "phone_code": "+423" + "phone_code": "+423", + "alpha3_code": "LIE" }, { "name": "Sri Lanka", "flag_emoji": "🇱🇰", "name_code": "lk", - "phone_code": "+94" + "phone_code": "+94", + "alpha3_code": "LKA" }, { "name": "Liberia", "flag_emoji": "🇱🇷", "name_code": "lr", - "phone_code": "+231" + "phone_code": "+231", + "alpha3_code": "LBR" }, { "name": "Lesotho", "flag_emoji": "🇱🇸", "name_code": "ls", - "phone_code": "+266" + "phone_code": "+266", + "alpha3_code": "LSO" }, { "name": "Lithuania", "flag_emoji": "🇱🇹", "name_code": "lt", - "phone_code": "+370" + "phone_code": "+370", + "alpha3_code": "LTU" }, { "name": "Luxembourg", "flag_emoji": "🇱🇺", "name_code": "lu", - "phone_code": "+352" + "phone_code": "+352", + "alpha3_code": "LUX" }, { "name": "Latvia", "flag_emoji": "🇱🇻", "name_code": "lv", - "phone_code": "+371" + "phone_code": "+371", + "alpha3_code": "LVA" }, { "name": "Libya", "flag_emoji": "🇱🇾", "name_code": "ly", - "phone_code": "+218" + "phone_code": "+218", + "alpha3_code": "LBY" }, { "name": "Morocco", "flag_emoji": "🇲🇦", "name_code": "ma", - "phone_code": "+212" + "phone_code": "+212", + "alpha3_code": "MAR" }, { "name": "Monaco", "flag_emoji": "🇲🇨", "name_code": "mc", - "phone_code": "+377" + "phone_code": "+377", + "alpha3_code": "MCO" }, { "name": "Moldova", "flag_emoji": "🇲🇩", "name_code": "md", - "phone_code": "+373" + "phone_code": "+373", + "alpha3_code": "MDA" }, { "name": "Montenegro", "flag_emoji": "🇲🇪", "name_code": "me", - "phone_code": "+382" + "phone_code": "+382", + "alpha3_code": "MNE" }, { "name": "Saint Martin", "flag_emoji": "🇲🇫", "name_code": "mf", - "phone_code": "+590" + "phone_code": "+590", + "alpha3_code": "MAF" }, { "name": "Madagascar", "flag_emoji": "🇲🇬", "name_code": "mg", - "phone_code": "+261" + "phone_code": "+261", + "alpha3_code": "MDG" }, { "name": "Marshall Islands", "flag_emoji": "🇲🇭", "name_code": "mh", - "phone_code": "+692" + "phone_code": "+692", + "alpha3_code": "MHL" }, { "name": "Macedonia (FYROM)", "flag_emoji": "🇲🇰", "name_code": "mk", - "phone_code": "+389" + "phone_code": "+389", + "alpha3_code": "MKD" }, { "name": "Mali", "flag_emoji": "🇲🇱", "name_code": "ml", - "phone_code": "+223" + "phone_code": "+223", + "alpha3_code": "MLI" }, { "name": "Myanmar", "flag_emoji": "🇲🇲", "name_code": "mm", - "phone_code": "+95" + "phone_code": "+95", + "alpha3_code": "MMR" }, { "name": "Mongolia", "flag_emoji": "🇲🇳", "name_code": "mn", - "phone_code": "+976" + "phone_code": "+976", + "alpha3_code": "MNG" }, { "name": "Macau", "flag_emoji": "🇲🇴", "name_code": "mo", - "phone_code": "+853" + "phone_code": "+853", + "alpha3_code": "MAC" }, { "name": "Northern Mariana Islands", "flag_emoji": "🇲🇵", "name_code": "mp", - "phone_code": "+1670" + "phone_code": "+1670", + "alpha3_code": "MNP" }, { "name": "Martinique", "flag_emoji": "🇲🇶", "name_code": "mq", - "phone_code": "+596" + "phone_code": "+596", + "alpha3_code": "MTQ" }, { "name": "Mauritania", "flag_emoji": "🇲🇷", "name_code": "mr", - "phone_code": "+222" + "phone_code": "+222", + "alpha3_code": "MRT" }, { "name": "Montserrat", "flag_emoji": "🇲🇸", "name_code": "ms", - "phone_code": "+1664" + "phone_code": "+1664", + "alpha3_code": "MSR" }, { "name": "Malta", "flag_emoji": "🇲🇹", "name_code": "mt", - "phone_code": "+356" + "phone_code": "+356", + "alpha3_code": "MLT" }, { "name": "Mauritius", "flag_emoji": "🇲🇺", "name_code": "mu", - "phone_code": "+230" + "phone_code": "+230", + "alpha3_code": "MUS" }, { "name": "Maldives", "flag_emoji": "🇲🇻", "name_code": "mv", - "phone_code": "+960" + "phone_code": "+960", + "alpha3_code": "MDV" }, { "name": "Malawi", "flag_emoji": "🇲🇼", "name_code": "mw", - "phone_code": "+265" + "phone_code": "+265", + "alpha3_code": "MWI" }, { "name": "Mexico", "flag_emoji": "🇲🇽", "name_code": "mx", - "phone_code": "+52" + "phone_code": "+52", + "alpha3_code": "MEX" }, { "name": "Malaysia", "flag_emoji": "🇲🇾", "name_code": "my", - "phone_code": "+60" + "phone_code": "+60", + "alpha3_code": "MYS" }, { "name": "Mozambique", "flag_emoji": "🇲🇿", "name_code": "mz", - "phone_code": "+258" + "phone_code": "+258", + "alpha3_code": "MOZ" }, { "name": "Namibia", "flag_emoji": "🇳🇦", "name_code": "na", - "phone_code": "+264" + "phone_code": "+264", + "alpha3_code": "NAM" }, { "name": "New Caledonia", "flag_emoji": "🇳🇨", "name_code": "nc", - "phone_code": "+687" + "phone_code": "+687", + "alpha3_code": "NCL" }, { "name": "Niger", "flag_emoji": "🇳🇪", "name_code": "ne", - "phone_code": "+227" + "phone_code": "+227", + "alpha3_code": "NER" }, { "name": "Norfolk Islands", "flag_emoji": "🇳🇫", "name_code": "nf", - "phone_code": "+672" + "phone_code": "+672", + "alpha3_code": "NFK" }, { "name": "Nigeria", "flag_emoji": "🇳🇬", "name_code": "ng", - "phone_code": "+234" + "phone_code": "+234", + "alpha3_code": "NGA" }, { "name": "Nicaragua", "flag_emoji": "🇳🇮", "name_code": "ni", - "phone_code": "+505" + "phone_code": "+505", + "alpha3_code": "NIC" }, { "name": "Netherlands", "flag_emoji": "🇳🇱", "name_code": "nl", - "phone_code": "+31" + "phone_code": "+31", + "alpha3_code": "NLD" }, { "name": "Norway", "flag_emoji": "🇳🇴", "name_code": "no", - "phone_code": "+47" + "phone_code": "+47", + "alpha3_code": "NOR" }, { "name": "Nepal", "flag_emoji": "🇳🇵", "name_code": "np", - "phone_code": "+977" + "phone_code": "+977", + "alpha3_code": "NPL" }, { "name": "Nauru", "flag_emoji": "🇳🇷", "name_code": "nr", - "phone_code": "+674" + "phone_code": "+674", + "alpha3_code": "NRU" }, { "name": "Niue", "flag_emoji": "🇳🇺", "name_code": "nu", - "phone_code": "+683" + "phone_code": "+683", + "alpha3_code": "NIU" }, { "name": "New Zealand", "flag_emoji": "🇳🇿", "name_code": "nz", - "phone_code": "+64" + "phone_code": "+64", + "alpha3_code": "NZL" }, { "name": "Oman", "flag_emoji": "🇴🇲", "name_code": "om", - "phone_code": "+968" + "phone_code": "+968", + "alpha3_code": "OMN" }, { "name": "Panama", "flag_emoji": "🇵🇦", "name_code": "pa", - "phone_code": "+507" + "phone_code": "+507", + "alpha3_code": "PAN" }, { "name": "Peru", "flag_emoji": "🇵🇪", "name_code": "pe", - "phone_code": "+51" + "phone_code": "+51", + "alpha3_code": "PER" }, { "name": "French Polynesia", "flag_emoji": "🇵🇫", "name_code": "pf", - "phone_code": "+689" + "phone_code": "+689", + "alpha3_code": "PYF" }, { "name": "Papua New Guinea", "flag_emoji": "🇵🇬", "name_code": "pg", - "phone_code": "+675" + "phone_code": "+675", + "alpha3_code": "PNG" }, { "name": "Philippines", "flag_emoji": "🇵🇭", "name_code": "ph", - "phone_code": "+63" + "phone_code": "+63", + "alpha3_code": "PHL" }, { "name": "Pakistan", "flag_emoji": "🇵🇰", "name_code": "pk", - "phone_code": "+92" + "phone_code": "+92", + "alpha3_code": "PAK" }, { "name": "Poland", "flag_emoji": "🇵🇱", "name_code": "pl", - "phone_code": "+48" + "phone_code": "+48", + "alpha3_code": "POL" }, { "name": "Saint Pierre And Miquelon", "flag_emoji": "🇵🇲", "name_code": "pm", - "phone_code": "+508" + "phone_code": "+508", + "alpha3_code": "SPM" }, { "name": "Pitcairn Islands", "flag_emoji": "🇵🇳", "name_code": "pn", - "phone_code": "+870" + "phone_code": "+870", + "alpha3_code": "PCN" }, { "name": "Puerto Rico", "flag_emoji": "🇵🇷", "name_code": "pr", - "phone_code": "+1787" + "phone_code": "+1787", + "alpha3_code": "PRI" }, { "name": "Puerto Rico", "flag_emoji": "🇵🇷", "name_code": "pr", - "phone_code": "+1939" + "phone_code": "+1939", + "alpha3_code": "PRI" }, { "name": "Palestine", "flag_emoji": "🇵🇸", "name_code": "ps", - "phone_code": "+970" + "phone_code": "+970", + "alpha3_code": "PSE" }, { "name": "Portugal", "flag_emoji": "🇵🇹", "name_code": "pt", - "phone_code": "+351" + "phone_code": "+351", + "alpha3_code": "PRT" }, { "name": "Palau", "flag_emoji": "🇵🇼", "name_code": "pw", - "phone_code": "+680" + "phone_code": "+680", + "alpha3_code": "PLW" }, { "name": "Paraguay", "flag_emoji": "🇵🇾", "name_code": "py", - "phone_code": "+595" + "phone_code": "+595", + "alpha3_code": "PRY" }, { "name": "Qatar", "flag_emoji": "🇶🇦", "name_code": "qa", - "phone_code": "+974" + "phone_code": "+974", + "alpha3_code": "QAT" }, { "name": "Réunion", "flag_emoji": "🇷🇪", "name_code": "re", - "phone_code": "+262" + "phone_code": "+262", + "alpha3_code": "REU" }, { "name": "Romania", "flag_emoji": "🇷🇴", "name_code": "ro", - "phone_code": "+40" + "phone_code": "+40", + "alpha3_code": "ROU" }, { "name": "Serbia", "flag_emoji": "🇷🇸", "name_code": "rs", - "phone_code": "+381" + "phone_code": "+381", + "alpha3_code": "SRB" }, { "name": "Russian Federation", "flag_emoji": "🇷🇺", "name_code": "ru", - "phone_code": "+7" + "phone_code": "+7", + "alpha3_code": "RUS" }, { "name": "Rwanda", "flag_emoji": "🇷🇼", "name_code": "rw", - "phone_code": "+250" + "phone_code": "+250", + "alpha3_code": "RWA" }, { "name": "Saudi Arabia", "flag_emoji": "🇸🇦", "name_code": "sa", - "phone_code": "+966" + "phone_code": "+966", + "alpha3_code": "SAU" }, { "name": "Solomon Islands", "flag_emoji": "🇸🇧", "name_code": "sb", - "phone_code": "+677" + "phone_code": "+677", + "alpha3_code": "SLB" }, { "name": "Seychelles", "flag_emoji": "🇸🇨", "name_code": "sc", - "phone_code": "+248" + "phone_code": "+248", + "alpha3_code": "SYC" }, { "name": "Sudan", "flag_emoji": "🇸🇩", "name_code": "sd", - "phone_code": "+249" + "phone_code": "+249", + "alpha3_code": "SDN" }, { "name": "Sweden", "flag_emoji": "🇸🇪", "name_code": "se", - "phone_code": "+46" + "phone_code": "+46", + "alpha3_code": "SWE" }, { "name": "Singapore", "flag_emoji": "🇸🇬", "name_code": "sg", - "phone_code": "+65" + "phone_code": "+65", + "alpha3_code": "SGP" }, { "name": "Saint Helena", "flag_emoji": "🇸🇭", "name_code": "sh", - "phone_code": "+290" + "phone_code": "+290", + "alpha3_code": "SHN" }, { "name": "Slovenia", "flag_emoji": "🇸🇮", "name_code": "si", - "phone_code": "+386" + "phone_code": "+386", + "alpha3_code": "SVN" }, { "name": "Slovakia", "flag_emoji": "🇸🇰", "name_code": "sk", - "phone_code": "+421" + "phone_code": "+421", + "alpha3_code": "SVK" }, { "name": "Sierra Leone", "flag_emoji": "🇸🇱", "name_code": "sl", - "phone_code": "+232" + "phone_code": "+232", + "alpha3_code": "SLE" }, { "name": "San Marino", "flag_emoji": "🇸🇲", "name_code": "sm", - "phone_code": "+378" + "phone_code": "+378", + "alpha3_code": "SMR" }, { "name": "Senegal", "flag_emoji": "🇸🇳", "name_code": "sn", - "phone_code": "+221" + "phone_code": "+221", + "alpha3_code": "SEN" }, { "name": "Somalia", "flag_emoji": "🇸🇴", "name_code": "so", - "phone_code": "+252" + "phone_code": "+252", + "alpha3_code": "SOM" }, { "name": "Suriname", "flag_emoji": "🇸🇷", "name_code": "sr", - "phone_code": "+597" + "phone_code": "+597", + "alpha3_code": "SUR" }, { "name": "South Sudan", "flag_emoji": "🇸🇸", "name_code": "ss", - "phone_code": "+211" + "phone_code": "+211", + "alpha3_code": "SSD" }, { "name": "Sao Tome And Principe", "flag_emoji": "🇸🇹", "name_code": "st", - "phone_code": "+239" + "phone_code": "+239", + "alpha3_code": "STP" }, { "name": "El Salvador", "flag_emoji": "🇸🇻", "name_code": "sv", - "phone_code": "+503" + "phone_code": "+503", + "alpha3_code": "SLV" }, { "name": "Sint Maarten", "flag_emoji": "🇸🇽", "name_code": "sx", - "phone_code": "+1721" + "phone_code": "+1721", + "alpha3_code": "SXM" }, { "name": "Syrian Arab Republic", "flag_emoji": "🇸🇾", "name_code": "sy", - "phone_code": "+963" + "phone_code": "+963", + "alpha3_code": "SYR" }, { "name": "Swaziland", "flag_emoji": "🇸🇿", "name_code": "sz", - "phone_code": "+268" + "phone_code": "+268", + "alpha3_code": "SWZ" }, { "name": "Turks and Caicos Islands", "flag_emoji": "🇹🇨", "name_code": "tc", - "phone_code": "+1649" + "phone_code": "+1649", + "alpha3_code": "TCA" }, { "name": "Chad", "flag_emoji": "🇹🇩", "name_code": "td", - "phone_code": "+235" + "phone_code": "+235", + "alpha3_code": "TCD" }, { "name": "Togo", "flag_emoji": "🇹🇬", "name_code": "tg", - "phone_code": "+228" + "phone_code": "+228", + "alpha3_code": "TGO" }, { "name": "Thailand", "flag_emoji": "🇹🇭", "name_code": "th", - "phone_code": "+66" + "phone_code": "+66", + "alpha3_code": "THA" }, { "name": "Tajikistan", "flag_emoji": "🇹🇯", "name_code": "tj", - "phone_code": "+992" + "phone_code": "+992", + "alpha3_code": "TJK" }, { "name": "Tokelau", "flag_emoji": "🇹🇰", "name_code": "tk", - "phone_code": "+690" + "phone_code": "+690", + "alpha3_code": "TKL" }, { "name": "Timor-leste", "flag_emoji": "🇹🇱", "name_code": "tl", - "phone_code": "+670" + "phone_code": "+670", + "alpha3_code": "TLS" }, { "name": "Turkmenistan", "flag_emoji": "🇹🇲", "name_code": "tm", - "phone_code": "+993" + "phone_code": "+993", + "alpha3_code": "TKM" }, { "name": "Tunisia", "flag_emoji": "🇹🇳", "name_code": "tn", - "phone_code": "+216" + "phone_code": "+216", + "alpha3_code": "TUN" }, { "name": "Tonga", "flag_emoji": "🇹🇴", "name_code": "to", - "phone_code": "+676" + "phone_code": "+676", + "alpha3_code": "TON" }, { "name": "Turkey", "flag_emoji": "🇹🇷", "name_code": "tr", - "phone_code": "+90" + "phone_code": "+90", + "alpha3_code": "TUR" }, { "name": "Trinidad & Tobago", "flag_emoji": "🇹🇹", "name_code": "tt", - "phone_code": "+1868" + "phone_code": "+1868", + "alpha3_code": "TTO" }, { "name": "Tuvalu", "flag_emoji": "🇹🇻", "name_code": "tv", - "phone_code": "+688" + "phone_code": "+688", + "alpha3_code": "TUV" }, { "name": "Taiwan", "flag_emoji": "🇹🇼", "name_code": "tw", - "phone_code": "+886" + "phone_code": "+886", + "alpha3_code": "TWN" }, { "name": "Tanzania", "flag_emoji": "🇹🇿", "name_code": "tz", - "phone_code": "+255" + "phone_code": "+255", + "alpha3_code": "TZA" }, { "name": "Ukraine", "flag_emoji": "🇺🇦", "name_code": "ua", - "phone_code": "+380" + "phone_code": "+380", + "alpha3_code": "UKR" }, { "name": "Uganda", "flag_emoji": "🇺🇬", "name_code": "ug", - "phone_code": "+256" + "phone_code": "+256", + "alpha3_code": "UGA" }, { "name": "United States", "flag_emoji": "🇺🇸", "name_code": "us", - "phone_code": "+1" + "phone_code": "+1", + "alpha3_code": "USA" }, { "name": "Uruguay", "flag_emoji": "🇺🇾", "name_code": "uy", - "phone_code": "+598" + "phone_code": "+598", + "alpha3_code": "URY" }, { "name": "Uzbekistan", "flag_emoji": "🇺🇿", "name_code": "uz", - "phone_code": "+998" + "phone_code": "+998", + "alpha3_code": "UZB" }, { "name": "Holy See (vatican City State)", "flag_emoji": "🇻🇦", "name_code": "va", - "phone_code": "+379" + "phone_code": "+379", + "alpha3_code": "VAT" }, { "name": "Saint Vincent & The Grenadines", "flag_emoji": "🇻🇨", "name_code": "vc", - "phone_code": "+1784" + "phone_code": "+1784", + "alpha3_code": "VCT" }, { "name": "Venezuela", "flag_emoji": "🇻🇪", "name_code": "ve", - "phone_code": "+58" + "phone_code": "+58", + "alpha3_code": "VEN" }, { "name": "British Virgin Islands", "flag_emoji": "🇻🇬", "name_code": "vg", - "phone_code": "+1284" + "phone_code": "+1284", + "alpha3_code": "VGB" }, { "name": "US Virgin Islands", "flag_emoji": "🇻🇮", "name_code": "vi", - "phone_code": "+1340" + "phone_code": "+1340", + "alpha3_code": "VIR" }, { "name": "Vietnam", "flag_emoji": "🇻🇳", "name_code": "vn", - "phone_code": "+84" + "phone_code": "+84", + "alpha3_code": "VNM" }, { "name": "Vanuatu", "flag_emoji": "🇻🇺", "name_code": "vu", - "phone_code": "+678" + "phone_code": "+678", + "alpha3_code": "VUT" }, { "name": "Wallis And Futuna", "flag_emoji": "🇼🇫", "name_code": "wf", - "phone_code": "+681" + "phone_code": "+681", + "alpha3_code": "WLF" }, { "name": "Samoa", "flag_emoji": "🇼🇸", "name_code": "ws", - "phone_code": "+685" + "phone_code": "+685", + "alpha3_code": "WSM" }, { "name": "Kosovo", "flag_emoji": "🇽🇰", "name_code": "xk", - "phone_code": "+383" + "phone_code": "+383", + "alpha3_code": "UNK" }, { "name": "Yemen", "flag_emoji": "🇾🇪", "name_code": "ye", - "phone_code": "+967" + "phone_code": "+967", + "alpha3_code": "YEM" }, { "name": "Mayotte", "flag_emoji": "🇾🇹", "name_code": "yt", - "phone_code": "+262" + "phone_code": "+262", + "alpha3_code": "MYT" }, { "name": "South Africa", "flag_emoji": "🇿🇦", "name_code": "za", - "phone_code": "+27" + "phone_code": "+27", + "alpha3_code": "ZAF" }, { "name": "Zambia", "flag_emoji": "🇿🇲", "name_code": "zm", - "phone_code": "+260" + "phone_code": "+260", + "alpha3_code": "ZMB" }, { "name": "Zimbabwe", "flag_emoji": "🇿🇼", "name_code": "zw", - "phone_code": "+263" + "phone_code": "+263", + "alpha3_code": "ZWE" } ] diff --git a/Packages/KeyAppKit/Sources/KeyAppBusiness/UserAction/UserActionError.swift b/Packages/KeyAppKit/Sources/KeyAppBusiness/UserAction/UserActionError.swift index df335269a3..f44b255f30 100644 --- a/Packages/KeyAppKit/Sources/KeyAppBusiness/UserAction/UserActionError.swift +++ b/Packages/KeyAppKit/Sources/KeyAppBusiness/UserAction/UserActionError.swift @@ -23,6 +23,14 @@ public extension UserActionError { ) } +public extension UserActionError { + private static let requestFailureDomain: String = "RequestFailure" + + static func requestFailure(description: String) -> UserActionError { + UserActionError(domain: requestFailureDomain, code: 1, reason: description) + } +} + public extension UserActionError { private static let feeRelayDomain: String = "FeeRelay" diff --git a/Packages/KeyAppKit/Sources/KeyAppKitCore/Extensions/Date+Extensions.swift b/Packages/KeyAppKit/Sources/KeyAppKitCore/Extensions/Date+Extensions.swift new file mode 100644 index 0000000000..1c0a183adb --- /dev/null +++ b/Packages/KeyAppKit/Sources/KeyAppKitCore/Extensions/Date+Extensions.swift @@ -0,0 +1,7 @@ +import Foundation + +public extension Date { + var millisecondsSince1970: Int64 { + Int64((timeIntervalSince1970 * 1000.0).rounded()) + } +} diff --git a/Packages/KeyAppKit/Sources/KeyAppNetworking/AnyCodable.swift b/Packages/KeyAppKit/Sources/KeyAppNetworking/AnyCodable.swift new file mode 100644 index 0000000000..b755a35bd1 --- /dev/null +++ b/Packages/KeyAppKit/Sources/KeyAppNetworking/AnyCodable.swift @@ -0,0 +1,458 @@ +import Foundation + +@frozen public struct AnyDecodable: Decodable { + public let value: Any + + public init(_ value: T?) { + self.value = value ?? () + } +} + +@usableFromInline +protocol _AnyDecodable { + var value: Any { get } + init(_ value: T?) +} + +extension AnyDecodable: _AnyDecodable {} + +extension _AnyDecodable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + #if canImport(Foundation) + self.init(NSNull()) + #else + self.init(Self?.none) + #endif + } else if let bool = try? container.decode(Bool.self) { + self.init(bool) + } else if let int = try? container.decode(Int.self) { + self.init(int) + } else if let uint = try? container.decode(UInt.self) { + self.init(uint) + } else if let double = try? container.decode(Double.self) { + self.init(double) + } else if let string = try? container.decode(String.self) { + self.init(string) + } else if let array = try? container.decode([AnyDecodable].self) { + self.init(array.map(\.value)) + } else if let dictionary = try? container.decode([String: AnyDecodable].self) { + self.init(dictionary.mapValues { $0.value }) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "AnyDecodable value cannot be decoded" + ) + } + } +} + +extension AnyDecodable: Equatable { + public static func == (lhs: AnyDecodable, rhs: AnyDecodable) -> Bool { + switch (lhs.value, rhs.value) { + #if canImport(Foundation) + case is (NSNull, NSNull), is (Void, Void): + return true + #endif + case let (lhs as Bool, rhs as Bool): + return lhs == rhs + case let (lhs as Int, rhs as Int): + return lhs == rhs + case let (lhs as Int8, rhs as Int8): + return lhs == rhs + case let (lhs as Int16, rhs as Int16): + return lhs == rhs + case let (lhs as Int32, rhs as Int32): + return lhs == rhs + case let (lhs as Int64, rhs as Int64): + return lhs == rhs + case let (lhs as UInt, rhs as UInt): + return lhs == rhs + case let (lhs as UInt8, rhs as UInt8): + return lhs == rhs + case let (lhs as UInt16, rhs as UInt16): + return lhs == rhs + case let (lhs as UInt32, rhs as UInt32): + return lhs == rhs + case let (lhs as UInt64, rhs as UInt64): + return lhs == rhs + case let (lhs as Float, rhs as Float): + return lhs == rhs + case let (lhs as Double, rhs as Double): + return lhs == rhs + case let (lhs as String, rhs as String): + return lhs == rhs + case let (lhs as [String: AnyDecodable], rhs as [String: AnyDecodable]): + return lhs == rhs + case let (lhs as [AnyDecodable], rhs as [AnyDecodable]): + return lhs == rhs + default: + return false + } + } +} + +extension AnyDecodable: CustomStringConvertible { + public var description: String { + switch value { + case is Void: + return String(describing: nil as Any?) + case let value as CustomStringConvertible: + return value.description + default: + return String(describing: value) + } + } +} + +extension AnyDecodable: CustomDebugStringConvertible { + public var debugDescription: String { + switch value { + case let value as CustomDebugStringConvertible: + return "AnyDecodable(\(value.debugDescription))" + default: + return "AnyDecodable(\(description))" + } + } +} + +extension AnyDecodable: Hashable { + public func hash(into hasher: inout Hasher) { + switch value { + case let value as Bool: + hasher.combine(value) + case let value as Int: + hasher.combine(value) + case let value as Int8: + hasher.combine(value) + case let value as Int16: + hasher.combine(value) + case let value as Int32: + hasher.combine(value) + case let value as Int64: + hasher.combine(value) + case let value as UInt: + hasher.combine(value) + case let value as UInt8: + hasher.combine(value) + case let value as UInt16: + hasher.combine(value) + case let value as UInt32: + hasher.combine(value) + case let value as UInt64: + hasher.combine(value) + case let value as Float: + hasher.combine(value) + case let value as Double: + hasher.combine(value) + case let value as String: + hasher.combine(value) + case let value as [String: AnyDecodable]: + hasher.combine(value) + case let value as [AnyDecodable]: + hasher.combine(value) + default: + break + } + } +} + +#if canImport(Foundation) + import Foundation +#endif + +/** + A type-erased `Encodable` value. + + The `AnyEncodable` type forwards encoding responsibilities + to an underlying value, hiding its specific underlying type. + + You can encode mixed-type values in dictionaries + and other collections that require `Encodable` conformance + by declaring their contained type to be `AnyEncodable`: + + let dictionary: [String: AnyEncodable] = [ + "boolean": true, + "integer": 42, + "double": 3.141592653589793, + "string": "string", + "array": [1, 2, 3], + "nested": [ + "a": "alpha", + "b": "bravo", + "c": "charlie" + ], + "null": nil + ] + + let encoder = JSONEncoder() + let json = try! encoder.encode(dictionary) + */ +@frozen public struct AnyEncodable: Encodable { + public let value: Any + + public init(_ value: T?) { + self.value = value ?? () + } +} + +@usableFromInline +protocol _AnyEncodable { + var value: Any { get } + init(_ value: T?) +} + +extension AnyEncodable: _AnyEncodable {} + +// MARK: - Encodable + +extension _AnyEncodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch value { + #if canImport(Foundation) + case is NSNull: + try container.encodeNil() + #endif + case is Void: + try container.encodeNil() + case let bool as Bool: + try container.encode(bool) + case let int as Int: + try container.encode(int) + case let int8 as Int8: + try container.encode(int8) + case let int16 as Int16: + try container.encode(int16) + case let int32 as Int32: + try container.encode(int32) + case let int64 as Int64: + try container.encode(int64) + case let uint as UInt: + try container.encode(uint) + case let uint8 as UInt8: + try container.encode(uint8) + case let uint16 as UInt16: + try container.encode(uint16) + case let uint32 as UInt32: + try container.encode(uint32) + case let uint64 as UInt64: + try container.encode(uint64) + case let float as Float: + try container.encode(float) + case let double as Double: + try container.encode(double) + case let string as String: + try container.encode(string) + #if canImport(Foundation) + case let number as NSNumber: + try encode(nsnumber: number, into: &container) + case let date as Date: + try container.encode(date) + case let url as URL: + try container.encode(url) + #endif + case let array as [Any?]: + try container.encode(array.map { AnyEncodable($0) }) + case let dictionary as [String: Any?]: + try container.encode(dictionary.mapValues { AnyEncodable($0) }) + case let encodable as Encodable: + try encodable.encode(to: encoder) + default: + let context = EncodingError.Context( + codingPath: container.codingPath, + debugDescription: "AnyEncodable value cannot be encoded" + ) + throw EncodingError.invalidValue(value, context) + } + } + + #if canImport(Foundation) + private func encode(nsnumber: NSNumber, into container: inout SingleValueEncodingContainer) throws { + switch Character(Unicode.Scalar(UInt8(nsnumber.objCType.pointee))) { + case "B": + try container.encode(nsnumber.boolValue) + case "c": + try container.encode(nsnumber.int8Value) + case "s": + try container.encode(nsnumber.int16Value) + case "i", "l": + try container.encode(nsnumber.int32Value) + case "q": + try container.encode(nsnumber.int64Value) + case "C": + try container.encode(nsnumber.uint8Value) + case "S": + try container.encode(nsnumber.uint16Value) + case "I", "L": + try container.encode(nsnumber.uint32Value) + case "Q": + try container.encode(nsnumber.uint64Value) + case "f": + try container.encode(nsnumber.floatValue) + case "d": + try container.encode(nsnumber.doubleValue) + default: + let context = EncodingError.Context( + codingPath: container.codingPath, + debugDescription: "NSNumber cannot be encoded because its type is not handled" + ) + throw EncodingError.invalidValue(nsnumber, context) + } + } + #endif +} + +extension AnyEncodable: Equatable { + public static func == (lhs: AnyEncodable, rhs: AnyEncodable) -> Bool { + switch (lhs.value, rhs.value) { + case is (Void, Void): + return true + case let (lhs as Bool, rhs as Bool): + return lhs == rhs + case let (lhs as Int, rhs as Int): + return lhs == rhs + case let (lhs as Int8, rhs as Int8): + return lhs == rhs + case let (lhs as Int16, rhs as Int16): + return lhs == rhs + case let (lhs as Int32, rhs as Int32): + return lhs == rhs + case let (lhs as Int64, rhs as Int64): + return lhs == rhs + case let (lhs as UInt, rhs as UInt): + return lhs == rhs + case let (lhs as UInt8, rhs as UInt8): + return lhs == rhs + case let (lhs as UInt16, rhs as UInt16): + return lhs == rhs + case let (lhs as UInt32, rhs as UInt32): + return lhs == rhs + case let (lhs as UInt64, rhs as UInt64): + return lhs == rhs + case let (lhs as Float, rhs as Float): + return lhs == rhs + case let (lhs as Double, rhs as Double): + return lhs == rhs + case let (lhs as String, rhs as String): + return lhs == rhs + case let (lhs as [String: AnyEncodable], rhs as [String: AnyEncodable]): + return lhs == rhs + case let (lhs as [AnyEncodable], rhs as [AnyEncodable]): + return lhs == rhs + default: + return false + } + } +} + +extension AnyEncodable: CustomStringConvertible { + public var description: String { + switch value { + case is Void: + return String(describing: nil as Any?) + case let value as CustomStringConvertible: + return value.description + default: + return String(describing: value) + } + } +} + +extension AnyEncodable: CustomDebugStringConvertible { + public var debugDescription: String { + switch value { + case let value as CustomDebugStringConvertible: + return "AnyEncodable(\(value.debugDescription))" + default: + return "AnyEncodable(\(description))" + } + } +} + +extension AnyEncodable: ExpressibleByNilLiteral {} +extension AnyEncodable: ExpressibleByBooleanLiteral {} +extension AnyEncodable: ExpressibleByIntegerLiteral {} +extension AnyEncodable: ExpressibleByFloatLiteral {} +extension AnyEncodable: ExpressibleByStringLiteral {} +extension AnyEncodable: ExpressibleByStringInterpolation {} +extension AnyEncodable: ExpressibleByArrayLiteral {} +extension AnyEncodable: ExpressibleByDictionaryLiteral {} + +extension _AnyEncodable { + public init(nilLiteral _: ()) { + self.init(nil as Any?) + } + + public init(booleanLiteral value: Bool) { + self.init(value) + } + + public init(integerLiteral value: Int) { + self.init(value) + } + + public init(floatLiteral value: Double) { + self.init(value) + } + + public init(extendedGraphemeClusterLiteral value: String) { + self.init(value) + } + + public init(stringLiteral value: String) { + self.init(value) + } + + public init(arrayLiteral elements: Any...) { + self.init(elements) + } + + public init(dictionaryLiteral elements: (AnyHashable, Any)...) { + self.init([AnyHashable: Any](elements, uniquingKeysWith: { first, _ in first })) + } +} + +extension AnyEncodable: Hashable { + public func hash(into hasher: inout Hasher) { + switch value { + case let value as Bool: + hasher.combine(value) + case let value as Int: + hasher.combine(value) + case let value as Int8: + hasher.combine(value) + case let value as Int16: + hasher.combine(value) + case let value as Int32: + hasher.combine(value) + case let value as Int64: + hasher.combine(value) + case let value as UInt: + hasher.combine(value) + case let value as UInt8: + hasher.combine(value) + case let value as UInt16: + hasher.combine(value) + case let value as UInt32: + hasher.combine(value) + case let value as UInt64: + hasher.combine(value) + case let value as Float: + hasher.combine(value) + case let value as Double: + hasher.combine(value) + case let value as String: + hasher.combine(value) + case let value as [String: AnyEncodable]: + hasher.combine(value) + case let value as [AnyEncodable]: + hasher.combine(value) + default: + break + } + } +} diff --git a/Packages/KeyAppKit/Sources/KeyAppNetworking/HTTPClient/HTTPClient.swift b/Packages/KeyAppKit/Sources/KeyAppNetworking/HTTPClient/HTTPClient.swift index 7744aff35e..77b52b813a 100644 --- a/Packages/KeyAppKit/Sources/KeyAppNetworking/HTTPClient/HTTPClient.swift +++ b/Packages/KeyAppKit/Sources/KeyAppNetworking/HTTPClient/HTTPClient.swift @@ -63,9 +63,19 @@ extension HTTPClient: IHTTPClient { request.httpBody = body.data(using: .utf8) } + // print cURL + #if DEBUG + print(request.cURL()) + #endif + // Retrieve data let (data, response) = try await urlSession.data(for: request) + // print response + #if DEBUG + print(String(data: data, encoding: .utf8) ?? "nil") + #endif + // Check cancellation try Task.checkCancellation() @@ -78,3 +88,31 @@ extension HTTPClient: IHTTPClient { return try decoder.decode(responseModel, data: data, httpURLResponse: response) } } + +// MARK: - Helpers + +private extension URLRequest { + func cURL(pretty: Bool = false) -> String { + let newLine = pretty ? "\\\n" : "" + let method = (pretty ? "--request " : "-X ") + "\(httpMethod ?? "GET") \(newLine)" + let url: String = (pretty ? "--url " : "") + "\'\(self.url?.absoluteString ?? "")\' \(newLine)" + + var cURL = "curl " + var header = "" + var data = "" + + if let httpHeaders = allHTTPHeaderFields, !httpHeaders.keys.isEmpty { + for (key, value) in httpHeaders { + header += (pretty ? "--header " : "-H ") + "\'\(key): \(value)\' \(newLine)" + } + } + + if let bodyData = httpBody, let bodyString = String(data: bodyData, encoding: .utf8), !bodyString.isEmpty { + data = "--data '\(bodyString)'" + } + + cURL += method + url + header + data + + return cURL + } +} diff --git a/Packages/KeyAppKit/Sources/Onboarding/Models/WalletMetaData.swift b/Packages/KeyAppKit/Sources/Onboarding/Models/WalletMetaData.swift index a9e861f301..160aa21b32 100644 --- a/Packages/KeyAppKit/Sources/Onboarding/Models/WalletMetaData.swift +++ b/Packages/KeyAppKit/Sources/Onboarding/Models/WalletMetaData.swift @@ -1,4 +1,5 @@ import Foundation +import KeyAppKitCore public struct WalletMetaData: Codable, Equatable { public static let ethPublicInfoKey = CodingUserInfoKey(rawValue: "ethPublic")! @@ -44,7 +45,7 @@ public struct WalletMetaData: Codable, Equatable { } } - public let striga: Striga + public var striga: Striga public init( ethPublic: String, diff --git a/Packages/KeyAppKit/Sources/Onboarding/Service/ResendCounter.swift b/Packages/KeyAppKit/Sources/Onboarding/Service/ResendCounter.swift index b31dc63445..be276be238 100644 --- a/Packages/KeyAppKit/Sources/Onboarding/Service/ResendCounter.swift +++ b/Packages/KeyAppKit/Sources/Onboarding/Service/ResendCounter.swift @@ -7,7 +7,7 @@ public struct ResendCounter: Codable, Equatable { public let attempt: Int public let until: Date - func incremented() -> ResendCounter { + public func incremented() -> ResendCounter { let newAttempt = attempt + 1 return ResendCounter( attempt: newAttempt, diff --git a/Packages/KeyAppKit/Sources/TransactionParser/Model/Info/StrigaClaimInfo.swift b/Packages/KeyAppKit/Sources/TransactionParser/Model/Info/StrigaClaimInfo.swift new file mode 100644 index 0000000000..6a99db6fb7 --- /dev/null +++ b/Packages/KeyAppKit/Sources/TransactionParser/Model/Info/StrigaClaimInfo.swift @@ -0,0 +1,25 @@ +import Foundation +import SolanaSwift + +/// A struct that contains all information about striga claimming. +public struct StrigaClaimInfo: Hashable { + public let amount: Double? + public let token: Token? + public let recevingPubkey: String? + + public init(amount: Double?, token: Token?, recevingPubkey: String?) { + self.amount = amount + self.token = token + self.recevingPubkey = recevingPubkey + } +} + +extension StrigaClaimInfo: Info { + public var symbol: String? { + token?.symbol + } + + public var mintAddress: String? { + token?.address + } +} diff --git a/Packages/KeyAppKit/Sources/TransactionParser/Model/Info/StrigaWithdrawInfo.swift b/Packages/KeyAppKit/Sources/TransactionParser/Model/Info/StrigaWithdrawInfo.swift new file mode 100644 index 0000000000..dc92347e83 --- /dev/null +++ b/Packages/KeyAppKit/Sources/TransactionParser/Model/Info/StrigaWithdrawInfo.swift @@ -0,0 +1,27 @@ +import Foundation +import SolanaSwift + +/// A struct that contains all information about striga withdrawing. +public struct StrigaWithdrawInfo: Hashable { + public let amount: Double? + public let token: Token? + public let IBAN: String + public let BIC: String + + public init(amount: Double?, token: Token?, IBAN: String, BIC: String) { + self.amount = amount + self.token = token + self.IBAN = IBAN + self.BIC = BIC + } +} + +extension StrigaWithdrawInfo: Info { + public var symbol: String? { + token?.symbol + } + + public var mintAddress: String? { + token?.address + } +} diff --git a/Packages/KeyAppKit/Tests/UnitTests/BankTransferTests/BankTransferServiceTests.swift b/Packages/KeyAppKit/Tests/UnitTests/BankTransferTests/BankTransferServiceTests.swift new file mode 100644 index 0000000000..3ba62dde55 --- /dev/null +++ b/Packages/KeyAppKit/Tests/UnitTests/BankTransferTests/BankTransferServiceTests.swift @@ -0,0 +1,73 @@ +//// +//// BankTransferServiceTests.swift +//// +//// +//// Created by Ivan on 24.05.2023. +//// +// +// import Foundation +// import XCTest +// +// @testable import BankTransfer +// +// final class BankTransferServiceTests: XCTestCase { +// +// var bankTransferService: BankTransferServiceImpl! +// var strigaProvider: MockStrigaProvider! +// +// override func setUpWithError() throws { +// try super.setUpWithError() +// +// strigaProvider = .init() +// // TODO: - How to mock KeyPair? +//// bankTransferService = .init(strigaProvider: strigaProvider, keyPair: KeyPair()) +// } +// +// override func tearDownWithError() throws { +// strigaProvider = nil +// bankTransferService = nil +// +// try super.tearDownWithError() +// } +// +// @MainActor +// func testCreateUserHardcode() async throws { +// +// // when +// _ = try await bankTransferService.createUser(data: .fake()) +// +// // then +// let model = strigaProvider.invokedCreateUserParameters!.model +// XCTAssertEqual(model.expectedIncomingTxVolumeYearly, "MORE_THAN_15000_EUR") +// XCTAssertEqual(model.expectedOutgoingTxVolumeYearly, "MORE_THAN_15000_EUR") +// XCTAssertEqual(model.purposeOfAccount, "CRYPTO_PAYMENTS") +// } +// } +// +//// MARK: - Mocks +// +// private extension BankTransferRegistrationData where Self == StrigaUserDetailsResponse { +// static func fake() -> StrigaUserDetailsResponse{ +// StrigaUserDetailsResponse( +// firstName: "firstName", +// lastName: "lastName", +// email: "email", +// mobile: .init( +// countryCode: "phoneCountryCode", +// number: "phoneNumber" +// ), +// dateOfBirth: .init(year: 2015, month: 10, day: 11), +// address: .init( +// addressLine1: "addressLine1", +// addressLine2: "addressLine2", +// city: "city", +// postalCode: "postalCode", +// state: "state", +// country: "country" +// ), +// occupation: "occupation", +// sourceOfFunds: "sourceOfFunds", +// placeOfBirth: "placeOfBirt" +// ) +// } +// } diff --git a/Packages/KeyAppKit/Tests/UnitTests/BankTransferTests/Mocks/MockStrigaProvider.swift b/Packages/KeyAppKit/Tests/UnitTests/BankTransferTests/Mocks/MockStrigaProvider.swift new file mode 100644 index 0000000000..9d082ca6a8 --- /dev/null +++ b/Packages/KeyAppKit/Tests/UnitTests/BankTransferTests/Mocks/MockStrigaProvider.swift @@ -0,0 +1,98 @@ +//// +//// MockStrigaProvider.swift +//// +//// +//// Created by Ivan on 24.05.2023. +//// +// +// import Foundation +// import BankTransfer +// +// final class MockStrigaProvider: StrigaRemoteProvider { +// +// var invokedCreateUser = false +// var invokedCreateUserCount = 0 +// var invokedCreateUserParameters: (model: StrigaCreateUserRequest, Void)? +// var invokedCreateUserParametersList = [(model: StrigaCreateUserRequest, Void)]() +// var stubbedCreateUserResult: Result? +// +// func createUser(model: StrigaCreateUserRequest) async throws -> StrigaCreateUserResponse { +// invokedCreateUser = true +// invokedCreateUserCount += 1 +// invokedCreateUserParameters = (model, ()) +// invokedCreateUserParametersList.append((model, ())) +// if let stubbedCreateUserResult { +// switch stubbedCreateUserResult { +// case let .success(response): +// return response +// case let .failure(error): +// throw error +// } +// } +// throw NSError(domain: "", code: 0) +// } +// +// var invokedGetUserDetails = false +// var invokedGetUserDetailsCount = 0 +// var invokedGetUserDetailsParameters: (userId: String, Void)? +// var invokedGetUserDetailsParametersList = [(userId: String, Void)]() +// var stubbedGetUserDetailsResult: Result? +// +// func getUserDetails(userId: String) async throws -> StrigaUserDetailsResponse { +// invokedGetUserDetails = true +// invokedGetUserDetailsCount += 1 +// invokedGetUserDetailsParameters = (userId, ()) +// invokedGetUserDetailsParametersList.append((userId, ())) +// if let stubbedGetUserDetailsResult { +// switch stubbedGetUserDetailsResult { +// case let .success(response): +// return response +// case let .failure(error): +// throw error +// } +// } +// throw NSError(domain: "", code: 0) +// } +// +// var invokedGetUserId = false +// var invokedGetUserIdCount = 0 +// var stubbedGetUserIdResult: Result? +// +// func getUserId() async throws -> String? { +// invokedGetUserId = true +// invokedGetUserIdCount += 1 +// if let stubbedGetUserIdResult { +// switch stubbedGetUserIdResult { +// case let .success(response): +// return response +// case let .failure(error): +// throw error +// } +// } +// throw NSError(domain: "", code: 0) +// } +// +// var invokedVerifyMobileNumber = false +// var invokedVerifyMobileNumberCount = 0 +// var invokedVerifyMobileNumberParameters: (userId: String, verificationCode: String)? +// var invokedVerifyMobileNumberParametersList = [(userId: String, verificationCode: String)]() +// +// func verifyMobileNumber(userId: String, verificationCode: String) async throws { +// invokedVerifyMobileNumber = true +// invokedVerifyMobileNumberCount += 1 +// invokedVerifyMobileNumberParameters = (userId, verificationCode) +// invokedVerifyMobileNumberParametersList.append((userId, verificationCode)) +// } +// +// var invokedResendSMS = false +// var invokedResendSMSCount = 0 +// var invokedResendSMSParameters: (userId: String, Void)? +// var invokedResendSMSParametersList = [(userId: String, Void)]() +// +// func resendSMS(userId: String) async throws { +// invokedResendSMS = true +// invokedResendSMSCount += 1 +// invokedResendSMSParameters = (userId, ()) +// invokedResendSMSParametersList.append((userId, ())) +// } +// } diff --git a/Packages/KeyAppKit/Tests/UnitTests/BankTransferTests/StrigaEndpointTests.swift b/Packages/KeyAppKit/Tests/UnitTests/BankTransferTests/StrigaEndpointTests.swift new file mode 100644 index 0000000000..c556703805 --- /dev/null +++ b/Packages/KeyAppKit/Tests/UnitTests/BankTransferTests/StrigaEndpointTests.swift @@ -0,0 +1,379 @@ +import SolanaSwift +import TweetNacl +import XCTest +@testable import BankTransfer + +class StrigaEndpointTests: XCTestCase { + let baseURL = "https://example.com" + + func testGetSignedTimestampMessage() async throws { + let keyPair = try await KeyPair( + phrase: "miracle pizza supply useful steak border same again youth silver access hundred" + .components(separatedBy: " "), + network: .mainnetBeta + ) + let date = NSDate(timeIntervalSince1970: 1_685_587_890.6146898) + + let signedTimestampMessage = try keyPair.getSignedTimestampMessage(timestamp: date) + + let expectedMessage = + "1685587890000:VhqmzP3ub4pQv8WwZG4IUMVeMwDPYXPQDRAIRxSFmMVezD5MWIBRl/UN11mpu0XXYXweaFHV92joLN2c89SEDg==" + + XCTAssertEqual(signedTimestampMessage, expectedMessage) + } + + func testGetKYC() throws { + let keyPair = try KeyPair() + let userId = "userId" + let timestamp = NSDate() + + let endpoint = try StrigaEndpoint.getKYC( + baseURL: baseURL, + keyPair: keyPair, + userId: userId, + timestamp: timestamp + ) + + XCTAssertEqual(endpoint.urlString, "https://example.com/striga/api/v1/user/kyc/userId") + XCTAssertEqual(endpoint.method, .get) + + let expectedHeader = try [ + "Content-Type": "application/json", + "User-PublicKey": keyPair.publicKey.base58EncodedString, + "Signed-Message": keyPair.getSignedTimestampMessage(timestamp: timestamp), + ] + XCTAssertEqual(endpoint.header, expectedHeader) + + XCTAssertNil(endpoint.body) + } + + func testVerifyMobileNumber() throws { + let keyPair = try KeyPair() + let userId = "userId" + let verificationCode = "code" + let timestamp = NSDate() + + let endpoint = try StrigaEndpoint.verifyMobileNumber( + baseURL: baseURL, + keyPair: keyPair, + userId: userId, + verificationCode: verificationCode, + timestamp: timestamp + ) + + XCTAssertEqual(endpoint.urlString, "https://example.com/striga/api/v1/user/verify-mobile") + XCTAssertEqual(endpoint.method, .post) + + let expectedHeader = try [ + "Content-Type": "application/json", + "User-PublicKey": keyPair.publicKey.base58EncodedString, + "Signed-Message": keyPair.getSignedTimestampMessage(timestamp: timestamp), + ] + XCTAssertEqual(endpoint.header, expectedHeader) + + let expectedBody = "{\"userId\":\"userId\",\"verificationCode\":\"code\"}" + XCTAssertEqual(endpoint.body, expectedBody) + } + + func testGetUserDetails() throws { + let keyPair = try KeyPair() + let userId = "abdicidjdi" + let timestamp = NSDate() + + let endpoint = try StrigaEndpoint.getUserDetails( + baseURL: baseURL, + keyPair: keyPair, + userId: userId, + timestamp: timestamp + ) + + XCTAssertEqual(endpoint.urlString, "https://example.com/striga/api/v1/user/abdicidjdi") + XCTAssertEqual(endpoint.method, .get) + + let expectedHeader = try [ + "Content-Type": "application/json", + "User-PublicKey": keyPair.publicKey.base58EncodedString, + "Signed-Message": keyPair.getSignedTimestampMessage(timestamp: timestamp), + ] + XCTAssertEqual(endpoint.header, expectedHeader) + + XCTAssertNil(endpoint.body) + } + + func testCreateUser() throws { + let keyPair = try KeyPair() + let body = StrigaCreateUserRequest( + firstName: "Elon", + lastName: "Musk", + email: "me@starlink.com", + mobile: .init( + countryCode: "1", + number: "123443453" + ), + dateOfBirth: .init(year: 1987, month: 12, day: 1), + address: .init( + addressLine1: "Elon str, 1", + addressLine2: nil, + city: "New York", + postalCode: "12345", + state: "NY", + country: "United States" + ), + occupation: .accounting, + sourceOfFunds: .civilContract, + ipAddress: "127.0.0.1", + placeOfBirth: "FRA", + expectedIncomingTxVolumeYearly: "20000", + expectedOutgoingTxVolumeYearly: "20000", + selfPepDeclaration: true, + purposeOfAccount: "hack" + ) + let timestamp = NSDate() + + let endpoint = try StrigaEndpoint.createUser( + baseURL: baseURL, + keyPair: keyPair, + body: body, + timestamp: timestamp + ) + + XCTAssertEqual(endpoint.urlString, "https://example.com/api/v1/user/create") + XCTAssertEqual(endpoint.method, .post) + + let expectedHeader = try [ + "Content-Type": "application/json", + "User-PublicKey": keyPair.publicKey.base58EncodedString, + "Signed-Message": keyPair.getSignedTimestampMessage(timestamp: timestamp), + ] + XCTAssertEqual(endpoint.header, expectedHeader) + + let expectedBody = + "{\"address\":{\"addressLine1\":\"Elon str, 1\",\"city\":\"New York\",\"country\":\"United States\",\"postalCode\":\"12345\",\"state\":\"NY\"},\"dateOfBirth\":{\"day\":1,\"month\":12,\"year\":1987},\"email\":\"me@starlink.com\",\"expectedIncomingTxVolumeYearly\":\"20000\",\"expectedOutgoingTxVolumeYearly\":\"20000\",\"firstName\":\"Elon\",\"ipAddress\":\"127.0.0.1\",\"lastName\":\"Musk\",\"mobile\":{\"countryCode\":\"1\",\"number\":\"123443453\"},\"occupation\":\"ACCOUNTING\",\"placeOfBirth\":\"FRA\",\"purposeOfAccount\":\"hack\",\"selfPepDeclaration\":true,\"sourceOfFunds\":\"CIVIL_CONTRACT\"}" + XCTAssertEqual(endpoint.body, expectedBody) + } + + func testResendSMS() throws { + let keyPair = try KeyPair() + let userId = "ijivjiji-jfijdij" + let timestamp = NSDate() + + let endpoint = try StrigaEndpoint.resendSMS( + baseURL: baseURL, + keyPair: keyPair, + userId: userId, + timestamp: timestamp + ) + + XCTAssertEqual(endpoint.urlString, "https://example.com/striga/api/v1/user/resend-sms") + XCTAssertEqual(endpoint.method, .post) + + let expectedHeader = try [ + "Content-Type": "application/json", + "User-PublicKey": keyPair.publicKey.base58EncodedString, + "Signed-Message": keyPair.getSignedTimestampMessage(timestamp: timestamp), + ] + XCTAssertEqual(endpoint.header, expectedHeader) + + let expectedBody = "{\"userId\":\"ijivjiji-jfijdij\"}" + XCTAssertEqual(endpoint.body, expectedBody) + } + + func testKYCGetToken() throws { + let keyPair = try KeyPair() + let userId = "ijivjiji-jfijdij" + let timestamp = NSDate() + + let endpoint = try StrigaEndpoint.getKYCToken( + baseURL: baseURL, + keyPair: keyPair, + userId: userId, + timestamp: timestamp + ) + + XCTAssertEqual(endpoint.urlString, "https://example.com/striga/api/v1/user/kyc/start") + XCTAssertEqual(endpoint.method, .post) + + let expectedHeader = try [ + "Content-Type": "application/json", + "User-PublicKey": keyPair.publicKey.base58EncodedString, + "Signed-Message": keyPair.getSignedTimestampMessage(timestamp: timestamp), + ] + XCTAssertEqual(endpoint.header, expectedHeader) + + let expectedBody = "{\"userId\":\"ijivjiji-jfijdij\"}" + XCTAssertEqual(endpoint.body, expectedBody) + } + + func testGetAllWallets() throws { + let keyPair = try KeyPair() + let userId = "ijivjiji-jfijdij" + let timestamp = NSDate() + + let endpoint = try StrigaEndpoint.getAllWallets( + baseURL: baseURL, + keyPair: keyPair, + userId: userId, + startDate: Date(timeIntervalSince1970: 0), + endDate: Date(timeIntervalSince1970: 2), + page: 1, + timestamp: timestamp + ) + + XCTAssertEqual(endpoint.urlString, "https://example.com/striga/api/v1/wallets/get/all") + XCTAssertEqual(endpoint.method, .post) + + let expectedHeader = try [ + "Content-Type": "application/json", + "User-PublicKey": keyPair.publicKey.base58EncodedString, + "Signed-Message": keyPair.getSignedTimestampMessage(timestamp: timestamp), + ] + XCTAssertEqual(endpoint.header, expectedHeader) + + let expectedBody = "{\"endDate\":2000,\"page\":1,\"startDate\":0,\"userId\":\"ijivjiji-jfijdij\"}" + XCTAssertEqual(endpoint.body, expectedBody) + } + + func testEnrichAccount() throws { + let keyPair = try KeyPair() + let userId = "19085577-4f74-40ad-a86c-0ad28d664170" + let accountId = "817c19ad473cd1bef869b408858156a2" + let timestamp = NSDate() + + let endpoint = try StrigaEndpoint.enrichAccount( + baseURL: baseURL, + keyPair: keyPair, + userId: userId, + accountId: accountId, + timestamp: timestamp + ) + + XCTAssertEqual(endpoint.urlString, "https://example.com/striga/api/v1/wallets/account/enrich") + XCTAssertEqual(endpoint.method, .post) + + let expectedHeader = try [ + "User-PublicKey": keyPair.publicKey.base58EncodedString, + "Signed-Message": keyPair.getSignedTimestampMessage(timestamp: timestamp), + "Content-Type": "application/json", + ] + XCTAssertEqual(endpoint.header, expectedHeader) + + let expectedBody = + "{\"accountId\":\"817c19ad473cd1bef869b408858156a2\",\"userId\":\"19085577-4f74-40ad-a86c-0ad28d664170\"}" + XCTAssertEqual(endpoint.body, expectedBody) + } + + func testInitiateOnChainWalletSend() throws { + let keyPair = try KeyPair() + let userId = "19085577-4f74-40ad-a86c-0ad28d664170" + let sourceAccountId = "817c19ad473cd1bef869b408858156a2" + let whitelistedAddressId = "817c19ad473cd1bef869b408858156a2" + let amount = "123" + let timestamp = NSDate() + + let endpoint = try StrigaEndpoint.initiateOnChainWalletSend( + baseURL: baseURL, + keyPair: keyPair, + userId: userId, + sourceAccountId: sourceAccountId, + whitelistedAddressId: whitelistedAddressId, + amount: amount, + timestamp: timestamp + ) + + XCTAssertEqual(endpoint.urlString, "https://example.com/api/v1/wallets/send/initiate/onchain") + XCTAssertEqual(endpoint.method, .post) + + let expectedHeaders = try [ + "Content-Type": "application/json", + "User-PublicKey": keyPair.publicKey.base58EncodedString, + "Signed-Message": keyPair.getSignedTimestampMessage(timestamp: timestamp), + ] + XCTAssertEqual(endpoint.header, expectedHeaders) + + let expectedBody = + "{\"accountCreation\":false,\"amount\":\"123\",\"sourceAccountId\":\"817c19ad473cd1bef869b408858156a2\",\"userId\":\"19085577-4f74-40ad-a86c-0ad28d664170\",\"whitelistedAddressId\":\"817c19ad473cd1bef869b408858156a2\"}" + XCTAssertEqual(endpoint.body!, expectedBody) + } + + func testTransactionResendOTPEndpoint() throws { + let keyPair = try KeyPair() + let userId = "cecaea44-47f2-439b-99a1-a35fefaf1eb6" + let challengeId = "f56aaf67-acc1-4397-ae6b-57b553bdc5b0" + let timestamp = NSDate() + + let endpoint = try StrigaEndpoint.transactionResendOTP( + baseURL: baseURL, + keyPair: keyPair, + userId: userId, + challengeId: challengeId, + timestamp: timestamp + ) + + XCTAssertEqual(endpoint.urlString, "https://example.com/striga/api/v1/wallets/transaction/resend-otp") + XCTAssertEqual(endpoint.method, .post) + + let expectedHeader = try [ + "Content-Type": "application/json", + "User-PublicKey": keyPair.publicKey.base58EncodedString, + "Signed-Message": keyPair.getSignedTimestampMessage(timestamp: timestamp), + ] + XCTAssertEqual(endpoint.header, expectedHeader) + + let expectedBody = + "{\"challengeId\":\"f56aaf67-acc1-4397-ae6b-57b553bdc5b0\",\"userId\":\"cecaea44-47f2-439b-99a1-a35fefaf1eb6\"}" + XCTAssertEqual(endpoint.body, expectedBody) + } + + func testTransactionConfirmOTPEndpoint() throws { + let keyPair = try KeyPair() + let userId = "cecaea44-47f2-439b-99a1-a35fefaf1eb6" + let challengeId = "f56aaf67-acc1-4397-ae6b-57b553bdc5b0" + let timestamp = NSDate() + + let endpoint = try StrigaEndpoint.transactionConfirmOTP( + baseURL: baseURL, + keyPair: keyPair, + userId: userId, + challengeId: challengeId, + verificationCode: "123456", + ip: "ipString", + timestamp: timestamp + ) + + XCTAssertEqual(endpoint.urlString, "https://example.com/striga/api/v1/wallets/transaction/confirm") + XCTAssertEqual(endpoint.method, .post) + + let expectedHeader = try [ + "Content-Type": "application/json", + "User-PublicKey": keyPair.publicKey.base58EncodedString, + "Signed-Message": keyPair.getSignedTimestampMessage(timestamp: timestamp), + ] + XCTAssertEqual(endpoint.header, expectedHeader) + + let expectedBody = + "{\"challengeId\":\"f56aaf67-acc1-4397-ae6b-57b553bdc5b0\",\"ip\":\"ipString\",\"userId\":\"cecaea44-47f2-439b-99a1-a35fefaf1eb6\",\"verificationCode\":\"123456\"}" + XCTAssertEqual(endpoint.body, expectedBody) + } + + func testExchangeRatesEndpoint() async throws { + let keyPair = try KeyPair() + let timestamp = NSDate() + + let endpoint = try StrigaEndpoint.exchangeRates( + baseURL: baseURL, + keyPair: keyPair, + timestamp: timestamp + ) + + XCTAssertEqual(endpoint.urlString, "https://example.com/striga/api/v1/trade/rates") + XCTAssertEqual(endpoint.method, .post) + + let expectedHeader = try [ + "Content-Type": "application/json", + "User-PublicKey": keyPair.publicKey.base58EncodedString, + "Signed-Message": keyPair.getSignedTimestampMessage(timestamp: timestamp), + ] + XCTAssertEqual(endpoint.header, expectedHeader) + XCTAssertEqual(endpoint.body, nil) + } +} diff --git a/Packages/KeyAppKit/Tests/UnitTests/BankTransferTests/StrigaProviderTests.swift b/Packages/KeyAppKit/Tests/UnitTests/BankTransferTests/StrigaProviderTests.swift new file mode 100644 index 0000000000..20840703fb --- /dev/null +++ b/Packages/KeyAppKit/Tests/UnitTests/BankTransferTests/StrigaProviderTests.swift @@ -0,0 +1,149 @@ +//// +//// StrigaProviderTests.swift +//// +//// +//// Created by Ivan on 24.05.2023. +//// +// +// import Foundation +// import XCTest +// +// @testable import BankTransfer +// @testable import KeyAppNetworking +// +// final class StrigaProviderTests: XCTestCase { +// +// var strigaProvider: StrigaRemoteProviderImpl! +// var httpClient: MockHTTPClient! +// +// override func setUpWithError() throws { +// try super.setUpWithError() +// +// httpClient = .init() +// strigaProvider = .init(httpClient: httpClient) +// } +// +// override func tearDownWithError() throws { +// strigaProvider = nil +// httpClient = nil +// +// try super.tearDownWithError() +// } +// +// @MainActor +// func testGetUserDetailsSuccess() async throws { +// +// // then +// let userId = "123" +// httpClient.stubbedRequestResult = StrigaUserDetailsResponse.fake() +// +// // when +// _ = try await strigaProvider.getUserDetails(authHeader: .init(pubKey: "", signedMessage: ""), userId: userId) +// +// // then +// let endpoint = httpClient.invokedRequestParameters!.endpoint! +// XCTAssertEqual(endpoint.baseURL, "https://payment.keyapp.org/striga/api/v1/user/") +// } +// +// @MainActor +// func testCreateUserEndpointSuccess() async throws { +// +// // then +// httpClient.stubbedRequestResult = CreateUserResponse(userId: "", email: "", KYC: .init(status: "")) +// +// // when +// _ = try await strigaProvider.createUser(authHeader: .init(pubKey: "", signedMessage: ""), model: .fake()) +// +// // then +// let endpoint = httpClient.invokedRequestParameters!.endpoint! +// let model = endpoint.body! +// XCTAssertEqual(endpoint.baseURL, "https://payment.keyapp.org/striga/api/v1/user/") +// XCTAssertEqual(model, "{\n \"address\" : {\n \"addressLine1\" : \"addressLine1\",\n \"addressLine2\" : +// \"addressLine2\",\n \"city\" : \"city\",\n \"country\" : \"country\",\n \"postalCode\" : +// \"postalCode\",\n \"state\" : \"state\"\n },\n \"dateOfBirth\" : {\n \"day\" : 24,\n \"month\" : +// 5,\n \"year\" : 2023\n },\n \"email\" : \"email\",\n \"expectedIncomingTxVolumeYearly\" : +// \"expectedIncomingTxVolumeYearly\",\n \"expectedOutgoingTxVolumeYearly\" : +// \"expectedOutgoingTxVolumeYearly\",\n \"firstName\" : \"firstName\",\n \"ipAddress\" : \"ipAddress\",\n +// \"lastName\" : \"lastName\",\n \"mobile\" : {\n \"countryCode\" : \"countryCode\",\n \"number\" : +// \"number\"\n },\n \"occupation\" : \"occupation\",\n \"placeOfBirth\" : \"placeOfBirth\",\n +// \"purposeOfAccount\" : \"purposeOfAccount\",\n \"selfPepDeclaration\" : false,\n \"sourceOfFunds\" : +// \"sourceOfFunds\"\n}") +// } +// +// @MainActor +// func testVerifyMobileNumberSuccess() async throws { +// +// // then +// httpClient.stubbedRequestResult = "" +// +// // when +// _ = try await strigaProvider.verifyMobileNumber( +// authHeader: .init(pubKey: "", signedMessage: ""), +// userId: "userId", +// verificationCode: "verificationCode" +// ) +// +// // then +// let endpoint = httpClient.invokedRequestParameters!.endpoint! +// let model = endpoint.body! +// debugPrint("---model: ", model) +// XCTAssertEqual(endpoint.baseURL, "https://payment.keyapp.org/striga/api/v1/user/") +// XCTAssertEqual(model, "{\n \"userId\" : \"userId\",\n \"verificationCode\" : \"verificationCode\"\n}") +// } +// } +// +//// MARK: - Fakes +// +// private extension StrigaCreateUserRequest { +// static func fake() -> StrigaCreateUserRequest { +// StrigaCreateUserRequest( +// firstName: "firstName", +// lastName: "lastName", +// email: "email", +// mobile: .init(countryCode: "countryCode", number: "number"), +// dateOfBirth: .init(year: 2023, month: 5, day: 24), +// address: .init( +// addressLine1: "addressLine1", +// addressLine2: "addressLine2", +// city: "city", +// postalCode: "postalCode", +// state: "state", +// country: "country" +// ), +// occupation: "occupation", +// sourceOfFunds: "sourceOfFunds", +// ipAddress: "ipAddress", +// placeOfBirth: "placeOfBirth", +// expectedIncomingTxVolumeYearly: "expectedIncomingTxVolumeYearly", +// expectedOutgoingTxVolumeYearly: "expectedOutgoingTxVolumeYearly", +// selfPepDeclaration: false, +// purposeOfAccount: "purposeOfAccount" +// ) +// } +// } +// +// private extension StrigaUserDetailsResponse { +// static func fake() -> StrigaUserDetailsResponse { +// StrigaUserDetailsResponse( +// firstName: "firstName", +// lastName: "lastName", +// email: "email", +// mobile: .init( +// countryCode: "countryCode", +// number: "number" +// ), +// dateOfBirth: .init(year: 2023, month: 5, day: 24), +// address: .init( +// addressLine1: "12 Boo str", +// addressLine2: nil, +// city: "Ho Chi Minh city", +// postalCode: "128943", +// state: "Ho Chi Minh", +// country: "Vietnam" +// ), +// occupation: "occupation", +// sourceOfFunds: "sourceOfFunds", +// placeOfBirth: "placeOfBirth" +// ) +// } +// } diff --git a/Packages/KeyAppKit/Tests/UnitTests/BankTransferTests/StrigaRemoteProviderTests.swift b/Packages/KeyAppKit/Tests/UnitTests/BankTransferTests/StrigaRemoteProviderTests.swift new file mode 100644 index 0000000000..bcf4ba8532 --- /dev/null +++ b/Packages/KeyAppKit/Tests/UnitTests/BankTransferTests/StrigaRemoteProviderTests.swift @@ -0,0 +1,618 @@ +import Foundation +import KeyAppNetworking +import SolanaSwift +import TweetNacl +import XCTest +@testable import BankTransfer + +final class StrigaRemoteProviderTests: XCTestCase { + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + // MARK: - GetKYCStatus + + func testGetKYCStatus_SuccessfulResponse_ReturnsStrigaKYC() async throws { + // Arrange + let mockData = + #"{"userId":"9fd9f525-cb24-4682-8c5a-aa5c2b7e4dde","emailVerified":false,"mobileVerified":false,"status":"NOT_STARTED"}"# + let provider = try getMockProvider(responseString: mockData, statusCode: 200) + + // Act + let kycStatus = try await provider.getKYCStatus(userId: "123") + + // Assert + XCTAssertEqual(kycStatus.mobileVerified, false) + XCTAssertEqual(kycStatus.status, .notStarted) + } + + // MARK: - GetUserDetails + + func testGetUserDetails_SuccessfulResponse_ReturnsStrigaUserDetailsResponse() async throws { + // Arrange + let mockData = + #"{"firstName":"Claudia","lastName":"Tracy Lind","email":"test_1655832993@mailinator.com","documentIssuingCountry":"PS","nationality":"RS","mobile":{"countryCode":"+372","number":"56316716"},"dateOfBirth":{"month":"1","day":"15","year":"2000"},"address":{"addressLine1":"Sepapaja 12","addressLine2":"Hajumaa","city":"Tallinn","state":"Tallinn","country":"EE","postalCode":"11412"},"occupation":"PRECIOUS_GOODS_JEWELRY","sourceOfFunds":"CIVIL_CONTRACT","purposeOfAccount":"CRYPTO_PAYMENTS","selfPepDeclaration":true,"placeOfBirth":"Antoniettamouth","expectedIncomingTxVolumeYearly":"MORE_THAN_15000_EUR","expectedOutgoingTxVolumeYearly":"MORE_THAN_15000_EUR","KYC":{"emailVerified":true,"mobileVerified":true,"status":"REJECTED","details":["UNSATISFACTORY_PHOTOS","SCREENSHOTS","PROBLEMATIC_APPLICANT_DATA"],"rejectionComments":{"userComment":"The full name on the profile is either missing or incorrect.","autoComment":"Please enter your first and last name exactly as they are written in your identity document."}},"userId":"9fd9f525-cb24-4682-8c5a-aa5c2b7e4dde","createdAt":1655832993460}"# + let provider = try getMockProvider(responseString: mockData, statusCode: 200) + + // Act + let userDetails = try await provider.getUserDetails(userId: "123") + + // Assert +// XCTAssertEqual(userDetails.userId, "9fd9f525-cb24-4682-8c5a-aa5c2b7e4dde") + XCTAssertEqual(userDetails.firstName, "Claudia") + XCTAssertEqual(userDetails.lastName, "Tracy Lind") + XCTAssertEqual(userDetails.email, "test_1655832993@mailinator.com") +// XCTAssertEqual(userDetails.documentIssuingCountry, "PS") +// XCTAssertEqual(userDetails.nationality, "RS") + XCTAssertEqual(userDetails.mobile.countryCode, "+372") + XCTAssertEqual(userDetails.mobile.number, "56316716") + XCTAssertEqual(userDetails.dateOfBirth?.month, "1") + XCTAssertEqual(userDetails.dateOfBirth?.day, "15") + XCTAssertEqual(userDetails.dateOfBirth?.year, "2000") + XCTAssertEqual(userDetails.address?.addressLine1, "Sepapaja 12") + XCTAssertEqual(userDetails.address?.addressLine2, "Hajumaa") + XCTAssertEqual(userDetails.address?.city, "Tallinn") + XCTAssertEqual(userDetails.address?.state, "Tallinn") + XCTAssertEqual(userDetails.address?.country, "EE") + XCTAssertEqual(userDetails.address?.postalCode, "11412") + XCTAssertEqual(userDetails.occupation, .preciousGoodsJewelry) + XCTAssertEqual(userDetails.sourceOfFunds, .civilContract) +// XCTAssertEqual(userDetails.purposeOfAccount, "OTHER") +// XCTAssertTrue(userDetails.selfPepDeclaration) + XCTAssertEqual(userDetails.placeOfBirth, "Antoniettamouth") +// XCTAssertEqual(userDetails.expectedIncomingTxVolumeYearly, "MORE_THAN_15000_EUR") +// XCTAssertEqual(userDetails.expectedOutgoingTxVolumeYearly, "MORE_THAN_15000_EUR") +// XCTAssertTrue(userDetails.KYC.emailVerified ?? false) + XCTAssertTrue(userDetails.KYC.mobileVerified) + XCTAssertEqual(userDetails.KYC.status, .rejected) +// XCTAssertEqual(userDetails.KYC.details, ["UNSATISFACTORY_PHOTOS", "SCREENSHOTS", +// "PROBLEMATIC_APPLICANT_DATA"]) +// XCTAssertEqual(userDetails.KYC?.rejectionComments?.userComment, "The full name on the profile is either +// missing or incorrect.") +// XCTAssertEqual(userDetails.KYC?.rejectionComments?.autoComment, "Please enter your first and last name exactly +// as they are written in your identity document.") +// XCTAssertEqual(userDetails.createdAt, 1655832993460) + } + + // MARK: - Create User + + func testCreateUser_SuccessfulResponse_ReturnsCreateUserResponse() async throws { + // Arrange + let mockData = + #"{"userId":"de13f7b0-c159-4955-a226-42ca2e4f0b76","email":"test_1652858341@mailinator.com","KYC":{"status":"NOT_STARTED"}}"# + let provider = try getMockProvider(responseString: mockData, statusCode: 200) + + // Act + let response = try await provider.createUser(model: StrigaCreateUserRequest( + firstName: "John", + lastName: "Doe", + email: "johndoe@example.com", + mobile: StrigaCreateUserRequest.Mobile(countryCode: "+1", number: "1234567890"), + dateOfBirth: StrigaCreateUserRequest.DateOfBirth(year: "1990", month: "01", day: "01"), + address: StrigaCreateUserRequest.Address( + addressLine1: "123 Main St", + addressLine2: "Apt 4B", + city: "New York", + postalCode: "10001", + state: "NY", + country: "US" + ), + occupation: .artEntertainment, + sourceOfFunds: .civilContract, + ipAddress: "127.0.0.1", + placeOfBirth: "New York", + expectedIncomingTxVolumeYearly: "MORE_THAN_50000_USD", + expectedOutgoingTxVolumeYearly: "MORE_THAN_50000_USD", + selfPepDeclaration: true, + purposeOfAccount: "Personal Savings" + )) + + // Assert + XCTAssertEqual(response.userId, "de13f7b0-c159-4955-a226-42ca2e4f0b76") + XCTAssertEqual(response.email, "test_1652858341@mailinator.com") + XCTAssertEqual(response.KYC.status, .notStarted) + } + + // MARK: - Verify phone number + + func testVerifyPhoneNumber_SuccessfulResponse_ReturnsAccepted() async throws { + // Arrange + let mockData = #"Accepted"# + let provider = try getMockProvider(responseString: mockData, statusCode: 200) + + // Act + try await provider.verifyMobileNumber(userId: "123", verificationCode: "123456") + + // Assert + XCTAssertTrue(true) + } + + func testVerifyPhoneNumber_EmptyResponse400_ReturnsInvalidResponse() async throws { + // Arrange + let mockData = #"{}"# + let provider = try getMockProvider(responseString: mockData, statusCode: 409) + + // Act + do { + try await provider.verifyMobileNumber(userId: "123", verificationCode: "123456") + XCTFail() + } catch HTTPClientError.invalidResponse(_, _) { + XCTAssertTrue(true) + } catch { + XCTFail() + } + } + + func testVerifyPhoneNumber_ErrorDetail409_ReturnsOtpExceededVerification() async throws { + // Arrange + let mockData = + #"{"status":409,"errorCode":"30003","errorDetails":{"message":"Exceeded verification attempts"}}"# + let provider = try getMockProvider(responseString: mockData, statusCode: 409) + + // Act + do { + try await provider.verifyMobileNumber(userId: "123", verificationCode: "123456") + XCTFail() + } catch BankTransferError.otpExceededVerification { + XCTAssertTrue(true) + } catch { + XCTFail() + } + } + + // MARK: - Resend SMS + + func testResendSMS_SuccessfulResponse_ReturnsAccepted() async throws { + // Arrange + let mockData = #"{"dateExpires":"2023-06-20T12:14:14.981Z"}"# + let provider = try getMockProvider(responseString: mockData, statusCode: 200) + + // Act + try await provider.resendSMS(userId: "123") + + // Assert + XCTAssertTrue(true) + } + + func testResendSMS_ErrorDetail409_ReturnsInvalidResponse() async throws { + // Arrange + let mockData = + #"{"message":"Mobile is already verified","errorCode":"00002","errorDetails":{"message":"Mobile is already verified","errorDetails":"885d9dd3-56d1-416b-a85b-873fcec69071"}}"# + let provider = try getMockProvider(responseString: mockData, statusCode: 409) + + // Act + do { + try await provider.resendSMS(userId: "123") + XCTFail() + } catch BankTransferError.mobileAlreadyVerified { + XCTAssertTrue(true) + } catch { + XCTFail() + } + } + + // MARK: - GetKYCToken + + func testGetKYCToken_SuccessfulResponse_ReturnsToken() async throws { + // Arrange + let mockData = + #"{"provider":"SUMSUB","token":"_act-sbx-cc6a85f3-4315-4d26-b507-3e5ea31ff2f9","userId":"2f1853b2-927a-4aa9-8bb1-3e51fb119ace","verificationLink":"https://in.sumsub.com/idensic/l/#/sbx_Eke06K3fpzlbWuf3"}"# + let provider = try getMockProvider(responseString: mockData, statusCode: 200) + + // Act + let token = try await provider.getKYCToken(userId: "123") + + // Assert + XCTAssertEqual(token, "_act-sbx-cc6a85f3-4315-4d26-b507-3e5ea31ff2f9") + } + + func testGetKYCToken_InvalidFields400_ReturnsInvalidResponse() async throws { + // Arrange + let mockData = + #"{"status":400,"errorCode":"00002","errorDetails":{"message":"Invalid fields","errorDetails":[{"msg":"Invalid value","param":"mobile.number","location":"body"}]}}"# + let provider = try getMockProvider(responseString: mockData, statusCode: 400) + + // Act + do { + let _ = try await provider.getKYCToken(userId: "123") + XCTFail() + } catch HTTPClientError.invalidResponse(_, _) { + XCTAssertTrue(true) + } catch { + XCTFail() + } + } + + func testGetKYCToken_UserNotVerified409_ReturnsInvalidResponse() async throws { + // Arrange + let mockData = #"{"status":409,"errorCode":"30007","errorDetails":{"message":"User is not verified"}}"# + let provider = try getMockProvider(responseString: mockData, statusCode: 409) + + // Act + do { + let _ = try await provider.getKYCToken(userId: "123") + XCTFail() + } catch HTTPClientError.invalidResponse(_, _) { + XCTAssertTrue(true) + } catch { + XCTFail() + } + } + + // MARK: - GetAllUserWallets + + func testGetAllWalletsByUser_SuccessfulResponse_ReturnsStrigaGetAllWalletsResponse() async throws { + // Arrange + let mockData = + #"{"wallets":[{"walletId":"3d57a943-8145-4183-8079-cd86b68d2993","accounts":{"EUR":{"accountId":"4dc6ecb29d74198e9e507f8025cad011","parentWalletId":"719c6236-0420-43c1-a7cb-1e20ce540a8d","currency":"EUR","ownerId":"80efa80b-2b53-4b80-be88-4fcaa7d3a540","ownerType":"CONSUMER","createdAt":"2023-06-26T14:06:00.673Z","availableBalance":{"amount":"0","currency":"cents"},"linkedCardId":"UNLINKED","linkedBankAccountId":"EUR13467658780233","status":"ACTIVE","permissions":["CUSTODY","TRADE","INTER","INTRA"],"enriched":true},"USDC":{"accountId":"140ecf6f979975c8e868d14038004b37","parentWalletId":"3d57a943-8145-4183-8079-cd86b68d2993","currency":"USDC","ownerId":"aa3534a1-d13d-4920-b023-97cb00d49bad","ownerType":"CONSUMER","createdAt":"2023-05-28T19:47:17.078Z","availableBalance":{"amount":"5889","currency":"cents"},"linkedCardId":"UNLINKED","blockchainDepositAddress":"0xF13607D9Ab2D98f6734Dc09e4CDE7dA515fe329c","blockchainNetwork":{"name":"USD Coin Test (Goerli)","type":"ERC20","contractAddress":"0x07865c6E87B9F70255377e024ace6630C1Eaa37F"},"status":"ACTIVE","permissions":["CUSTODY","TRADE","INTER","INTRA"],"enriched":true}},"syncedOwnerId":"80efa80b-2b53-4b80-be88-4fcaa7d3a540","ownerType":"CONSUMER","createdAt":"2023-06-26T14:06:00.693Z","comment":"DEFAULT"}],"count":1,"total":1}"# + let provider = try getMockProvider(responseString: mockData, statusCode: 200) + + // Act + let response = try await provider.getAllWalletsByUser( + userId: "123", + startDate: Date(), + endDate: Date(), + page: 1 + ) + + // Assert the count and total values + XCTAssertEqual(response.count, 1) + XCTAssertEqual(response.total, 1) + + // Assert the wallets array + XCTAssertEqual(response.wallets.count, 1) + + // Assert the first wallet + let wallet = response.wallets[0] + XCTAssertEqual(wallet.walletID, "3d57a943-8145-4183-8079-cd86b68d2993") + + // Assert the accounts within the wallet + let accounts = wallet.accounts + + // Assert the EUR account + let eurAccount = accounts.eur + XCTAssertEqual(eurAccount?.accountID, "4dc6ecb29d74198e9e507f8025cad011") + XCTAssertEqual(eurAccount?.currency, "EUR") + // ... continue asserting other properties of the EUR account + + // Assert the USDC account + let usdcAccount = accounts.usdc + XCTAssertEqual(usdcAccount?.accountID, "140ecf6f979975c8e868d14038004b37") + XCTAssertEqual(usdcAccount?.currency, "USDC") + XCTAssertEqual(usdcAccount?.parentWalletID, "3d57a943-8145-4183-8079-cd86b68d2993") + XCTAssertEqual(usdcAccount?.ownerID, "aa3534a1-d13d-4920-b023-97cb00d49bad") + XCTAssertEqual(usdcAccount?.ownerType, "CONSUMER") + XCTAssertEqual(usdcAccount?.createdAt, "2023-05-28T19:47:17.078Z") + + // Assert the available balance of USDC account + let usdcAvailableBalance = usdcAccount?.availableBalance + XCTAssertEqual(usdcAvailableBalance?.amount, "5889") + XCTAssertEqual(usdcAvailableBalance?.currency, "cents") + + // Assert the linked card ID of USDC account + XCTAssertEqual(usdcAccount?.linkedCardID, "UNLINKED") + + // Assert the blockchain deposit address of USDC account + XCTAssertEqual(usdcAccount?.blockchainDepositAddress, "0xF13607D9Ab2D98f6734Dc09e4CDE7dA515fe329c") + + // Assert the blockchain network details of USDC account + let usdcBlockchainNetwork = usdcAccount?.blockchainNetwork + XCTAssertEqual(usdcBlockchainNetwork?.name, "USD Coin Test (Goerli)") + XCTAssertEqual(usdcBlockchainNetwork?.type, "ERC20") + XCTAssertEqual(usdcBlockchainNetwork?.contractAddress, "0x07865c6E87B9F70255377e024ace6630C1Eaa37F") + + // Assert the status of USDC account + XCTAssertEqual(usdcAccount?.status, "ACTIVE") + + // Assert the permissions of USDC account + let usdcPermissions = usdcAccount?.permissions + XCTAssertEqual(usdcPermissions, ["CUSTODY", "TRADE", "INTER", "INTRA"]) + + // Assert the enriched property of USDC account + XCTAssertTrue(usdcAccount?.enriched ?? false) + } + + // MARK: - Enrich account + + func testEnrichAccount_SuccessfulResponse_ReturnsEnrichedAccount() async throws { + // Arrange + let mockData = + #"{"blockchainDepositAddress":"0x59d42C04022E926DAF16d139aFCBCa0da33E2323","blockchainNetwork":{"name":"Binance USD (BSC Test)","type":"BEP20","contractAddress":"0xeD24FC36d5Ee211Ea25A80239Fb8C4Cfd80f12Ee"}}"# + let provider = try getMockProvider(responseString: mockData, statusCode: 200) + + // Act + let enrichedAccount: StrigaEnrichedUSDCAccountResponse = try await provider.enrichAccount( + userId: "123", + accountId: "456" + ) + + // Assert + XCTAssertEqual(enrichedAccount.blockchainDepositAddress, "0x59d42C04022E926DAF16d139aFCBCa0da33E2323") + XCTAssertEqual(enrichedAccount.blockchainNetwork.name, "Binance USD (BSC Test)") + XCTAssertEqual(enrichedAccount.blockchainNetwork.type, "BEP20") + XCTAssertEqual(enrichedAccount.blockchainNetwork.contractAddress, "0xeD24FC36d5Ee211Ea25A80239Fb8C4Cfd80f12Ee") + } + + func testInitiateOnChainWalletSend_SuccessfulResponse() async throws { + // Arrange + let mockData = """ + { + "challengeId": "eaec4a27-d78d-4f49-80bf-9c1ecba98853", + "dateExpires": "2023-03-30T05:21:47.402Z", + "transaction": { + "syncedOwnerId": "51a2ed48-3b70-4775-b549-0d7e4850b64d", + "sourceAccountId": "9c73b2f8a7c4e567c0460ef83c309ce1", + "parentWalletId": "2c24c517-c682-4472-bbde-627e4a26fcf8", + "currency": "ETH", + "amount": "10000000000000000", + "status": "PENDING_2FA_CONFIRMATION", + "txType": "ON_CHAIN_WITHDRAWAL_INITIATED", + "blockchainDestinationAddress": "0x6475C4E02248E463fDBbF2D3fB436aFCa9c56DbD", + "blockchainNetwork": { + "name": "Ethereum Test (Goerli)" + }, + "transactionCurrency": "ETH" + }, + "feeEstimate": { + "totalFee": "948640405755000", + "networkFee": "948640405755000", + "ourFee": "948640405755000", + "theirFee": "0", + "feeCurrency": "ETH", + "gasLimit": "21000", + "gasPrice": "21.044" + } + } + """ + let provider = try getMockProvider(responseString: mockData, statusCode: 200) + + // Act + let decodedData = try await provider.initiateOnChainWalletSend( + userId: "123", + sourceAccountId: "456", + whitelistedAddressId: "789", + amount: "100", + accountCreation: true + ) + + // Assert + XCTAssertEqual(decodedData.challengeId, "eaec4a27-d78d-4f49-80bf-9c1ecba98853") + XCTAssertEqual(decodedData.dateExpires, "2023-03-30T05:21:47.402Z") + + XCTAssertEqual(decodedData.transaction.syncedOwnerId, "51a2ed48-3b70-4775-b549-0d7e4850b64d") + XCTAssertEqual(decodedData.transaction.sourceAccountId, "9c73b2f8a7c4e567c0460ef83c309ce1") + XCTAssertEqual(decodedData.transaction.parentWalletId, "2c24c517-c682-4472-bbde-627e4a26fcf8") + XCTAssertEqual(decodedData.transaction.currency, "ETH") + XCTAssertEqual(decodedData.transaction.amount, "10000000000000000") + XCTAssertEqual(decodedData.transaction.status, "PENDING_2FA_CONFIRMATION") + XCTAssertEqual(decodedData.transaction.txType, .initiated) + XCTAssertEqual( + decodedData.transaction.blockchainDestinationAddress, + "0x6475C4E02248E463fDBbF2D3fB436aFCa9c56DbD" + ) + XCTAssertEqual(decodedData.transaction.blockchainNetwork.name, "Ethereum Test (Goerli)") + XCTAssertEqual(decodedData.transaction.transactionCurrency, "ETH") + + XCTAssertEqual(decodedData.feeEstimate.totalFee, "948640405755000") + XCTAssertEqual(decodedData.feeEstimate.networkFee, "948640405755000") + XCTAssertEqual(decodedData.feeEstimate.ourFee, "948640405755000") + XCTAssertEqual(decodedData.feeEstimate.theirFee, "0") + XCTAssertEqual(decodedData.feeEstimate.feeCurrency, "ETH") + XCTAssertEqual(decodedData.feeEstimate.gasLimit, "21000") + XCTAssertEqual(decodedData.feeEstimate.gasPrice, "21.044") + } + + func testTransactionResendOTP_SuccessfulResponse() async throws { + // Arrange + let mockData = """ + { + "challengeId": "f56aaf67-acc1-4397-ae6b-57b553bdc5b0", + "dateExpires": "2022-11-10T14:17:28.162Z", + "attempts": 1 + } + """ + let provider = try getMockProvider(responseString: mockData, statusCode: 200) + + // Act + let result = try await provider.transactionResendOTP( + userId: "cecaea44-47f2-439b-99a1-a35fefaf1eb6", + challengeId: "f56aaf67-acc1-4397-ae6b-57b553bdc5b0" + ) + + // Assert + XCTAssertEqual(result.challengeId, "f56aaf67-acc1-4397-ae6b-57b553bdc5b0") + XCTAssertEqual(result.dateExpires, "2022-11-10T14:17:28.162Z") + XCTAssertEqual(result.attempts, 1) + } + + func testProcessTransaction_SuccessfulResponse() async throws { + // Arrange + let mockData = """ + { + "id": "e0a00ba5-6788-41c6-95f1-258b49b406a7", + "amount": "50", + "feeSats": "1", + "invoice": "lntb500n1p3k6pj7pp5axdfq2mprvc9csgkgp0fhm3magqw8dp4f64gncwwpjlae2ke8zmqdqqcqzpgxqyz5vqsp5ey8e6twhw0rqnqer8ycsvx4mgy56nalxzqm08gwymj2sxa5s8qcq9qyyssqstksm7hq62vfkqrsv6vh283npc2c597l6mmvjplk84h3dmv5qzh4lusetk3v4pdfr4tcfj3ezf87sakhr9cc6eq8l238uev5mxdhv2gpds0fn3", + "payeeNode": "020ec0c6a0c4fe5d8a79928ead294c36234a76f6e0dca896c35413612a3fd8dbf8", + "network": { + "bech32": "tb", + "pubKeyHash": 111, + "scriptHash": 196, + "validWitnessVersions": [ + 0, + 1 + ] + } + } + """ + let provider = try getMockProvider(responseString: mockData, statusCode: 200) + + // Act + let result = try await provider.transactionConfirmOTP( + userId: "cecaea44-47f2-439b-99a1-a35fefaf1eb6", + challengeId: "123", + code: "123456", + ip: "ipString" + ) + + // Assert + XCTAssertEqual(result.id, "e0a00ba5-6788-41c6-95f1-258b49b406a7") + XCTAssertEqual(result.amount, "50") + XCTAssertEqual(result.feeSats, "1") + XCTAssertEqual( + result.invoice, + "lntb500n1p3k6pj7pp5axdfq2mprvc9csgkgp0fhm3magqw8dp4f64gncwwpjlae2ke8zmqdqqcqzpgxqyz5vqsp5ey8e6twhw0rqnqer8ycsvx4mgy56nalxzqm08gwymj2sxa5s8qcq9qyyssqstksm7hq62vfkqrsv6vh283npc2c597l6mmvjplk84h3dmv5qzh4lusetk3v4pdfr4tcfj3ezf87sakhr9cc6eq8l238uev5mxdhv2gpds0fn3" + ) + XCTAssertEqual(result.payeeNode, "020ec0c6a0c4fe5d8a79928ead294c36234a76f6e0dca896c35413612a3fd8dbf8") + XCTAssertEqual(result.network.bech32, "tb") + XCTAssertEqual(result.network.pubKeyHash, 111) + XCTAssertEqual(result.network.scriptHash, 196) + XCTAssertEqual(result.network.validWitnessVersions, [0, 1]) + } + + func testInitiateOnchainFeeEstimate_SuccessfulResponse() async throws { + // Arrange + let mockData = """ + { + "totalFee": "909237719334000", + "networkFee": "909237719334000", + "ourFee": "909237719334000", + "theirFee": "0", + "feeCurrency": "ETH", + "gasLimit": "21000", + "gasPrice": "18.313" + } + """ + let provider = try getMockProvider(responseString: mockData, statusCode: 200) + + let result = try await provider.initiateOnchainFeeEstimate( + userId: "65367b0e-d569-44ad-bcb2-e004c9cd3646", + sourceAccountId: "73e3aa3714c4488b6205b0f93fbbbd6f", + whitelistedAddressId: "bed66a98-e36a-41e9-a478-3c6bf277d0d5", + amount: "1210189000000000" + ) + + // Assert + XCTAssertEqual(result.totalFee, "909237719334000") + XCTAssertEqual(result.networkFee, "909237719334000") + XCTAssertEqual(result.ourFee, "909237719334000") + XCTAssertEqual(result.theirFee, "0") + XCTAssertEqual(result.feeCurrency, "ETH") + XCTAssertEqual(result.gasLimit, "21000") + XCTAssertEqual(result.gasPrice, "18.313") + } + + func testExchangeRates_SuccessfulResponse() async throws { + // Arrange + let mockData = """ + {"ETHEUR":{"price":"1693","buy":"1701.47","sell":"1684.53","timestamp":1689670715581,"currency":"Euros"},"USDCEUR":{"price":"0.9","buy":"0.9","sell":"0.88","timestamp":1689670714000,"currency":"Euros"},"USDCUSDT":{"price":"1","buy":"1.01","sell":"0.99","timestamp":1689670714000,"currency":"Tether"},"USDTEUR":{"price":"0.9","buy":"0.9","sell":"0.88","timestamp":1689670717071,"currency":"Euros"},"BTCEUR":{"price":"26729","buy":"26862.65","sell":"26595.35","timestamp":1689670717094,"currency":"Euros"},"BTCUSDC":{"price":"30026.55","buy":"30176.69","sell":"29876.41","timestamp":1689670714000,"currency":"USD Coin"},"BTCUSDT":{"price":"30017.27","buy":"30167.36","sell":"29867.18","timestamp":1689670717006,"currency":"Tether"},"BUSDEUR":{"price":"1.13","buy":"1.13","sell":"1.11","timestamp":1689670710865,"currency":"Binance USD"},"BNBEUR":{"price":"215.9","buy":"216.98","sell":"214.82","timestamp":1689670709965,"currency":"Euros"},"LINKBUSD":{"price":"7.06","buy":"7.1","sell":"7.02","timestamp":1689670716666,"currency":"Binance USD"},"MATICBUSD":{"price":"0.76","buy":"0.76","sell":"0.75","timestamp":1689670715736,"currency":"Binance USD"},"SUSHIBUSD":{"price":"0.74","buy":"0.75","sell":"0.73","timestamp":1689670715694,"currency":"Binance USD"},"UNIBUSD":{"price":"6.16","buy":"6.19","sell":"6.12","timestamp":1689670714011,"currency":"Binance USD"},"1INCHBUSD":{"price":"0.38","buy":"0.39","sell":"0.37","timestamp":1689670716518,"currency":"Binance USD"}} + """ + let provider = try getMockProvider(responseString: mockData, statusCode: 200) + + let result = try await provider.exchangeRates() + + // Assert + XCTAssertNotNil(result["USDCEUR"]) + XCTAssertEqual(result["USDCEUR"]?.price, "0.9") + XCTAssertEqual(result["USDCEUR"]?.buy, "0.9") + XCTAssertEqual(result["USDCEUR"]?.sell, "0.88") + XCTAssertEqual(result.isEmpty, false) + } + + func testExchangeRates_FailedResponse() async throws { + // Arrange + var result: StrigaExchangeRatesResponse? + let mockData = "" + do { + let provider = try getMockProvider( + responseString: mockData, + statusCode: 0, + error: NSError(domain: "", code: NSURLErrorTimedOut) + ) + result = try await provider.exchangeRates() + } catch { + // Assert + XCTAssertNil(result) + XCTAssertEqual((error as NSError).code, NSURLErrorTimedOut) + } + } + + func testGetAccountStatement_SuccessfulEmptyResponse() async throws { + // Arrange + let mockData = """ + {"transactions":[],"count":0,"total":0} + """ + let provider = try getMockProvider(responseString: mockData, statusCode: 200) + + let result = try await provider.getAccountStatement( + userId: "cecaea44-47f2-439b-99a1-a35fefaf1eb6", + accountId: "4dc6ecb29d74198e9e507f8025cad011", + startDate: Date(timeIntervalSince1970: 1_687_564_800), + endDate: Date(), + page: 1 + ) + + // Assert + XCTAssertEqual(result.transactions.isEmpty, true) + } + + func testGetAccountStatement_SuccessfulResponse() async throws { + // Arrange + let mockData = """ + {"transactions":[{"id":"af9d7fa5-4676-4190-ada1-06615c018f99","accountId":"d23cd18146112c1547be09a11ec2b7fb","syncedOwnerId":"54dd6616-f959-41b6-9d96-6d9fddaa3473","sourceSyncedOwnerId":"54dd6616-f959-41b6-9d96-6d9fddaa3473","destinationSyncedOwnerId":"54dd6616-f959-41b6-9d96-6d9fddaa3473","debit":"5000","timestamp":"2023-07-20T13:54:36.826Z","txType":"EXCHANGE_DEBIT","txSubType":"CURRENCY_EXCHANGE_PENDING","memo":"Swap 5000 EUR to USDC","memoPayer":"Simulate Payin","exchangeRate":"1","balanceBefore":{"amount":"5000","currency":"cents"},"balanceAfter":{"amount":"0","currency":"cents"}},{"id":"a25e0dd1-8f4f-441d-a671-2f7d1e9738e6","accountId":"d23cd18146112c1547be09a11ec2b7fb","syncedOwnerId":"54dd6616-f959-41b6-9d96-6d9fddaa3473","sourceSyncedOwnerId":"54dd6616-f959-41b6-9d96-6d9fddaa3473","credit":"5000","timestamp":"2023-07-20T13:54:36.321Z","txType":"SEPA_PAYIN_COMPLETED","memo":"Simulate Payin","exchangeRate":"1","balanceBefore":{"amount":"0","currency":"cents"},"balanceAfter":{"amount":"5000","currency":"cents"},"bankingTransactionId":"a25e0dd1-8f4f-441d-a671-2f7d1e9738e6","bankingTransactionShortId":"20230720-MQ3R2H","bankingSenderBic":"BUKBGB22","bankingSenderIban":"GB29NWBK60161331926819","bankingSenderName":"Boris Johnson","bankingPaymentType":"SEPA","bankingSenderInformation":null,"bankingSenderRoutingCodes":[],"bankingSenderAccountNumber":null,"bankingTransactionDateTime":"2023-07-20T13:54:35.904836","bankingTransactionReference":"Simulate Payin"},{"id":"7c2075cb-7037-45f2-aeb6-dc1a61334f1a","accountId":"d23cd18146112c1547be09a11ec2b7fb","syncedOwnerId":"54dd6616-f959-41b6-9d96-6d9fddaa3473","sourceSyncedOwnerId":"54dd6616-f959-41b6-9d96-6d9fddaa3473","destinationSyncedOwnerId":"54dd6616-f959-41b6-9d96-6d9fddaa3473","debit":"1000","timestamp":"2023-07-20T13:54:21.603Z","txType":"EXCHANGE_DEBIT","txSubType":"CURRENCY_EXCHANGE_PENDING","memo":"Swap 1000 EUR to USDC","memoPayer":"Simulate Payin","exchangeRate":"1","balanceBefore":{"amount":"1000","currency":"cents"},"balanceAfter":{"amount":"0","currency":"cents"}},{"id":"5c29131d-186e-47ad-a9c2-252368fc88ea","accountId":"d23cd18146112c1547be09a11ec2b7fb","syncedOwnerId":"54dd6616-f959-41b6-9d96-6d9fddaa3473","sourceSyncedOwnerId":"54dd6616-f959-41b6-9d96-6d9fddaa3473","credit":"1000","timestamp":"2023-07-20T13:54:20.988Z","txType":"SEPA_PAYIN_COMPLETED","memo":"Simulate Payin","exchangeRate":"1","balanceBefore":{"amount":"0","currency":"cents"},"balanceAfter":{"amount":"1000","currency":"cents"},"bankingTransactionId":"5c29131d-186e-47ad-a9c2-252368fc88ea","bankingTransactionShortId":"20230720-RAF998","bankingSenderBic":"BUKBGB22","bankingSenderIban":"GB29NWBK60161331926819","bankingSenderName":"Boris Johnson","bankingPaymentType":"SEPA","bankingSenderInformation":null,"bankingSenderRoutingCodes":[],"bankingSenderAccountNumber":null,"bankingTransactionDateTime":"2023-07-20T13:54:19.754214","bankingTransactionReference":"Simulate Payin"}],"count":4,"total":4} + """ + let provider = try getMockProvider(responseString: mockData, statusCode: 200) + + let result = try await provider.getAccountStatement( + userId: "cecaea44-47f2-439b-99a1-a35fefaf1eb6", + accountId: "4dc6ecb29d74198e9e507f8025cad011", + startDate: Date(timeIntervalSince1970: 1_687_564_800), + endDate: Date(), + page: 1 + ) + + // Assert + XCTAssertEqual(result.transactions.isEmpty, false) + XCTAssertEqual(result.transactions.contains(where: { $0.txType == "SEPA_PAYOUT_COMPLETED" }), false) + XCTAssertEqual(result.transactions.contains(where: { $0.txType == "SEPA_PAYOUT_INITIATED" }), false) + XCTAssertEqual(result.transactions.contains(where: { $0.txType == "SEPA_PAYIN_COMPLETED" }), true) + XCTAssertNotNil(result.transactions.first(where: { $0.txType == "SEPA_PAYIN_COMPLETED" })?.bankingSenderIban) + XCTAssertNotNil(result.transactions.first(where: { $0.txType == "SEPA_PAYIN_COMPLETED" })?.bankingSenderBic) + } + + func testInitiateSEPAPayment_SuccessfulResponse() async throws { + // Arrange + let mockData = """ + {"challengeId":"924aa8d8-a377-4d61-8761-0b98a4f3f897","dateExpires":"2023-07-25T15:56:11.231Z","transaction":{"syncedOwnerId":"b861b16e-1070-4f54-b992-219549538526","sourceAccountId":"793815a2c66152e7de19318617860ba2","iban":"GB29NWBK60161331926819","bic":"BUKBGB22","amount":"574","status":"PENDING_2FA_CONFIRMATION","txType":"SEPA_PAYOUT_INITIATED","parentWalletId":"f4df3cc6-9c60-461a-8207-05cc8e6e7207","currency":"EUR","feeEstimate":{"totalFee":"0","networkFee":"0","ourFee":"0","theirFee":"0","feeCurrency":"EUR"}},"feeEstimate":{"totalFee":"0","networkFee":"0","ourFee":"0","theirFee":"0","feeCurrency":"EUR"}} + """ + let provider = try getMockProvider(responseString: mockData, statusCode: 200) + + let result = try await provider.initiateSEPAPayment( + userId: "cecaea44-47f2-439b-99a1-a35fefaf1eb6", + accountId: "4dc6ecb29d74198e9e507f8025cad011", + amount: "574", + iban: "GB29NWBK60161331926819", + bic: "BUKBGB22" + ) + + // Assert + XCTAssertNotNil(result.challengeId) + XCTAssertFalse(result.challengeId.isEmpty) + } + + // MARK: - Helper Methods + + func getMockProvider(responseString: String, statusCode: Int, error: Error? = nil) throws -> StrigaRemoteProvider { + let mockURLSession = MockURLSession(responseString: responseString, statusCode: statusCode, error: error) + let httpClient = HTTPClient(urlSession: mockURLSession) + return try StrigaRemoteProviderImpl( + baseURL: "https://example.com/api", + solanaKeyPair: KeyPair(), + httpClient: httpClient + ) + } +} diff --git a/Packages/KeyAppUI/Sources/KeyAppUI/Button/TextButton/New/NewTextButton.swift b/Packages/KeyAppUI/Sources/KeyAppUI/Button/TextButton/New/NewTextButton.swift index 9b16bd3ee6..10fb099fa9 100644 --- a/Packages/KeyAppUI/Sources/KeyAppUI/Button/TextButton/New/NewTextButton.swift +++ b/Packages/KeyAppUI/Sources/KeyAppUI/Button/TextButton/New/NewTextButton.swift @@ -15,7 +15,7 @@ public struct NewTextButton: View { public init( title: String, - size: TextButton.Size, + size: TextButton.Size = .large, style: TextButton.Style, expandable: Bool = false, isEnabled: Bool = true, @@ -47,7 +47,10 @@ public struct NewTextButton: View { } public var body: some View { - Button(action: action) { + Button(action: { + guard !isLoading else { return } + action() + }) { HStack(spacing: 8) { if let leading { if isLoading { @@ -55,6 +58,9 @@ public struct NewTextButton: View { .padding(.leading, 8) } else { Image(uiImage: leading) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: size.iconSize, height: size.iconSize) .padding(.leading, 8) } } else { @@ -71,8 +77,14 @@ public struct NewTextButton: View { .padding(.trailing, 8) } else { Image(uiImage: trailing) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: size.iconSize, height: size.iconSize) .padding(.trailing, 8) } + } else if isLoading, leading == nil { + progressView + .padding(.trailing, 8) } else { Spacer().frame(width: 4) } @@ -84,7 +96,7 @@ public struct NewTextButton: View { .foregroundColor(isEnabled ? appearance.foregroundColor : Color(Asset.Colors.mountain.color)) .background(isEnabled ? appearance.backgroundColor : Color(Asset.Colors.rain.color)) .cornerRadius(appearance.borderRadius) - .disabled(!isEnabled || isLoading) + .disabled(!isEnabled) .overlay( RoundedRectangle(cornerRadius: appearance.borderRadius) .stroke( @@ -98,9 +110,8 @@ public struct NewTextButton: View { NewCircularProgressIndicator( backgroundColor: appearance.loadingBackgroundColor, foregroundColor: appearance.loadingForegroundColor, - size: CGSize(width: 20, height: 20) + size: CGSize(width: size.iconSize, height: size.iconSize) ) - .padding(2) } } @@ -115,7 +126,8 @@ struct NewTextButton_Previews: PreviewProvider { title: "Title", size: .medium, style: .primary, - trailing: Asset.MaterialIcon.arrowForward.image + isLoading: true, + leading: Asset.MaterialIcon.arrowForward.image ) { } NewTextButton( @@ -129,7 +141,8 @@ struct NewTextButton_Previews: PreviewProvider { title: "Title", size: .medium, style: .invertedRed, - expandable: true + expandable: true, + isLoading: true ) { } NewTextButton( diff --git a/Packages/KeyAppUI/Sources/KeyAppUI/Button/TextButton/TextButtonStyles.swift b/Packages/KeyAppUI/Sources/KeyAppUI/Button/TextButton/TextButtonStyles.swift index 3e91f3ae83..0f7f0d7c37 100644 --- a/Packages/KeyAppUI/Sources/KeyAppUI/Button/TextButton/TextButtonStyles.swift +++ b/Packages/KeyAppUI/Sources/KeyAppUI/Button/TextButton/TextButtonStyles.swift @@ -126,6 +126,13 @@ public extension TextButton { case .large: return 12 } } + + public var iconSize: CGFloat { + switch self { + case .small, .medium: return 16 + case .large: return 20 + } + } } /// Create button with defined style diff --git a/Packages/KeyAppUI/Sources/KeyAppUI/CircularProgressIndicator/NewCircularProgressIndicator.swift b/Packages/KeyAppUI/Sources/KeyAppUI/CircularProgressIndicator/NewCircularProgressIndicator.swift index 03d592d9a0..d5f6430cdc 100644 --- a/Packages/KeyAppUI/Sources/KeyAppUI/CircularProgressIndicator/NewCircularProgressIndicator.swift +++ b/Packages/KeyAppUI/Sources/KeyAppUI/CircularProgressIndicator/NewCircularProgressIndicator.swift @@ -33,7 +33,7 @@ public struct NewCircularProgressIndicator: View { Circle() .trim(from: animateStart ? 1/3 : 1/9, to: animateEnd ? 2/5 : 1) .stroke(lineWidth: lineWidth) - .rotationEffect(.degrees(isCircleRotating ? 360 : 0)) + .rotationEffect(.degrees(isCircleRotating ? 0 : 360)) .frame(width: size.width, height: size.height) .foregroundColor(foregroundColor) .onAppear() { diff --git a/Packages/KeyAppUI/Sources/KeyAppUI/Generated/Asset+Generated.swift b/Packages/KeyAppUI/Sources/KeyAppUI/Generated/Asset+Generated.swift index 3477551703..19908d7a64 100644 --- a/Packages/KeyAppUI/Sources/KeyAppUI/Generated/Asset+Generated.swift +++ b/Packages/KeyAppUI/Sources/KeyAppUI/Generated/Asset+Generated.swift @@ -8,6 +8,9 @@ #elseif os(tvOS) || os(watchOS) import UIKit #endif +#if canImport(SwiftUI) + import SwiftUI +#endif // Deprecated typealiases @available(*, deprecated, renamed: "ColorAsset.Color", message: "This typealias will be removed in SwiftGen 7.0") @@ -23,6 +26,7 @@ public typealias AssetImageTypeAlias = ImageAsset.Image public enum Asset { public enum Colors { public static let cloud = ColorAsset(name: "Cloud") + public static let lightGrass = ColorAsset(name: "LightGrass") public static let lightRose = ColorAsset(name: "LightRose") public static let lightSea = ColorAsset(name: "LightSea") public static let lightSun = ColorAsset(name: "LightSun") @@ -33,15 +37,19 @@ public enum Asset { public static let night = ColorAsset(name: "Night") public static let rain = ColorAsset(name: "Rain") public static let rose = ColorAsset(name: "Rose") + public static let sea = ColorAsset(name: "Sea") public static let searchBarBgColor = ColorAsset(name: "SearchBarBgColor") public static let silver = ColorAsset(name: "Silver") public static let sky = ColorAsset(name: "Sky") public static let smoke = ColorAsset(name: "Smoke") public static let snow = ColorAsset(name: "Snow") public static let sun = ColorAsset(name: "Sun") + // swiftlint:disable trailing_comma + @available(*, deprecated, message: "All values properties are now deprecated") public static let allColors: [ColorAsset] = [ cloud, + lightGrass, lightRose, lightSea, lightSun, @@ -52,6 +60,7 @@ public enum Asset { night, rain, rose, + sea, searchBarBgColor, silver, sky, @@ -59,8 +68,6 @@ public enum Asset { snow, sun, ] - public static let allImages: [ImageAsset] = [ - ] // swiftlint:enable trailing_comma } public enum Icons { @@ -71,9 +78,9 @@ public enum Asset { public static let remove = ImageAsset(name: "remove") public static let send = ImageAsset(name: "send") public static let warning = ImageAsset(name: "warning") + // swiftlint:disable trailing_comma - public static let allColors: [ColorAsset] = [ - ] + @available(*, deprecated, message: "All values properties are now deprecated") public static let allImages: [ImageAsset] = [ copyFilled, key, @@ -182,9 +189,9 @@ public enum Asset { public static let waves = ImageAsset(name: "waves") public static let weekend = ImageAsset(name: "weekend") public static let whereToVote = ImageAsset(name: "where_to_vote") + // swiftlint:disable trailing_comma - public static let allColors: [ColorAsset] = [ - ] + @available(*, deprecated, message: "All values properties are now deprecated") public static let allImages: [ImageAsset] = [ accountBalanceWalletOutlined, add, @@ -318,6 +325,13 @@ public final class ColorAsset { } #endif + #if canImport(SwiftUI) + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) + public private(set) lazy var swiftUIColor: SwiftUI.Color = { + SwiftUI.Color(asset: self) + }() + #endif + fileprivate init(name: String) { self.name = name } @@ -337,6 +351,16 @@ public extension ColorAsset.Color { } } +#if canImport(SwiftUI) +@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) +public extension SwiftUI.Color { + init(asset: ColorAsset) { + let bundle = BundleToken.bundle + self.init(asset.name, bundle: bundle) + } +} +#endif + public struct ImageAsset { public fileprivate(set) var name: String @@ -373,6 +397,13 @@ public struct ImageAsset { return result } #endif + + #if canImport(SwiftUI) + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) + public var swiftUIImage: SwiftUI.Image { + SwiftUI.Image(asset: self) + } + #endif } public extension ImageAsset.Image { @@ -391,6 +422,26 @@ public extension ImageAsset.Image { } } +#if canImport(SwiftUI) +@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) +public extension SwiftUI.Image { + init(asset: ImageAsset) { + let bundle = BundleToken.bundle + self.init(asset.name, bundle: bundle) + } + + init(asset: ImageAsset, label: Text) { + let bundle = BundleToken.bundle + self.init(asset.name, bundle: bundle, label: label) + } + + init(decorative asset: ImageAsset) { + let bundle = BundleToken.bundle + self.init(decorative: asset.name, bundle: bundle) + } +} +#endif + // swiftlint:disable convenience_type private final class BundleToken { static let bundle: Bundle = { diff --git a/Packages/KeyAppUI/Sources/KeyAppUI/Input/BaseTextFieldView.swift b/Packages/KeyAppUI/Sources/KeyAppUI/Input/BaseTextFieldView.swift index 15e7d71ea2..81611a3f3d 100644 --- a/Packages/KeyAppUI/Sources/KeyAppUI/Input/BaseTextFieldView.swift +++ b/Packages/KeyAppUI/Sources/KeyAppUI/Input/BaseTextFieldView.swift @@ -99,7 +99,10 @@ open class BaseTextFieldView: BECompositionView { UILabel().withAttributedText( .attributedString(with: "", of: .label1, weight: .regular) .withForegroundColor(Asset.Colors.mountain.color) - ).bind(bottomTipLabel).padding(.init(top: 5, left: 8, bottom: 0, right: 0)) + ) + .bind(bottomTipLabel) + .centeredHorizontallyView + .padding(.init(top: 5, left: 0, bottom: 0, right: 0)) } } diff --git a/p2p_wallet/Resources/Colors.xcassets/cdf6cd.colorset/Contents.json b/Packages/KeyAppUI/Sources/KeyAppUI/Resources/Colors.xcassets/LightGrass.colorset/Contents.json similarity index 100% rename from p2p_wallet/Resources/Colors.xcassets/cdf6cd.colorset/Contents.json rename to Packages/KeyAppUI/Sources/KeyAppUI/Resources/Colors.xcassets/LightGrass.colorset/Contents.json diff --git a/p2p_wallet/Resources/Colors.xcassets/sea.colorset/Contents.json b/Packages/KeyAppUI/Sources/KeyAppUI/Resources/Colors.xcassets/Sea.colorset/Contents.json similarity index 100% rename from p2p_wallet/Resources/Colors.xcassets/sea.colorset/Contents.json rename to Packages/KeyAppUI/Sources/KeyAppUI/Resources/Colors.xcassets/Sea.colorset/Contents.json diff --git a/Packages/KeyAppUI/Sources/KeyAppUI/Typography/Typography.swift b/Packages/KeyAppUI/Sources/KeyAppUI/Typography/Typography.swift index bb1b5b3c4e..aa4839f562 100644 --- a/Packages/KeyAppUI/Sources/KeyAppUI/Typography/Typography.swift +++ b/Packages/KeyAppUI/Sources/KeyAppUI/Typography/Typography.swift @@ -138,9 +138,9 @@ public extension NSAttributedString { } public extension Text { - func apply(style: UIFont.Style) -> some View { + func apply(style: UIFont.Style, weight: UIFont.Weight = .regular) -> some View { self.kerning(UIFont.letterSpacing(for: style)) - .font(Font(UIFont.font(of: style).withSize(UIFont.fontSize(of: style)) as CTFont)) + .font(Font(UIFont.font(of: style, weight: weight).withSize(UIFont.fontSize(of: style)) as CTFont)) .lineSpacing(UIFont.lineHeight(for: style)) } } diff --git a/p2p_wallet.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/p2p_wallet.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f0498ac85c..1cf7ff2ab4 100644 --- a/p2p_wallet.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/p2p_wallet.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -95,8 +95,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/firebase-ios-sdk.git", "state" : { - "revision" : "df2171b0c6afb9e9d4f7e07669d558c510b9f6be", - "version" : "10.13.0" + "revision" : "a580250a9ff49ec38da5430cef20f88ddc831db2", + "version" : "10.12.0" } }, { @@ -104,8 +104,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleAppMeasurement.git", "state" : { - "revision" : "03b9beee1a61f62d32c521e172e192a1663a5e8b", - "version" : "10.13.0" + "revision" : "0a226a8c50494c4cb877fbde27ab6374520a3354", + "version" : "10.12.0" } }, { @@ -113,8 +113,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleDataTransport.git", "state" : { - "revision" : "aae45a320fd0d11811820335b1eabc8753902a40", - "version" : "9.2.5" + "revision" : "98a00258d4518b7521253a70b7f70bb76d2120fe", + "version" : "9.2.4" } }, { @@ -131,8 +131,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleUtilities.git", "state" : { - "revision" : "c38ce365d77b04a9a300c31061c5227589e5597b", - "version" : "7.11.5" + "revision" : "4446686bc3714d49ce043d0f68318f42ed718cb6", + "version" : "7.11.4" } }, { @@ -162,6 +162,15 @@ "version" : "1.3.1" } }, + { + "identity" : "idensicmobilesdk-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SumSubstance/IdensicMobileSDK-iOS.git", + "state" : { + "revision" : "c75411b565f5203c0a6c4dcd9d32ad981bd257b9", + "version" : "1.26.0" + } + }, { "identity" : "intercom-ios-sp", "kind" : "remoteSourceControl", @@ -234,6 +243,15 @@ "version" : "2.30909.0" } }, + { + "identity" : "openssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/krzyzanowskim/OpenSSL", + "state" : { + "revision" : "0faf71a188bcfdf0245cab42886b9b240ca71c52", + "version" : "1.1.2200" + } + }, { "identity" : "phonenumberkit", "kind" : "remoteSourceControl", @@ -329,8 +347,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/p2p-org/solana-swift", "state" : { - "branch" : "main", - "revision" : "5d186612724dd806eb5293e565ef8dab43d24d39" + "revision" : "ab9a29e291a2adb1f4dfecea09b9c516c9fba970", + "version" : "4.0.0" } }, { @@ -356,8 +374,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "cf281631ff10ec6111f2761052aa81896a83a007", - "version" : "2.58.0" + "revision" : "a2e487b77f17edbce9a65f2b7415f2f479dc8e48", + "version" : "2.57.0" } }, { @@ -383,8 +401,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-transport-services.git", "state" : { - "revision" : "e7403c35ca6bb539a7ca353b91cc2d8ec0362d58", - "version" : "1.19.0" + "revision" : "41f4098903878418537020075a4d8a6e20a0b182", + "version" : "1.17.0" } }, { @@ -450,13 +468,22 @@ "version" : "1.1.0" } }, + { + "identity" : "twilio-video-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/twilio/twilio-video-ios", + "state" : { + "revision" : "e028f87d5425316c39723c7bc2508f5349e36482", + "version" : "4.6.3" + } + }, { "identity" : "web3.swift", "kind" : "remoteSourceControl", "location" : "https://github.com/Boilertalk/Web3.swift.git", "state" : { - "revision" : "808d6c70daa10186d0084f44b3dfa2b8d4c88789", - "version" : "0.8.4" + "revision" : "4647a88ef5a85d92963512e0f7ebc78a66b6d850", + "version" : "0.8.3" } }, { diff --git a/p2p_wallet/Common/Extensions/Combine+Extensions.swift b/p2p_wallet/Common/Extensions/Combine+Extensions.swift index ae4da77248..515b247610 100644 --- a/p2p_wallet/Common/Extensions/Combine+Extensions.swift +++ b/p2p_wallet/Common/Extensions/Combine+Extensions.swift @@ -70,13 +70,12 @@ extension NSObject { } fileprivate static func deinitCallback(forObject object: NSObject) -> AnyPublisher { - if let deinitCallback = objc_getAssociatedObject(object, &deinitCallbackKey) as? AnyPublisher { - return deinitCallback - } else { - let rem = DeinitCallback() - objc_setAssociatedObject(object, &deinitCallbackKey, rem, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - return rem.subject.eraseToAnyPublisher() + if let deinitCallback = objc_getAssociatedObject(object, &deinitCallbackKey) as? DeinitCallback { + return deinitCallback.subject.eraseToAnyPublisher() } + let rem = DeinitCallback() + objc_setAssociatedObject(object, &deinitCallbackKey, rem, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return rem.subject.eraseToAnyPublisher() } } diff --git a/p2p_wallet/Common/Extensions/CountriesAPI+DefaultRegion.swift b/p2p_wallet/Common/Extensions/CountriesAPI+DefaultRegion.swift new file mode 100644 index 0000000000..280d5a69dc --- /dev/null +++ b/p2p_wallet/Common/Extensions/CountriesAPI+DefaultRegion.swift @@ -0,0 +1,15 @@ +import CountriesAPI +import Foundation +import PhoneNumberKit + +extension CountriesAPI { + func defaultRegionCode() -> String { + Locale.current.regionCode?.lowercased() ?? PhoneNumberKit.defaultRegionCode().lowercased() + } + + func currentCountryName() async throws -> Region? { + try await fetchRegions().first { country in + country.alpha2.lowercased() == defaultRegionCode() + } + } +} diff --git a/p2p_wallet/Common/Extensions/Date+Extensions.swift b/p2p_wallet/Common/Extensions/Date+Extensions.swift index f56d8dd397..567c42d53d 100644 --- a/p2p_wallet/Common/Extensions/Date+Extensions.swift +++ b/p2p_wallet/Common/Extensions/Date+Extensions.swift @@ -7,4 +7,18 @@ extension Date { dateFormatter.dateFormat = format return dateFormatter.string(from: self) } + + func components(_ components: Set = [.day, .month, .year]) -> DateComponents { + Calendar.current.dateComponents(components, from: self) + } + + static func - (recent: Date, previous: Date) -> (month: Int?, day: Int?, hour: Int?, minute: Int?, second: Int?) { + let day = Calendar.current.dateComponents([.day], from: previous, to: recent).day + let month = Calendar.current.dateComponents([.month], from: previous, to: recent).month + let hour = Calendar.current.dateComponents([.hour], from: previous, to: recent).hour + let minute = Calendar.current.dateComponents([.minute], from: previous, to: recent).minute + let second = Calendar.current.dateComponents([.second], from: previous, to: recent).second + + return (month: month, day: day, hour: hour, minute: minute, second: second) + } } diff --git a/p2p_wallet/Common/Extensions/Encodable+Extensions.swift b/p2p_wallet/Common/Extensions/Encodable+Extensions.swift new file mode 100644 index 0000000000..1c3a936bb0 --- /dev/null +++ b/p2p_wallet/Common/Extensions/Encodable+Extensions.swift @@ -0,0 +1,21 @@ +import Foundation + +extension Encodable { + /// Snake case Encoded string for request as a json string + var snakeCaseEncoded: String? { + encoded(strategy: .convertToSnakeCase) + } + + /// Encoded string for request as a json string + var encoded: String? { + encoded(strategy: .useDefaultKeys) + } + + private func encoded(strategy: JSONEncoder.KeyEncodingStrategy) -> String? { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.keyEncodingStrategy = strategy + guard let data = try? encoder.encode(self) else { return nil } + return String(data: data, encoding: .utf8) + } +} diff --git a/p2p_wallet/Common/Extensions/String+Extensions.swift b/p2p_wallet/Common/Extensions/String+Extensions.swift index a819be8cd7..d19a44c53e 100644 --- a/p2p_wallet/Common/Extensions/String+Extensions.swift +++ b/p2p_wallet/Common/Extensions/String+Extensions.swift @@ -93,6 +93,10 @@ extension String { "" } + static var fakePausedTransactionSignaturePrefix: String { + "" + } + static func fakeTransactionSignature(id: String) -> String { fakeTransactionSignaturePrefix + "<\(id)>" } @@ -111,3 +115,16 @@ extension String { return String(stringLiteral: s) } } + +// MARK: - Date + +extension String { + func date(withFormat format: String, locale: Locale = Locale.shared) -> Date? { + let dateFormatter = DateFormatter() + dateFormatter.locale = locale + dateFormatter.dateFormat = format + return dateFormatter.date(from: self) + } +} + +// MARK: - HTML Entiteles diff --git a/p2p_wallet/Common/Extensions/String+IBANFormat.swift b/p2p_wallet/Common/Extensions/String+IBANFormat.swift new file mode 100644 index 0000000000..8065c4c6c0 --- /dev/null +++ b/p2p_wallet/Common/Extensions/String+IBANFormat.swift @@ -0,0 +1,30 @@ +import Foundation + +extension String { + func formatIBAN() -> String { + // Remove any spaces or special characters from the input string + let cleanedIBAN = components(separatedBy: CharacterSet.alphanumerics.inverted).joined() + + // Check if the IBAN is empty or not valid (less than 4 characters) + guard cleanedIBAN.count >= 4 else { + return cleanedIBAN + } + + // Create a formatted IBAN by grouping characters in blocks of four + var formattedIBAN = "" + var index = cleanedIBAN.startIndex + + while index < cleanedIBAN.endIndex { + let nextIndex = cleanedIBAN.index(index, offsetBy: 4, limitedBy: cleanedIBAN.endIndex) ?? cleanedIBAN + .endIndex + let block = cleanedIBAN[index ..< nextIndex] + formattedIBAN += String(block) + if nextIndex != cleanedIBAN.endIndex { + formattedIBAN += " " + } + index = nextIndex + } + + return formattedIBAN.uppercased() + } +} diff --git a/p2p_wallet/Common/Extensions/View+Extensions.swift b/p2p_wallet/Common/Extensions/View+Extensions.swift index 264519651b..e1d7136f62 100644 --- a/p2p_wallet/Common/Extensions/View+Extensions.swift +++ b/p2p_wallet/Common/Extensions/View+Extensions.swift @@ -2,10 +2,10 @@ import Combine import SwiftUI extension View { - func asViewController(withoutUIKitNavBar: Bool = true) -> UIViewController { + func asViewController(withoutUIKitNavBar: Bool = true, ignoresKeyboard: Bool = false) -> UIViewController { withoutUIKitNavBar - ? UIHostingControllerWithoutNavigation(rootView: self) - : UIHostingController(rootView: self) + ? UIHostingControllerWithoutNavigation(rootView: self, ignoresKeyboard: ignoresKeyboard) + : UIHostingController(rootView: self, ignoresKeyboard: ignoresKeyboard) } func castToAnyView() -> AnyView { diff --git a/p2p_wallet/Common/Extensions/WalletMetadataService+StrigaMetadataProvider.swift b/p2p_wallet/Common/Extensions/WalletMetadataService+StrigaMetadataProvider.swift new file mode 100644 index 0000000000..dc8873b3f2 --- /dev/null +++ b/p2p_wallet/Common/Extensions/WalletMetadataService+StrigaMetadataProvider.swift @@ -0,0 +1,25 @@ +import BankTransfer +import Foundation +import Onboarding + +extension WalletMetadataServiceImpl: StrigaMetadataProvider { + public func getStrigaMetadata() async -> StrigaMetadata? { + guard let metadata = metadata.value else { + return nil + } + return .init( + userId: metadata.striga.userId, + email: metadata.email, + phoneNumber: metadata.phoneNumber + ) + } + + public func updateMetadata(withUserId userId: String) async { + guard var newData = metadata.value else { + return + } + newData.striga.userId = userId + + await update(newData) + } +} diff --git a/p2p_wallet/Common/Models/PendingTransaction.swift b/p2p_wallet/Common/Models/PendingTransaction.swift index 5fe88b1b91..df44e5ce0d 100644 --- a/p2p_wallet/Common/Models/PendingTransaction.swift +++ b/p2p_wallet/Common/Models/PendingTransaction.swift @@ -9,6 +9,7 @@ struct PendingTransaction { case confirmed(_ numberOfConfirmed: Int) case finalized case error(_ error: Swift.Error) + case confirmationNeeded var numberOfConfirmations: Int? { switch self { diff --git a/p2p_wallet/Common/Models/RawTransactionType.swift b/p2p_wallet/Common/Models/RawTransactionType.swift index f20f8b9769..16cfcda8a8 100644 --- a/p2p_wallet/Common/Models/RawTransactionType.swift +++ b/p2p_wallet/Common/Models/RawTransactionType.swift @@ -6,8 +6,6 @@ import SolanaSwift protocol RawTransactionType { func createRequest() async throws -> String var mainDescription: String { get } - var payingFeeWallet: SolanaAccount? { get } - var feeAmount: FeeAmount { get } } struct SwapMetaInfo { diff --git a/p2p_wallet/Common/Models/WalletActionType.swift b/p2p_wallet/Common/Models/WalletActionType.swift index 6b60a90505..f77235b330 100644 --- a/p2p_wallet/Common/Models/WalletActionType.swift +++ b/p2p_wallet/Common/Models/WalletActionType.swift @@ -2,13 +2,17 @@ import UIKit enum WalletActionType { case receive + case topUp case buy case send case swap case cashOut + case withdraw var text: String { switch self { + case .topUp: + return L10n.topUp case .receive: return L10n.receive case .buy: @@ -18,12 +22,16 @@ enum WalletActionType { case .swap: return L10n.swap case .cashOut: - return "Cash out" + return L10n.cashOut + case .withdraw: + return L10n.withdraw } } var icon: UIImage { switch self { + case .topUp, .withdraw: + return .homeBuy case .receive: return .actionReceive case .buy: diff --git a/p2p_wallet/Common/Services/Auth/Logout/LogoutService.swift b/p2p_wallet/Common/Services/Auth/Logout/LogoutService.swift new file mode 100644 index 0000000000..70dc8ac8c9 --- /dev/null +++ b/p2p_wallet/Common/Services/Auth/Logout/LogoutService.swift @@ -0,0 +1,13 @@ +import BankTransfer +import Resolver + +protocol LogoutService { + func logout() async +} + +final class LogoutServiceImpl: LogoutService { + func logout() async { + await Resolver.resolve((any BankTransferService).self).clearCache() + try? await Resolver.resolve(UserWalletManager.self).remove() + } +} diff --git a/p2p_wallet/Common/Services/Defaults.swift b/p2p_wallet/Common/Services/Defaults.swift index 183918f41e..17afcc7af3 100644 --- a/p2p_wallet/Common/Services/Defaults.swift +++ b/p2p_wallet/Common/Services/Defaults.swift @@ -1,3 +1,4 @@ +import CountriesAPI import FirebaseRemoteConfig import Foundation import Onboarding @@ -41,6 +42,7 @@ extension DefaultsKeys { var forcedFeeRelayerEndpoint: DefaultsKey { .init(#function, defaultValue: nil) } var forcedNameServiceEndpoint: DefaultsKey { .init(#function, defaultValue: nil) } var forcedNewSwapEndpoint: DefaultsKey { .init(#function, defaultValue: nil) } + var forcedStrigaEndpoint: DefaultsKey { .init(#function, defaultValue: nil) } var didBackupOffline: DefaultsKey { .init(#function, defaultValue: false) } var walletName: DefaultsKey<[String: String]> { .init(#function, defaultValue: [:]) } @@ -123,6 +125,30 @@ extension DefaultsKeys { var ethBannerShouldHide: DefaultsKey { .init(#function, defaultValue: false) } + + var strigaOTPResendCounter: DefaultsKey { + .init(#function, defaultValue: nil) + } + + var strigaOTPConfirmErrorDate: DefaultsKey { + .init(#function, defaultValue: nil) + } + + var strigaOTPResendErrorDate: DefaultsKey { + .init(#function, defaultValue: nil) + } + + var region: DefaultsKey { + .init(#function, defaultValue: nil) + } + + var homeBannerVisibility: DefaultsKey { + .init(#function, defaultValue: nil) + } + + var strigaIBANInfoDoNotShow: DefaultsKey { + .init(#function, defaultValue: false) + } } // MARK: - Moonpay Environment @@ -133,3 +159,5 @@ extension DefaultsKeys { case sandbox } } + +extension Region: DefaultsSerializable {} diff --git a/p2p_wallet/Common/Services/FeatureFlags/Features.swift b/p2p_wallet/Common/Services/FeatureFlags/Features.swift index 1b15432e67..0e0e2d95ad 100755 --- a/p2p_wallet/Common/Services/FeatureFlags/Features.swift +++ b/p2p_wallet/Common/Services/FeatureFlags/Features.swift @@ -27,4 +27,6 @@ public extension Feature { static let sendViaLinkEnabled = Feature(rawValue: "send_via_link_enabled") static let solanaEthAddressEnabled = Feature(rawValue: "solana_eth_address_enabled") + + static let bankTransfer = Feature(rawValue: "striga_enabled") } diff --git a/p2p_wallet/Common/Services/GlobalAppState.swift b/p2p_wallet/Common/Services/GlobalAppState.swift index 0f8f7fcd43..1182d13c55 100644 --- a/p2p_wallet/Common/Services/GlobalAppState.swift +++ b/p2p_wallet/Common/Services/GlobalAppState.swift @@ -38,6 +38,21 @@ class GlobalAppState: ObservableObject { } } + // New striga endpoint + @Published var strigaEndpoint: String { + didSet { + Defaults.forcedStrigaEndpoint = strigaEndpoint + ResolverScope.session.reset() + } + } + + // Striga mocking + @Published var strigaMockingEnabled: Bool = false { + didSet { + ResolverScope.session.reset() + } + } + // TODO: Refactor! @Published var surveyID: String? @Published var sendViaLinkUrl: URL? @@ -54,6 +69,17 @@ class GlobalAppState: ObservableObject { } else { newSwapEndpoint = "https://swap.key.app" } + + if let forcedValue = Defaults.forcedStrigaEndpoint { + strigaEndpoint = forcedValue + } else { + switch Environment.current { + case .debug, .test: + strigaEndpoint = .secretConfig("STRIGA_PROXY_API_ENDPOINT_DEV")! + case .release: + strigaEndpoint = .secretConfig("STRIGA_PROXY_API_ENDPOINT_PROD")! + } + } } @Published var bridgeEndpoint: String = (Environment.current == .release) ? diff --git a/p2p_wallet/Common/Services/Logger/LogManagerLoggers/AlertLogger/AlertLoggerDataBuilder.swift b/p2p_wallet/Common/Services/Logger/LogManagerLoggers/AlertLogger/AlertLoggerDataBuilder.swift index b9703b0749..37be2033ac 100644 --- a/p2p_wallet/Common/Services/Logger/LogManagerLoggers/AlertLogger/AlertLoggerDataBuilder.swift +++ b/p2p_wallet/Common/Services/Logger/LogManagerLoggers/AlertLogger/AlertLoggerDataBuilder.swift @@ -10,6 +10,7 @@ enum AlertLoggerDataBuilder { let userPubkey: String let blockchainError: String? let feeRelayerError: String? + let otherError: String? let appVersion: String let timestamp: String } @@ -22,6 +23,7 @@ enum AlertLoggerDataBuilder { var blockchainError: String? var feeRelayerError: String? + var otherError: String? switch error { case let error as APIClientError: blockchainError = error.blockchainErrorDescription @@ -36,7 +38,14 @@ enum AlertLoggerDataBuilder { ) .blockchainErrorDescription default: - feeRelayerError = "\(error)" + // if error is encodable, log the json + if let encodableError = error as? Encodable { + otherError = encodableError.jsonString + } + // otherwise reflect to see error detail + else { + otherError = String(reflecting: error) + } } let appVersion = AppInfo.appVersionDetail @@ -47,6 +56,7 @@ enum AlertLoggerDataBuilder { userPubkey: userPubkey, blockchainError: blockchainError, feeRelayerError: feeRelayerError, + otherError: otherError, appVersion: appVersion, timestamp: timestamp ) diff --git a/p2p_wallet/Common/Services/Networking/Codable+SnakeCase.swift b/p2p_wallet/Common/Services/Networking/Codable+SnakeCase.swift deleted file mode 100644 index 0091c446b7..0000000000 --- a/p2p_wallet/Common/Services/Networking/Codable+SnakeCase.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation - -extension Encodable { - var snakeCaseEncoded: String? { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - encoder.keyEncodingStrategy = .convertToSnakeCase - guard let data = try? encoder.encode(self) else { return nil } - return String(data: data, encoding: .utf8) - } -} diff --git a/p2p_wallet/Common/Services/Networking/Dto/JsonRpcRequestDto.swift b/p2p_wallet/Common/Services/Networking/Dto/JsonRpcRequestDto.swift deleted file mode 100644 index 2cef91b742..0000000000 --- a/p2p_wallet/Common/Services/Networking/Dto/JsonRpcRequestDto.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation - -struct JsonRpcRequestDto: Encodable { - let jsonrpc = "2.0" - let id = UUID().uuidString - let method: String - let params: [T] -} diff --git a/p2p_wallet/Common/Services/Networking/Dto/JsonRpcResponseDto.swift b/p2p_wallet/Common/Services/Networking/Dto/JsonRpcResponseDto.swift deleted file mode 100644 index bbc25ac5ce..0000000000 --- a/p2p_wallet/Common/Services/Networking/Dto/JsonRpcResponseDto.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -struct JsonRpcResponseDto: Decodable { - let id: String - let result: T -} - -struct JsonRpcResponseErrorDto: Decodable { - let id: String - let error: JsonRpcError -} - -struct JsonRpcError: Decodable, Error { - let code: Int -} diff --git a/p2p_wallet/Common/Services/Networking/Endpoint.swift b/p2p_wallet/Common/Services/Networking/Endpoint.swift deleted file mode 100644 index b3e4364813..0000000000 --- a/p2p_wallet/Common/Services/Networking/Endpoint.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -protocol Endpoint { - var baseURL: String { get } - var path: String { get } - var method: RequestMethod { get } - var header: [String: String] { get } - var body: String? { get } -} - -extension Endpoint { - var header: [String: String] { - [ - "Content-Type": "application/json", - "Accept": "application/json", - "CHANNEL_ID": "P2PWALLET_MOBILE", - ] - } - - var baseURL: String { - GlobalAppState.shared.pushServiceEndpoint - } -} diff --git a/p2p_wallet/Common/Services/Networking/ErrorModel.swift b/p2p_wallet/Common/Services/Networking/ErrorModel.swift deleted file mode 100644 index 3f75f57549..0000000000 --- a/p2p_wallet/Common/Services/Networking/ErrorModel.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Foundation - -enum ErrorModel: Error, LocalizedError { - case decode - case invalidURL - case noResponse - case unauthorized - case unexpectedStatusCode - case unknown - case api(model: ApiErrorModel) - - var errorDescription: String? { - switch self { - case let .api(model): - return model.message - case .decode: - return "Decode error" - case .unauthorized: - return "Session expired" - default: - return "Unknown error" - } - } -} - -struct ApiErrorModel: Decodable { - let code: Int - let message: String -} diff --git a/p2p_wallet/Common/Services/Networking/HttpClient.swift b/p2p_wallet/Common/Services/Networking/HttpClient.swift deleted file mode 100644 index db7810c87d..0000000000 --- a/p2p_wallet/Common/Services/Networking/HttpClient.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// HttpClient.swift -// p2p_wallet -// -// Created by Ivan on 28.04.2022. -// -import Foundation - -protocol HttpClient { - func sendRequest(endpoint: Endpoint, responseModel: T.Type) async throws -> T -} - -final class HttpClientImpl: HttpClient { - func sendRequest(endpoint: Endpoint, responseModel: T.Type) async throws -> T { - guard let url = URL(string: endpoint.baseURL + endpoint.path) else { throw ErrorModel.invalidURL } - - var request = URLRequest(url: url) - request.httpMethod = endpoint.method.rawValue - request.allHTTPHeaderFields = endpoint.header - - if let body = endpoint.body { - request.httpBody = body.data(using: .utf8) - } - - print(request.cURL()) - - let (data, response) = try await URLSession.shared.data(for: request) - guard let response = response as? HTTPURLResponse else { throw ErrorModel.noResponse } - switch response.statusCode { - case 200 ... 299: - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - guard let decodedResponse = try? decoder.decode(responseModel, from: data) else { - if let decodedErrorResponse = try? decoder.decode(JsonRpcResponseErrorDto.self, from: data) { - throw decodedErrorResponse.error - } - throw ErrorModel.decode - } - return decodedResponse - case 401: - throw ErrorModel.unauthorized - default: - throw ErrorModel.unexpectedStatusCode - } - } -} diff --git a/p2p_wallet/Common/Services/Networking/RequestMethod.swift b/p2p_wallet/Common/Services/Networking/RequestMethod.swift deleted file mode 100644 index 23a48d195c..0000000000 --- a/p2p_wallet/Common/Services/Networking/RequestMethod.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Foundation - -enum RequestMethod: String { - case delete = "DELETE" - case get = "GET" - case patch = "PATCH" - case post = "POST" - case put = "PUT" -} diff --git a/p2p_wallet/Common/Services/NotificationsService/NotificationService.swift b/p2p_wallet/Common/Services/NotificationsService/NotificationService.swift index e58dcd511f..7fb7b3d135 100644 --- a/p2p_wallet/Common/Services/NotificationsService/NotificationService.swift +++ b/p2p_wallet/Common/Services/NotificationsService/NotificationService.swift @@ -2,12 +2,15 @@ import AnalyticsManager import BEPureLayout import Combine import Foundation +import KeyAppNetworking import KeyAppUI import Resolver import SolanaSwift import UIKit protocol NotificationService { + typealias DeviceTokenResponse = JSONRPCResponseDto + func sendRegisteredDeviceToken(_ deviceToken: Data, ethAddress: String?) async throws func deleteDeviceToken(ethAddress: String?) async throws func showInAppNotification(_ notification: InAppNotification) diff --git a/p2p_wallet/Common/Services/NotificationsService/Repository/NotificationRepository.swift b/p2p_wallet/Common/Services/NotificationsService/Repository/NotificationRepository.swift index cb90f1e365..66595e757d 100644 --- a/p2p_wallet/Common/Services/NotificationsService/Repository/NotificationRepository.swift +++ b/p2p_wallet/Common/Services/NotificationsService/Repository/NotificationRepository.swift @@ -1,44 +1,51 @@ import Foundation +import KeyAppNetworking import Resolver protocol NotificationRepository { - typealias DeviceTokenResponse = JsonRpcResponseDto + typealias DeviceTokenResponse = JSONRPCResponseDto func sendDeviceToken(model: DeviceTokenDto) async throws -> DeviceTokenResponse func removeDeviceToken(model: DeleteDeviceTokenDto) async throws -> DeviceTokenResponse } final class NotificationRepositoryImpl: NotificationRepository { - let httpClient = HttpClientImpl() + private let httpClient: HTTPClient + + init() { + let jsonDecoder = JSONDecoder() + jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase + + httpClient = .init( + decoder: JSONRPCDecoder(jsonDecoder: jsonDecoder) + ) + } func sendDeviceToken(model: DeviceTokenDto) async throws -> DeviceTokenResponse { do { - return try await httpClient.sendRequest( + return try await httpClient.request( endpoint: NotifierEndpoint.addDevice(dto: .init( method: "add_device", params: [model] )), responseModel: DeviceTokenResponse.self ) - } catch let error as JsonRpcError { - if error.code == -32001 { - return .init( - id: "", - result: .init( - deviceToken: model.deviceToken, - timestamp: String(Date().timeIntervalSince1970), - clientId: model.clientId - ) + } catch let error as JSONRPCError where error.code == -32001 { + return .init( + id: "", + result: .init( + deviceToken: model.deviceToken, + timestamp: String(Date().timeIntervalSince1970), + clientId: model.clientId ) - } - throw error + ) } catch { throw error } } func removeDeviceToken(model: DeleteDeviceTokenDto) async throws -> DeviceTokenResponse { - try await httpClient.sendRequest( + try await httpClient.request( endpoint: NotifierEndpoint.deleteDevice(dto: .init( method: "delete_device", params: [model] diff --git a/p2p_wallet/Common/Services/NotificationsService/Repository/NotifierEndpoint.swift b/p2p_wallet/Common/Services/NotificationsService/Repository/NotifierEndpoint.swift index ac98d38a48..45cab1cf3e 100644 --- a/p2p_wallet/Common/Services/NotificationsService/Repository/NotifierEndpoint.swift +++ b/p2p_wallet/Common/Services/NotificationsService/Repository/NotifierEndpoint.swift @@ -1,16 +1,31 @@ import Foundation +import KeyAppNetworking enum NotifierEndpoint { - case addDevice(dto: JsonRpcRequestDto) - case deleteDevice(dto: JsonRpcRequestDto) + case addDevice(dto: JSONRPCRequestDto) + case deleteDevice(dto: JSONRPCRequestDto) } -extension NotifierEndpoint: Endpoint { +// MARK: - Endpoint + +extension NotifierEndpoint: HTTPEndpoint { + var baseURL: String { + GlobalAppState.shared.pushServiceEndpoint + } + + var header: [String: String] { + [ + "Content-Type": "application/json", + "Accept": "application/json", + "CHANNEL_ID": "P2PWALLET_MOBILE", + ] + } + var path: String { "" } - var method: RequestMethod { + var method: HTTPMethod { .post } diff --git a/p2p_wallet/Common/Services/Storage/UserWalletManager.swift b/p2p_wallet/Common/Services/Storage/UserWalletManager.swift index f1e4e428ab..e367b0e447 100644 --- a/p2p_wallet/Common/Services/Storage/UserWalletManager.swift +++ b/p2p_wallet/Common/Services/Storage/UserWalletManager.swift @@ -101,10 +101,16 @@ class UserWalletManager: ObservableObject { Defaults.shouldShowConfirmAlertOnSwap = true Defaults.moonpayInfoShouldHide = false Defaults.ethBannerShouldHide = false + Defaults.strigaOTPResendCounter = nil + Defaults.strigaOTPConfirmErrorDate = nil + Defaults.strigaOTPResendErrorDate = nil Defaults.isSellInfoPresented = false Defaults.isTokenInputTypeChosen = false Defaults.fromTokenAddress = nil Defaults.toTokenAddress = nil + Defaults.region = nil + Defaults.homeBannerVisibility = nil + Defaults.strigaIBANInfoDoNotShow = false walletSettings.reset() diff --git a/p2p_wallet/Common/Services/TransactionHandler/TransactionHandler+Extensions.swift b/p2p_wallet/Common/Services/TransactionHandler/TransactionHandler+Extensions.swift index 75114cd449..671c943b9b 100644 --- a/p2p_wallet/Common/Services/TransactionHandler/TransactionHandler+Extensions.swift +++ b/p2p_wallet/Common/Services/TransactionHandler/TransactionHandler+Extensions.swift @@ -57,7 +57,7 @@ extension TransactionHandler { } // wait for 2 secs - try await Task.sleep(nanoseconds: 2_000_000_000) + try await Task.sleep(nanoseconds: 20_000_000_000) // mark as finalized await MainActor.run { [weak self] in @@ -69,6 +69,13 @@ extension TransactionHandler { return value } return + } else if transactionId.hasPrefix(.fakePausedTransactionSignaturePrefix) { + await self.updateTransactionAtIndex(index) { currentValue in + var value = currentValue + value.status = .confirmationNeeded + return value + } + return } // for production diff --git a/p2p_wallet/Common/Services/TransactionHandler/TransactionHandler.swift b/p2p_wallet/Common/Services/TransactionHandler/TransactionHandler.swift index d808f82372..7046b9fe0e 100644 --- a/p2p_wallet/Common/Services/TransactionHandler/TransactionHandler.swift +++ b/p2p_wallet/Common/Services/TransactionHandler/TransactionHandler.swift @@ -8,7 +8,11 @@ import SolanaSwift protocol TransactionHandlerType { typealias TransactionIndex = Int - func sendTransaction(_ processingTransaction: RawTransactionType) -> TransactionIndex + func sendTransaction( + _ processingTransaction: RawTransactionType, + status: PendingTransaction.TransactionStatus + ) -> TransactionIndex + func observeTransaction(transactionIndex: TransactionIndex) -> AnyPublisher func observePendingTransactions() -> AnyPublisher<[PendingTransaction], Never> @@ -26,7 +30,8 @@ class TransactionHandler: TransactionHandlerType { let onNewTransactionSubject = PassthroughSubject<(trx: PendingTransaction, index: Int), Never>() func sendTransaction( - _ processingTransaction: RawTransactionType + _ processingTransaction: RawTransactionType, + status: PendingTransaction.TransactionStatus = .sending ) -> TransactionIndex { // get index to return let txIndex = transactionsSubject.value.count @@ -37,7 +42,7 @@ class TransactionHandler: TransactionHandlerType { transactionId: nil, sentAt: Date(), rawTransaction: processingTransaction, - status: .sending + status: status ) var value = transactionsSubject.value diff --git a/p2p_wallet/Info.plist b/p2p_wallet/Info.plist index 322de822a5..494293056a 100644 --- a/p2p_wallet/Info.plist +++ b/p2p_wallet/Info.plist @@ -117,6 +117,8 @@ $(NAME_SERVICE_ENDPOINT_NEW) NAME_SERVICE_STAGING_ENDPOINT $(NAME_SERVICE_STAGING_ENDPOINT) + NFCReaderUsageDescription + Let us scan the document for more precise recognition NOTIFICATION_SERVICE_ENDPOINT $(NOTIFICATION_SERVICE_ENDPOINT) NOTIFICATION_SERVICE_ENDPOINT_RELEASE @@ -124,7 +126,9 @@ NSAdvertisingAttributionReportEndpoint https://appsflyer-skadnetwork.com/ NSCameraUsageDescription - This is required for the app to read wallet addresses from QR codes. + This is required for the app to read wallet addresses from QR codes and identify user for Bank transfer. + NSCameraUsageDescription + This is required for the app to identify user for Bank transfer. NSFaceIDUsageDescription This is required for the app to authenticate user with TouchID or FaceID. NSPhotoLibraryAddUsageDescription @@ -133,6 +137,8 @@ NSPhotoLibraryUsageDescription This is required for the app to send photos + NSLocationWhenInUseUsageDescription + Please provide us with your geolocation data to prove your current location NSUserTrackingUsageDescription Your data will be used only to measure advertising efficiency RPCPOOL_API_KEY @@ -145,6 +151,12 @@ $(SENTRY_DSN) SWAP_ERROR_LOGGER_ENDPOINT $(SWAP_ERROR_LOGGER_ENDPOINT) + STRIGA_PROXY_API_ENDPOINT_DEV + $(STRIGA_PROXY_API_ENDPOINT_DEV) + STRIGA_PROXY_API_ENDPOINT_PROD + $(STRIGA_PROXY_API_ENDPOINT_PROD) + STRIGA_PROXY_API_ENDPOINT_DEV_NEW + $(STRIGA_PROXY_API_ENDPOINT_DEV_NEW) TEST_ACCOUNT_SEED_PHRASE $(TEST_ACCOUNT_SEED_PHRASE) TOKEN_SERVICE_DEV @@ -208,5 +220,11 @@ Light UIViewControllerBasedStatusBarAppearance + com.apple.developer.nfc.readersession.iso7816.select-identifiers + + A0000002471001 + A0000002472001 + 00000000000000 + diff --git a/p2p_wallet/Injection/Resolver+registerAllServices.swift b/p2p_wallet/Injection/Resolver+registerAllServices.swift index c1166ccfd5..45f9760b57 100644 --- a/p2p_wallet/Injection/Resolver+registerAllServices.swift +++ b/p2p_wallet/Injection/Resolver+registerAllServices.swift @@ -1,4 +1,5 @@ import AnalyticsManager +import BankTransfer import CountriesAPI import FeeRelayerSwift import FirebaseRemoteConfig @@ -6,6 +7,7 @@ import History import Jupiter import KeyAppBusiness import KeyAppKitCore +import KeyAppNetworking import Moonpay import NameService import Onboarding @@ -135,6 +137,7 @@ extension Resolver: ResolverRegistering { ) } .implements(WalletMetadataService.self) + .implements(StrigaMetadataProvider.self) .scope(.session) // Prices @@ -286,6 +289,9 @@ extension Resolver: ResolverRegistering { .scope(.application) register { Web3(rpcURL: String.secretConfig("ETH_RPC")!) } + + register { LogoutServiceImpl() } + .implements(LogoutService.self) } /// Session scope: Live when user is authenticated @@ -385,6 +391,15 @@ extension Resolver: ResolverRegistering { errorObserver: resolve(), persistence: resolve() ), + StrigaBankTransferUserActionConsumer( + persistence: resolve(), + bankTransferService: resolve(), + solanaAccountService: resolve() + ), + StrigaBankTransferOutgoingUserActionConsumer( + persistence: resolve(), + bankTransferService: resolve() + ), ] ) } @@ -558,6 +573,49 @@ extension Resolver: ResolverRegistering { } .implements(JupiterTokensProvider.self) .scope(.session) + + register { + BankTransferServiceImpl( + repository: StrigaBankTransferUserDataRepository( + localProvider: GlobalAppState.shared.strigaMockingEnabled ? + MockStrigaLocalProvider( + useCase: .unregisteredUser, + hasCachedInput: true + ) : + StrigaLocalProviderImpl(), + remoteProvider: GlobalAppState.shared.strigaMockingEnabled ? + MockStrigaRemoteProvider( + useCase: .unregisteredUser, + mockUserId: "user-id", + mockKYCToken: "kyc-token" + ) : + StrigaRemoteProviderImpl( + baseURL: GlobalAppState.shared.strigaEndpoint, + solanaKeyPair: Resolver.resolve(UserWalletManager.self).wallet?.account + ), + metadataProvider: GlobalAppState.shared.strigaMockingEnabled ? + MockStrigaMetadataProvider( + useCase: .unregisteredUser, + mockUserId: "user-id" + ) : + Resolver.resolve(StrigaMetadataProvider.self), + commonInfoProvider: CommonInfoLocalProviderImpl(), + solanaKeyPair: Resolver.resolve(UserWalletManager.self).wallet?.account + ) + ) + } + .implements((any BankTransferService).self) + .scope(.session) + + register { + AnyBankTransferService( + value: + Resolver + .resolve((any BankTransferService) + .self) as! BankTransferServiceImpl + ) + } + .scope(.session) } /// Shared scope: share between screens diff --git a/p2p_wallet/Resources/Assets.xcassets/actions-topup-icon.imageset/Contents.json b/p2p_wallet/Resources/Assets.xcassets/actions-topup-icon.imageset/Contents.json new file mode 100644 index 0000000000..d840913e80 --- /dev/null +++ b/p2p_wallet/Resources/Assets.xcassets/actions-topup-icon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Frame 1523435@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Frame 1523435@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/p2p_wallet/Resources/Assets.xcassets/actions-topup-icon.imageset/Frame 1523435@2x.png b/p2p_wallet/Resources/Assets.xcassets/actions-topup-icon.imageset/Frame 1523435@2x.png new file mode 100644 index 0000000000..c877ffd546 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/actions-topup-icon.imageset/Frame 1523435@2x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/actions-topup-icon.imageset/Frame 1523435@3x.png b/p2p_wallet/Resources/Assets.xcassets/actions-topup-icon.imageset/Frame 1523435@3x.png new file mode 100644 index 0000000000..bad1e32b86 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/actions-topup-icon.imageset/Frame 1523435@3x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/addMoneyBankCardDisabled.imageset/Contents.json b/p2p_wallet/Resources/Assets.xcassets/addMoneyBankCardDisabled.imageset/Contents.json new file mode 100644 index 0000000000..bf014c4641 --- /dev/null +++ b/p2p_wallet/Resources/Assets.xcassets/addMoneyBankCardDisabled.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/p2p_wallet/Resources/Assets.xcassets/addMoneyBankCardDisabled.imageset/Icon@2x.png b/p2p_wallet/Resources/Assets.xcassets/addMoneyBankCardDisabled.imageset/Icon@2x.png new file mode 100644 index 0000000000..7bf12133e5 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/addMoneyBankCardDisabled.imageset/Icon@2x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/addMoneyBankCardDisabled.imageset/Icon@3x.png b/p2p_wallet/Resources/Assets.xcassets/addMoneyBankCardDisabled.imageset/Icon@3x.png new file mode 100644 index 0000000000..ce6b61de35 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/addMoneyBankCardDisabled.imageset/Icon@3x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/addMoneyBankTransferDisabled.imageset/Contents.json b/p2p_wallet/Resources/Assets.xcassets/addMoneyBankTransferDisabled.imageset/Contents.json new file mode 100644 index 0000000000..bf014c4641 --- /dev/null +++ b/p2p_wallet/Resources/Assets.xcassets/addMoneyBankTransferDisabled.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/p2p_wallet/Resources/Assets.xcassets/addMoneyBankTransferDisabled.imageset/Icon@2x.png b/p2p_wallet/Resources/Assets.xcassets/addMoneyBankTransferDisabled.imageset/Icon@2x.png new file mode 100644 index 0000000000..e586a31101 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/addMoneyBankTransferDisabled.imageset/Icon@2x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/addMoneyBankTransferDisabled.imageset/Icon@3x.png b/p2p_wallet/Resources/Assets.xcassets/addMoneyBankTransferDisabled.imageset/Icon@3x.png new file mode 100644 index 0000000000..20f42eb838 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/addMoneyBankTransferDisabled.imageset/Icon@3x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/bank-transfer-info-unavailable-icon.imageset/Contents.json b/p2p_wallet/Resources/Assets.xcassets/bank-transfer-info-unavailable-icon.imageset/Contents.json new file mode 100644 index 0000000000..5c1b1bcb42 --- /dev/null +++ b/p2p_wallet/Resources/Assets.xcassets/bank-transfer-info-unavailable-icon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "euro flag@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "euro flag@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/p2p_wallet/Resources/Assets.xcassets/bank-transfer-info-unavailable-icon.imageset/euro flag@2x.png b/p2p_wallet/Resources/Assets.xcassets/bank-transfer-info-unavailable-icon.imageset/euro flag@2x.png new file mode 100644 index 0000000000..e3cf10c4a0 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/bank-transfer-info-unavailable-icon.imageset/euro flag@2x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/bank-transfer-info-unavailable-icon.imageset/euro flag@3x.png b/p2p_wallet/Resources/Assets.xcassets/bank-transfer-info-unavailable-icon.imageset/euro flag@3x.png new file mode 100644 index 0000000000..68446308bb Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/bank-transfer-info-unavailable-icon.imageset/euro flag@3x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/buy-bank.imageset/Contents.json b/p2p_wallet/Resources/Assets.xcassets/buy-bank.imageset/Contents.json index 3143efd330..ca78fe4d32 100644 --- a/p2p_wallet/Resources/Assets.xcassets/buy-bank.imageset/Contents.json +++ b/p2p_wallet/Resources/Assets.xcassets/buy-bank.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "account_balance_24px.pdf", + "filename" : "bank.pdf", "idiom" : "universal" } ], diff --git a/p2p_wallet/Resources/Assets.xcassets/buy-bank.imageset/account_balance_24px.pdf b/p2p_wallet/Resources/Assets.xcassets/buy-bank.imageset/account_balance_24px.pdf deleted file mode 100644 index 95f072048d..0000000000 Binary files a/p2p_wallet/Resources/Assets.xcassets/buy-bank.imageset/account_balance_24px.pdf and /dev/null differ diff --git a/p2p_wallet/Resources/Assets.xcassets/buy-bank.imageset/bank.pdf b/p2p_wallet/Resources/Assets.xcassets/buy-bank.imageset/bank.pdf new file mode 100644 index 0000000000..59f9038dee Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/buy-bank.imageset/bank.pdf differ diff --git a/p2p_wallet/Resources/Assets.xcassets/cat-success.imageset/Contents.json b/p2p_wallet/Resources/Assets.xcassets/cat-success.imageset/Contents.json new file mode 100644 index 0000000000..d3f058cbf2 --- /dev/null +++ b/p2p_wallet/Resources/Assets.xcassets/cat-success.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Verify your identity@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Verify your identity@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/p2p_wallet/Resources/Assets.xcassets/cat-success.imageset/Verify your identity@2x.png b/p2p_wallet/Resources/Assets.xcassets/cat-success.imageset/Verify your identity@2x.png new file mode 100644 index 0000000000..f79956410a Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/cat-success.imageset/Verify your identity@2x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/cat-success.imageset/Verify your identity@3x.png b/p2p_wallet/Resources/Assets.xcassets/cat-success.imageset/Verify your identity@3x.png new file mode 100644 index 0000000000..d8e7f23cab Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/cat-success.imageset/Verify your identity@3x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/copy-lined.imageset/Contents.json b/p2p_wallet/Resources/Assets.xcassets/copy-lined.imageset/Contents.json new file mode 100644 index 0000000000..491dc357ef --- /dev/null +++ b/p2p_wallet/Resources/Assets.xcassets/copy-lined.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Union@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Union@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/p2p_wallet/Resources/Assets.xcassets/copy-lined.imageset/Union@2x.png b/p2p_wallet/Resources/Assets.xcassets/copy-lined.imageset/Union@2x.png new file mode 100644 index 0000000000..46a47c82a2 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/copy-lined.imageset/Union@2x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/copy-lined.imageset/Union@3x.png b/p2p_wallet/Resources/Assets.xcassets/copy-lined.imageset/Union@3x.png new file mode 100644 index 0000000000..102b0ae312 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/copy-lined.imageset/Union@3x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/history-filled.imageset/Contents.json b/p2p_wallet/Resources/Assets.xcassets/history-filled.imageset/Contents.json new file mode 100644 index 0000000000..b924976af5 --- /dev/null +++ b/p2p_wallet/Resources/Assets.xcassets/history-filled.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "History-filled@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "History-filled@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/p2p_wallet/Resources/Assets.xcassets/history-filled.imageset/History-filled@2x.png b/p2p_wallet/Resources/Assets.xcassets/history-filled.imageset/History-filled@2x.png new file mode 100644 index 0000000000..c7a5265756 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/history-filled.imageset/History-filled@2x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/history-filled.imageset/History-filled@3x.png b/p2p_wallet/Resources/Assets.xcassets/history-filled.imageset/History-filled@3x.png new file mode 100644 index 0000000000..73e6d6fa31 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/history-filled.imageset/History-filled@3x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/homeBannerPerson.imageset/Frame 1523508.pdf b/p2p_wallet/Resources/Assets.xcassets/homeBannerPerson.imageset/Frame 1523508.pdf index 624707712f..c6c32d97f1 100644 Binary files a/p2p_wallet/Resources/Assets.xcassets/homeBannerPerson.imageset/Frame 1523508.pdf and b/p2p_wallet/Resources/Assets.xcassets/homeBannerPerson.imageset/Frame 1523508.pdf differ diff --git a/p2p_wallet/Resources/Assets.xcassets/icon-upload.imageset/Contents.json b/p2p_wallet/Resources/Assets.xcassets/icon-upload.imageset/Contents.json new file mode 100644 index 0000000000..bf014c4641 --- /dev/null +++ b/p2p_wallet/Resources/Assets.xcassets/icon-upload.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/p2p_wallet/Resources/Assets.xcassets/icon-upload.imageset/Icon@2x.png b/p2p_wallet/Resources/Assets.xcassets/icon-upload.imageset/Icon@2x.png new file mode 100644 index 0000000000..b8a72eeb20 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/icon-upload.imageset/Icon@2x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/icon-upload.imageset/Icon@3x.png b/p2p_wallet/Resources/Assets.xcassets/icon-upload.imageset/Icon@3x.png new file mode 100644 index 0000000000..d4f5066ae1 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/icon-upload.imageset/Icon@3x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/invest.imageset/Contents.json b/p2p_wallet/Resources/Assets.xcassets/invest.imageset/Contents.json new file mode 100644 index 0000000000..19a1763700 --- /dev/null +++ b/p2p_wallet/Resources/Assets.xcassets/invest.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "invest-1@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "invest-1@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/p2p_wallet/Resources/Assets.xcassets/invest.imageset/invest-1@2x.png b/p2p_wallet/Resources/Assets.xcassets/invest.imageset/invest-1@2x.png new file mode 100644 index 0000000000..8d11548e05 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/invest.imageset/invest-1@2x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/invest.imageset/invest-1@3x.png b/p2p_wallet/Resources/Assets.xcassets/invest.imageset/invest-1@3x.png new file mode 100644 index 0000000000..f64de9b2f6 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/invest.imageset/invest-1@3x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/kyc-clock.imageset/Contents.json b/p2p_wallet/Resources/Assets.xcassets/kyc-clock.imageset/Contents.json new file mode 100644 index 0000000000..a0a4010af8 --- /dev/null +++ b/p2p_wallet/Resources/Assets.xcassets/kyc-clock.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Frame 1523717.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/p2p_wallet/Resources/Assets.xcassets/kyc-clock.imageset/Frame 1523717.pdf b/p2p_wallet/Resources/Assets.xcassets/kyc-clock.imageset/Frame 1523717.pdf new file mode 100644 index 0000000000..7e93516498 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/kyc-clock.imageset/Frame 1523717.pdf differ diff --git a/p2p_wallet/Resources/Assets.xcassets/kyc-fail.imageset/Contents.json b/p2p_wallet/Resources/Assets.xcassets/kyc-fail.imageset/Contents.json new file mode 100644 index 0000000000..cfa9dba6c7 --- /dev/null +++ b/p2p_wallet/Resources/Assets.xcassets/kyc-fail.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Group 1523452.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/p2p_wallet/Resources/Assets.xcassets/kyc-fail.imageset/Group 1523452.pdf b/p2p_wallet/Resources/Assets.xcassets/kyc-fail.imageset/Group 1523452.pdf new file mode 100644 index 0000000000..d44eae690f Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/kyc-fail.imageset/Group 1523452.pdf differ diff --git a/p2p_wallet/Resources/Assets.xcassets/kyc-finish.imageset/Contents.json b/p2p_wallet/Resources/Assets.xcassets/kyc-finish.imageset/Contents.json new file mode 100644 index 0000000000..6ce00492b7 --- /dev/null +++ b/p2p_wallet/Resources/Assets.xcassets/kyc-finish.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Frame.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/p2p_wallet/Resources/Assets.xcassets/kyc-finish.imageset/Frame.pdf b/p2p_wallet/Resources/Assets.xcassets/kyc-finish.imageset/Frame.pdf new file mode 100644 index 0000000000..0008f11a0f Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/kyc-finish.imageset/Frame.pdf differ diff --git a/p2p_wallet/Resources/Assets.xcassets/kyc-send.imageset/Contents.json b/p2p_wallet/Resources/Assets.xcassets/kyc-send.imageset/Contents.json new file mode 100644 index 0000000000..63e559e971 --- /dev/null +++ b/p2p_wallet/Resources/Assets.xcassets/kyc-send.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Send for free.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/p2p_wallet/Resources/Assets.xcassets/kyc-send.imageset/Send for free.pdf b/p2p_wallet/Resources/Assets.xcassets/kyc-send.imageset/Send for free.pdf new file mode 100644 index 0000000000..d078f6eb26 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/kyc-send.imageset/Send for free.pdf differ diff --git a/p2p_wallet/Resources/Assets.xcassets/kyc-show.imageset/Contents.json b/p2p_wallet/Resources/Assets.xcassets/kyc-show.imageset/Contents.json new file mode 100644 index 0000000000..78d4cb4260 --- /dev/null +++ b/p2p_wallet/Resources/Assets.xcassets/kyc-show.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "show.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/p2p_wallet/Resources/Assets.xcassets/kyc-show.imageset/show.pdf b/p2p_wallet/Resources/Assets.xcassets/kyc-show.imageset/show.pdf new file mode 100644 index 0000000000..a0d7b0f193 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/kyc-show.imageset/show.pdf differ diff --git a/p2p_wallet/Resources/Assets.xcassets/settingsCountry.imageset/Contents.json b/p2p_wallet/Resources/Assets.xcassets/settingsCountry.imageset/Contents.json new file mode 100644 index 0000000000..bf014c4641 --- /dev/null +++ b/p2p_wallet/Resources/Assets.xcassets/settingsCountry.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/p2p_wallet/Resources/Assets.xcassets/settingsCountry.imageset/Icon@2x.png b/p2p_wallet/Resources/Assets.xcassets/settingsCountry.imageset/Icon@2x.png new file mode 100644 index 0000000000..c0c5f4f5c3 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/settingsCountry.imageset/Icon@2x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/settingsCountry.imageset/Icon@3x.png b/p2p_wallet/Resources/Assets.xcassets/settingsCountry.imageset/Icon@3x.png new file mode 100644 index 0000000000..d7ef459c25 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/settingsCountry.imageset/Icon@3x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/shield-small.imageset/Contents.json b/p2p_wallet/Resources/Assets.xcassets/shield-small.imageset/Contents.json new file mode 100644 index 0000000000..8ec12a372e --- /dev/null +++ b/p2p_wallet/Resources/Assets.xcassets/shield-small.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Shield.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Shield@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Shield@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/p2p_wallet/Resources/Assets.xcassets/shield-small.imageset/Shield.png b/p2p_wallet/Resources/Assets.xcassets/shield-small.imageset/Shield.png new file mode 100644 index 0000000000..aaf57aa2ad Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/shield-small.imageset/Shield.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/shield-small.imageset/Shield@2x.png b/p2p_wallet/Resources/Assets.xcassets/shield-small.imageset/Shield@2x.png new file mode 100644 index 0000000000..7f79d1f6fc Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/shield-small.imageset/Shield@2x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/shield-small.imageset/Shield@3x.png b/p2p_wallet/Resources/Assets.xcassets/shield-small.imageset/Shield@3x.png new file mode 100644 index 0000000000..8016ac0870 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/shield-small.imageset/Shield@3x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/user.imageset/Contents.json b/p2p_wallet/Resources/Assets.xcassets/user.imageset/Contents.json index 9262ea85b7..bf014c4641 100644 --- a/p2p_wallet/Resources/Assets.xcassets/user.imageset/Contents.json +++ b/p2p_wallet/Resources/Assets.xcassets/user.imageset/Contents.json @@ -5,12 +5,12 @@ "scale" : "1x" }, { - "filename" : "User@2x.png", + "filename" : "Icon@2x.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "User@3x.png", + "filename" : "Icon@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/p2p_wallet/Resources/Assets.xcassets/user.imageset/Icon@2x.png b/p2p_wallet/Resources/Assets.xcassets/user.imageset/Icon@2x.png new file mode 100644 index 0000000000..d75daa3373 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/user.imageset/Icon@2x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/user.imageset/Icon@3x.png b/p2p_wallet/Resources/Assets.xcassets/user.imageset/Icon@3x.png new file mode 100644 index 0000000000..08647b7d33 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/user.imageset/Icon@3x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/user.imageset/User@2x.png b/p2p_wallet/Resources/Assets.xcassets/user.imageset/User@2x.png deleted file mode 100644 index 8fa1179123..0000000000 Binary files a/p2p_wallet/Resources/Assets.xcassets/user.imageset/User@2x.png and /dev/null differ diff --git a/p2p_wallet/Resources/Assets.xcassets/user.imageset/User@3x.png b/p2p_wallet/Resources/Assets.xcassets/user.imageset/User@3x.png deleted file mode 100644 index e34aaf5d6b..0000000000 Binary files a/p2p_wallet/Resources/Assets.xcassets/user.imageset/User@3x.png and /dev/null differ diff --git a/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-crypto.imageset/Contents.json b/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-crypto.imageset/Contents.json new file mode 100644 index 0000000000..bf014c4641 --- /dev/null +++ b/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-crypto.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-crypto.imageset/Icon@2x.png b/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-crypto.imageset/Icon@2x.png new file mode 100644 index 0000000000..6f5942fcfb Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-crypto.imageset/Icon@2x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-crypto.imageset/Icon@3x.png b/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-crypto.imageset/Icon@3x.png new file mode 100644 index 0000000000..0d79925619 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-crypto.imageset/Icon@3x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-transfer.imageset/Contents.json b/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-transfer.imageset/Contents.json new file mode 100644 index 0000000000..bf014c4641 --- /dev/null +++ b/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-transfer.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-transfer.imageset/Icon@2x.png b/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-transfer.imageset/Icon@2x.png new file mode 100644 index 0000000000..aeba36ec09 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-transfer.imageset/Icon@2x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-transfer.imageset/Icon@3x.png b/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-transfer.imageset/Icon@3x.png new file mode 100644 index 0000000000..a8abb49815 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-transfer.imageset/Icon@3x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-user.imageset/Contents.json b/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-user.imageset/Contents.json new file mode 100644 index 0000000000..cbcd52292f --- /dev/null +++ b/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-user.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "KeyApp@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "KeyApp@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-user.imageset/KeyApp@2x.png b/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-user.imageset/KeyApp@2x.png new file mode 100644 index 0000000000..7886362f3b Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-user.imageset/KeyApp@2x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-user.imageset/KeyApp@3x.png b/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-user.imageset/KeyApp@3x.png new file mode 100644 index 0000000000..84302bebb0 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/withdraw-actions-user.imageset/KeyApp@3x.png differ diff --git a/p2p_wallet/Resources/Base.lproj/Localizable.strings b/p2p_wallet/Resources/Base.lproj/Localizable.strings index ee52b5554a..253a446e53 100644 --- a/p2p_wallet/Resources/Base.lproj/Localizable.strings +++ b/p2p_wallet/Resources/Base.lproj/Localizable.strings @@ -547,6 +547,89 @@ "It usually takes few seconds for a transaction to complete" = "It usually takes few seconds for a transaction to complete"; "Username was copied to clipboard" = "Username was copied to clipboard"; "%@ is the minimum amount to receive from the Ethereum Network" = "%@ is the minimum amount to receive from the Ethereum Network"; +"Bank transfer" = "Bank transfer"; +"Bank card" = "Bank card"; +"Instant · %@ fees" = "Instant · %@ fees"; +"Crypto" = "Crypto"; +"Up to 3 days · %@ fees" = "Up to 3 days · %@ fees"; +"Up to 1 hour · %@ fees" = "Up to 1 hour · %@ fees"; +"bank card, bank transfer or crypto" = "bank card, bank transfer or crypto"; +"Contacts" = "Contacts"; +"Email" = "Email"; +"Phone number" = "Phone number"; +"First name" = "First name"; +"Enter" = "Enter"; +"DD.MM.YYYY" = "DD.MM.YYYY"; +"Date of birth" = "Date of birth"; +"Country of birth" = "Country of birth"; +"Select from list" = "Select from list"; +"Check red fields" = "Check red fields"; +"Fill your data" = "Fill your data"; +"Could not be empty" = "Could not be empty"; +"Open IBAN account for international transfers with zero fees" = "Open IBAN account for international transfers with zero fees"; +"This service is available only for European Economic Area countries" = "This service is available only for European Economic Area countries"; +"Your country" = "Your country"; +"Powered by Striga" = "Powered by Striga"; +"here" = "here"; +"Check the list of countries" = "Check the list of countries"; +"Change country" = "Change country"; +"Could not be later %@" = "Could not be later %@"; +"Could not be earlier %@" = "Could not be earlier %@"; +"Incorrect day" = "Incorrect day"; +"Incorrect month" = "Incorrect month"; +"Source of funds" = "Source of funds"; +"Current address" = "Current address"; +"City" = "City"; +"Full city name" = "Full city name"; +"Address line" = "Address line"; +"Your street and flat number" = "Your street and flat number"; +"Postal code" = "Postal code"; +"Recommended" = "Recommended"; +"State or region" = "State or region"; +"Country" = "Country"; +"Chosen" = "Chosen"; +"All industries" = "All industries"; +"Select your industry" = "Select your industry"; +"Select your source of funds" = "Select your source of funds"; +"All sources" = "All sources"; +"You can confirm the phone number and finish the registration later" = "You can confirm the phone number and finish the registration later"; +"Yes, left the page" = "Yes, left the page"; +"No, continue" = "No, continue"; +"Thank you" = "Thank you"; +"The last step is document and selfie verification. This is a one-time procedure to ensure safety of your account." = "The last step is document and selfie verification. This is a one-time procedure to ensure safety of your account."; +"To start verification confirm your phone number" = "To start verification confirm your phone number"; +"Check the number" = "Check the number"; +"Incorrect number" = "Incorrect number"; +"Add money" = "Add money"; +"Your postal code" = "Your postal code"; +"Finish identity verification\nto send your money\nworldwide" = "Finish identity verification\nto send your money\nworldwide"; +"Usually it takes a few hours" = "Usually it takes a few hours"; +"View" = "View"; +"Action required" = "Action required"; +"Please, check the details\nand update your data" = "Please, check the details\nand update your data"; +"Check details" = "Check details"; +"Verification is done" = "Verification is done"; +"Continue your top up\nvia a bank transfer" = "Continue your top up\nvia a bank transfer"; +"See details" = "See details"; +"Could not be more than %@ symbols" = "Could not be more than %@ symbols"; +"Could not be less than %@ symbols" = "Could not be less than %@ symbols"; +"Your documents verification is pending" = "Your documents verification is pending"; +"Usually it takes a few hours" = "Usually it takes a few hours"; +"Verification is rejected" = "Verification is rejected"; +"Add money via bank\ntransfer is unavailable" = "Add money via bank\ntransfer is unavailable"; +"Add money to your account\nto get started" = "Add money to your account\nto get started"; +"Finish identity verification\nto send money worldwide" = "Finish identity verification\nto send money worldwide"; +"HomeBanner. Your documents\nverification is pending" = "Your documents\nverification is pending"; +"HomeSmallBanner. Finish identity\nverification to send\nmoney worldwide" = "Finish identity\nverification to send\nmoney worldwide"; +"Please, wait 1 day for the next SMS request" = "Please, wait 1 day for the next SMS request"; +"After 5 SMS requests we disabled it for 1 day to secure your account" = "After 5 SMS requests we disabled it for 1 day to secure your account"; +"Open Wallet screen" = "Open Wallet screen"; +"Write to suppot" = "Write to suppot"; +"Please, wait 1 day for the next try" = "Please, wait 1 day for the next try"; +"After 5 incorrect attempts we disabled SMS verification for 1 day to secure your account." = "After 5 incorrect attempts we disabled SMS verification for 1 day to secure your account."; +"Seems like this number is already used" = "Seems like this number is already used"; +"With new data you can’t use Striga service for now. Bou you still have Bank card and Crypto options" = "With new data you can’t use Striga service for now. Bou you still have Bank card and Crypto options"; +"Open my blank" = "Open my blank"; "Your QR code was copied" = "Your QR code was copied"; "We've noticed that you're using a new device." = "We've noticed that you're using a new device."; "This device" = "This device"; @@ -559,6 +642,12 @@ "Updating" = "Updating"; "Follow us on Twitter" = "Follow us on Twitter"; "Join our Discord" = "Join our Discord"; +"IBAN" = "IBAN"; +"BIC" = "BIC"; +"Beneficiary" = "Beneficiary"; +"Euro account" = "Euro account"; +"Your KYC verification\nis pending" = "Your KYC verification\nis pending"; +"We will update the status\nonce it is finished" = "We will update the status\nonce it is finished"; "Security and privacy" = "Security & privacy"; "To access your account from another device, you need to use any 2 factors from the list below" = "To access your account from another device, you need to use any 2 factors from the list below"; "Key App respects your privacy - it can't access your funds or personal details. Your information stays securely stored on your device and in the blockchain" = "Key App respects your privacy - it can't access your funds or personal details. Your information stays securely stored on your device and in the blockchain."; @@ -572,17 +661,64 @@ "Confirmation Code Limit Hit" = "Confirmation Code Limit Hit"; "%@\nPlease log in with the correct %@ account." = "%@\nPlease log in with the correct %@ account."; "You've used all 5 codes. Try again later. For help, contact support." = "You've used all 5 codes. Try again later. For help, contact support."; +"For this transfer, the exchange rate isn't guaranteed:\nwe will use the rate at the moment of receiving money" = "For this transfer, the exchange rate isn't guaranteed:\nwe will use the rate at the moment of receiving money"; +"Can’t withdraw now" = "Can’t withdraw now"; +"Not enough money" = "Not enough money"; +"Only %@ per one transfer" = "Only %@ per one transfer"; +"%@ as minimal amount for transfer" = "%@ as minimal amount for transfer"; +"Getting rates" = "Getting rates"; +"Invalid IBAN" = "Invalid IBAN"; +"Invalid BIC" = "Invalid BIC"; +"Withdrawal" = "Withdrawal"; +"Check your data" = "Check your data"; +"Receiver" = "Receiver"; +"Your bank account name must match the name registered to your Key App account" = "Your bank account name must match the name registered to your Key App account"; +"Your IBAN" = "Your IBAN"; +"Outcoming transfer" = "Outcoming transfer"; +"We will ask you to confirm this operation within a few minutes" = "We will ask you to confirm this operation within a few minutes"; +"Transaction pending" = "Transaction pending"; +"Your bank account name must match %@" = "Your bank account name must match %@"; +"This is your personal IBAN. Use this details to make transfers through your banking app" = "This is your personal IBAN. Use this details to make transfers through your banking app"; +"We use SEPA Instant for bank transfers, and typically, money will appear in your account in less than a minute" = "We use SEPA Instant for bank transfers, and typically, money will appear in your account in less than a minute"; +"Use this IBAN to send money from your personal accounts" = "Use this IBAN to send money from your personal accounts"; +"Your bank account name must match the name of your Key App account" = "Your bank account name must match the name of your Key App account"; +"Important notes" = "Important notes"; "Crypto" = "Crypto"; "Incoming transfer" = "Incoming transfer"; "Welcome to your crypto portfolio. \nExplore over 800 tokens with zero fees." = "Welcome to your crypto portfolio. \nExplore over 800 tokens with zero fees."; -"Add Money" = "Add Money"; "Bank transfer" = "Bank transfer"; "Bank card" = "Bank card"; -"0%% fees" = "0%% fees"; -"1%% fee" = "1%% fee"; -"4,5%% fees" = "4,5%% fees"; -"withdraw" = "withdraw"; "My crypto" = "My crypto"; "Username copied to clipboard" = "Username copied to clipboard"; "Address copied to clipboard" = "Address copied to clipboard"; "1%% fees" = "1%% fees"; +"Top Up" = "Top Up"; +"Next" = "Next"; +"Learn more" = "Learn more"; +"Withdraw" = "Withdraw"; +"Select your country of residence" = "Select your country of residence"; +"We suggest payment options based on your choice" = "We suggest payment options\nbased on your choice"; +"My bank account" = "My bank account"; +"KeyApp user" = "Key App user"; +"Crypto exchange or wallet" = "Crypto exchange or wallet"; +"%@ fee" = "%@ fee"; +"Personal information" = "Personal information"; +"Full legal first and middle names" = "Full legal first and middle names"; +"Full legal last name(s)" = "Full legal last name(s)"; +"Spell your name exactly as it's shown on your passport or ID card." = "Spell your name exactly as it's shown on your passport or ID card."; +"Jobs industry" = "Jobs industry"; +"Employment industry" = "Employment industry"; +"Last name" = "Last name"; +"Selected" = "Selected"; +"Nothing was found" = "Nothing was found"; +"Verify your identity" = "Verify your identity"; +"Enter confirmation code" = "Enter confirmation code"; +"We have sent a code\nto" = "We have sent a code\nto"; +"Resend SMS %@" = "Resend SMS %@"; +"Tap to resend" = "Tap to resend"; +"Open account for instant international transfers" = "Open account for instant international transfers"; +"This account acts as an intermediary between Key App and our banking partner, Striga payment provider, which operates with your fiat money." = "This account acts as an intermediary between Key App and our banking partner, Striga payment provider, which operates with your fiat money."; +"By pressing the button below you agree" = "By pressing the button below you agree"; +"Terms" = "Terms"; +"Withdraw to" = "Withdraw to"; +"Select" = "Select"; diff --git a/p2p_wallet/Resources/Colors.xcassets/lightSea.colorset/Contents.json b/p2p_wallet/Resources/Colors.xcassets/h767680.colorset/Contents.json similarity index 55% rename from p2p_wallet/Resources/Colors.xcassets/lightSea.colorset/Contents.json rename to p2p_wallet/Resources/Colors.xcassets/h767680.colorset/Contents.json index b7dcbfc37f..bb3c8a86a8 100644 --- a/p2p_wallet/Resources/Colors.xcassets/lightSea.colorset/Contents.json +++ b/p2p_wallet/Resources/Colors.xcassets/h767680.colorset/Contents.json @@ -2,12 +2,12 @@ "colors" : [ { "color" : { - "color-space" : "display-p3", + "color-space" : "srgb", "components" : { - "alpha" : "1.000", - "blue" : "1.000", - "green" : "0.906", - "red" : "0.733" + "alpha" : "0.100", + "blue" : "0x80", + "green" : "0x76", + "red" : "0x76" } }, "idiom" : "universal" diff --git a/p2p_wallet/Scenes/Actions/ActionsCellView.swift b/p2p_wallet/Scenes/Actions/ActionsCellView.swift deleted file mode 100644 index 9216dee9b4..0000000000 --- a/p2p_wallet/Scenes/Actions/ActionsCellView.swift +++ /dev/null @@ -1,45 +0,0 @@ -import KeyAppUI -import SwiftUI - -struct ActionsCellView: View { - let icon: UIImage - let title: String - let subtitle: String - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack(alignment: .center, spacing: 0) { - HStack(alignment: .center, spacing: 0) { - Image(uiImage: icon) - .resizable() - .frame(width: 48, height: 48) - VStack(alignment: .leading, spacing: 5) { - Text(title) - .font(uiFont: .font(of: .text3, weight: .semibold)) - .foregroundColor(Color(red: 0.17, green: 0.17, blue: 0.17)) - .frame(maxWidth: .infinity, alignment: .leading) - Text(subtitle) - .font(uiFont: .font(of: .label1, weight: .regular)) - .foregroundColor(Color(red: 0.44, green: 0.49, blue: 0.55)) - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(.leading, 12) - } - .padding() - Image(uiImage: UIImage.chevronRight) - .frame(width: 24, height: 24) - .padding(.trailing, 12) - } - .frame(maxWidth: .infinity, minHeight: 72) - } - .background(Color(Asset.Colors.snow.color)) - .cornerRadius(16) - } -} - -struct ActionsCellViewPreview: PreviewProvider { - static var previews: some View { - ActionsCellView(icon: .appleIcon, title: "Test", subtitle: "Test", action: {}) - } -} diff --git a/p2p_wallet/Scenes/Actions/ActionsCoordinator.swift b/p2p_wallet/Scenes/Actions/ActionsCoordinator.swift deleted file mode 100644 index d567346cae..0000000000 --- a/p2p_wallet/Scenes/Actions/ActionsCoordinator.swift +++ /dev/null @@ -1,69 +0,0 @@ -import AnalyticsManager -import Combine -import Foundation -import Resolver -import SolanaSwift -import UIKit - -final class ActionsCoordinator: Coordinator { - @Injected private var analyticsManager: AnalyticsManager - - private unowned var viewController: UIViewController - - private let transition = PanelTransition() - - init(viewController: UIViewController) { - self.viewController = viewController - } - - override func start() -> AnyPublisher { - let view = ActionsView() - transition.containerHeight = view.viewHeight - let viewController = view.asViewController() - let navigationController = UINavigationController(rootViewController: viewController) - viewController.view.layer.cornerRadius = 16 - viewController.view.clipsToBounds = true - navigationController.transitioningDelegate = transition - navigationController.modalPresentationStyle = .custom - self.viewController.present(navigationController, animated: true) - - let subject = PassthroughSubject() - - transition.dismissed - .sink(receiveValue: { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - subject.send(.cancel) - } - }) - .store(in: &subscriptions) - transition.dimmClicked - .sink(receiveValue: { - viewController.dismiss(animated: true) - }) - .store(in: &subscriptions) - view.cancel - .sink(receiveValue: { - viewController.dismiss(animated: true) - }) - .store(in: &subscriptions) - - view.action - .sink(receiveValue: { actionType in - viewController.dismiss(animated: true) { - subject.send(.action(type: actionType)) - } - }) - .store(in: &subscriptions) - - return subject.prefix(1).eraseToAnyPublisher() - } -} - -// MARK: - Result - -extension ActionsCoordinator { - enum Result { - case cancel - case action(type: ActionsViewActionType) - } -} diff --git a/p2p_wallet/Scenes/Actions/ActionsView.swift b/p2p_wallet/Scenes/Actions/ActionsView.swift deleted file mode 100644 index f1c87cc2a0..0000000000 --- a/p2p_wallet/Scenes/Actions/ActionsView.swift +++ /dev/null @@ -1,70 +0,0 @@ -import Combine -import KeyAppBusiness -import KeyAppUI -import Resolver -import Sell -import SwiftUI -import SwiftyUserDefaults - -struct ActionsView: View { - @Injected private var sellDataService: any SellDataService - @Injected private var walletsRepository: SolanaAccountsService - - private let actionSubject = PassthroughSubject() - var action: AnyPublisher { actionSubject.eraseToAnyPublisher() } - private let cancelSubject = PassthroughSubject() - var cancel: AnyPublisher { cancelSubject.eraseToAnyPublisher() } - var isSellAvailable: Bool { - available(.sellScenarioEnabled) && - sellDataService.isAvailable && - !walletsRepository.getWallets().isTotalAmountEmpty - } - - var body: some View { - VStack(spacing: 8) { - Color(Asset.Colors.rain.color) - .frame(width: 31, height: 4) - .cornerRadius(2) - Text(L10n.addMoney) - .foregroundColor(Color(Asset.Colors.night.color)) - .font(uiFont: .font(of: .text1, weight: .bold)) - .padding(.top, 8) - VStack(spacing: 8) { - ForEach(ActionsViewActionType.allCases, id: \.title) { actionType in - ActionsCellView(icon: actionType.icon, title: actionType.title, subtitle: actionType.subtitle) { - actionSubject.send(actionType) - } - .padding(.horizontal, 4) - } - } - .padding(.top, 12) - Spacer() - } - .padding(.horizontal, 16) - .padding(.bottom, 16) - .padding(.top, 6) - .background(Color(Asset.Colors.smoke.color)) - .previewLayout(.sizeThatFits) - } -} - -// MARK: - Action - -extension ActionsView { - enum Action { - case buy - case receive - case swap - case send - case cashOut - } -} - -// MARK: - View Height - -extension ActionsView { - var viewHeight: CGFloat { - (UIScreen.main.bounds.width - 16 * 3) - + (UIApplication.shared.kWindow?.safeAreaInsets.bottom ?? 0) - } -} diff --git a/p2p_wallet/Scenes/Actions/ActionsViewActionType.swift b/p2p_wallet/Scenes/Actions/ActionsViewActionType.swift deleted file mode 100644 index f38cf4dfd4..0000000000 --- a/p2p_wallet/Scenes/Actions/ActionsViewActionType.swift +++ /dev/null @@ -1,41 +0,0 @@ -import KeyAppUI -import SwiftUI - -enum ActionsViewActionType: CaseIterable { - case bankTransfer - case bankCard - case crypto - - var title: String { - switch self { - case .bankTransfer: - return L10n.bankTransfer - case .bankCard: - return L10n.bankCard - case .crypto: - return L10n.crypto - } - } - - var subtitle: String { - switch self { - case .bankTransfer: - return L10n._1Fees - case .bankCard: - return L10n._45Fees - case .crypto: - return L10n._0Fees - } - } - - var icon: UIImage { - switch self { - case .bankTransfer: - return .addMoneyBankTransfer - case .bankCard: - return .addMoneyBankCard - case .crypto: - return .addMoneyCrypto - } - } -} diff --git a/p2p_wallet/Scenes/ChoosePhoneCode/ChoosePhoneCodeService.swift b/p2p_wallet/Scenes/ChoosePhoneCode/ChoosePhoneCodeService.swift new file mode 100644 index 0000000000..55fcd8ae9d --- /dev/null +++ b/p2p_wallet/Scenes/ChoosePhoneCode/ChoosePhoneCodeService.swift @@ -0,0 +1,53 @@ +import Combine +import CountriesAPI +import KeyAppKitCore +import Resolver + +final class ChoosePhoneCodeService: ChooseItemService { + let chosenTitle = L10n.selected + let otherTitle = L10n.allCountries + let emptyTitle = L10n.nothingWasFound + + var state: AnyPublisher, Never> { + statePublisher.eraseToAnyPublisher() + } + + @Injected private var countriesService: CountriesAPI + private let statePublisher: CurrentValueSubject, Never> + + init() { + statePublisher = CurrentValueSubject, Never>( + AsyncValueState(status: .fetching, value: []) + ) + + Task { + do { + let countries = try await self.countriesService.fetchCountries().unique + .map { PhoneCodeItem(country: $0) } + let uniqueCountries = Array(Set(countries)) + self.statePublisher.send( + AsyncValueState(status: .ready, value: [ChooseItemListSection(items: uniqueCountries)]) + ) + } catch { + DefaultLogManager.shared.log(event: "Error", logLevel: .error, data: error.localizedDescription) + self.statePublisher.send( + AsyncValueState(status: .ready, value: [ChooseItemListSection(items: [])], error: error) + ) + } + } + } + + func sort(items: [ChooseItemListSection]) -> [ChooseItemListSection] { + let newItems = items.map { section in + guard let countries = section.items as? [PhoneCodeItem] else { return section } + return ChooseItemListSection(items: countries + .sorted(by: { $0.country.name.lowercased() < $1.country.name.lowercased() })) + } + let isEmpty = newItems.flatMap(\.items).isEmpty + return isEmpty ? [] : newItems + } + + func sortFiltered(by _: String, items: [ChooseItemListSection]) -> [ChooseItemListSection] { + sort(items: items) + } +} diff --git a/p2p_wallet/Scenes/ChoosePhoneCode/PhoneCodeItem.swift b/p2p_wallet/Scenes/ChoosePhoneCode/PhoneCodeItem.swift new file mode 100644 index 0000000000..623b68323a --- /dev/null +++ b/p2p_wallet/Scenes/ChoosePhoneCode/PhoneCodeItem.swift @@ -0,0 +1,42 @@ +import CountriesAPI + +struct PhoneCodeItem { + let id: String + let country: Country + + init(country: Country) { + id = country.id + self.country = country + } + + init?(country: Country?) { + if let country { + id = country.id + self.country = country + } else { + return nil + } + } +} + +extension PhoneCodeItem: ChooseItemSearchableItem { + func matches(keyword: String) -> Bool { + country.matches(keyword: keyword) + } +} + +extension PhoneCodeItem: ChooseItemRenderable { + func render() -> EmojiTitleCellView { + EmojiTitleCellView(emoji: country.emoji ?? "", name: country.name, subtitle: country.dialCode) + } +} + +extension PhoneCodeItem: Equatable, Hashable { + static func == (lhs: PhoneCodeItem, rhs: PhoneCodeItem) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/p2p_wallet/Scenes/DebugMenu/View/DebugMenuView.swift b/p2p_wallet/Scenes/DebugMenu/View/DebugMenuView.swift index 7e12541113..ec4c2538a2 100644 --- a/p2p_wallet/Scenes/DebugMenu/View/DebugMenuView.swift +++ b/p2p_wallet/Scenes/DebugMenu/View/DebugMenuView.swift @@ -8,6 +8,7 @@ struct DebugMenuView: View { @ObservedObject private var globalAppState = GlobalAppState.shared @ObservedObject private var feeRelayerConfig = FeeRelayConfig.shared + @ObservedObject private var onboardingConfig = OnboardingConfig.shared init(viewModel: DebugMenuViewModel) { self.viewModel = viewModel @@ -19,6 +20,7 @@ struct DebugMenuView: View { Group { solanaEndpoint swapEndpoint + strigaEndpoint nameServiceEndpoint } @@ -149,4 +151,25 @@ struct DebugMenuView: View { } } } + + var strigaEndpoint: some View { + Section(header: Text("Striga endpoint")) { + Picker("URL", selection: $globalAppState.strigaEndpoint) { + Text("Unknown").tag(nil as String?) + ForEach(viewModel.strigaEndpoints, id: \.self) { endpoint in + Text(endpoint).tag(endpoint as String?) + } + } + + Toggle("Mocking enabled", isOn: $globalAppState.strigaMockingEnabled) + + Button { + Task { + try? await viewModel.clearStrigaUserIdFromMetadata() + } + } label: { + Text("Remove userId from metadata") + } + } + } } diff --git a/p2p_wallet/Scenes/DebugMenu/ViewModel/DebugMenuViewModel.swift b/p2p_wallet/Scenes/DebugMenu/ViewModel/DebugMenuViewModel.swift index 396524ac32..be02e7f692 100644 --- a/p2p_wallet/Scenes/DebugMenu/ViewModel/DebugMenuViewModel.swift +++ b/p2p_wallet/Scenes/DebugMenu/ViewModel/DebugMenuViewModel.swift @@ -1,9 +1,12 @@ +import BankTransfer import Combine import FirebaseRemoteConfig import KeyAppBusiness +import Onboarding import Resolver import SolanaSwift import SwiftyUserDefaults +import UIKit final class DebugMenuViewModel: BaseViewModel, ObservableObject { @Published var features: [FeatureItem] @@ -13,6 +16,7 @@ final class DebugMenuViewModel: BaseViewModel, ObservableObject { @Published var currentMoonpayEnvironment: DefaultsKeys.MoonpayEnvironment @Published var nameServiceEndpoints: [String] @Published var newSwapEndpoints: [String] + @Published var strigaEndpoints: [String] override init() { features = Menu.allCases @@ -49,6 +53,12 @@ final class DebugMenuViewModel: BaseViewModel, ObservableObject { "https://swap.keyapp.org", ] + strigaEndpoints = [ + .secretConfig("STRIGA_PROXY_API_ENDPOINT_PROD")!, + .secretConfig("STRIGA_PROXY_API_ENDPOINT_DEV")!, + .secretConfig("STRIGA_PROXY_API_ENDPOINT_DEV_NEW")!, + ] + currentMoonpayEnvironment = Defaults.moonpayEnvironment super.init() @@ -78,6 +88,24 @@ final class DebugMenuViewModel: BaseViewModel, ObservableObject { ) ) } + + func clearStrigaUserIdFromMetadata() async throws { + await Resolver.resolve((any BankTransferService).self).clearCache() + + let service = Resolver.resolve(WalletMetadataService.self) + + if var currentMetadata = service.metadata.value { + currentMetadata.striga.userId = nil + await service.update(currentMetadata) + } + + Resolver.resolve(NotificationService.self).showToast(title: "Deleted", text: "Metadata deleted from Keychain") + } + + func copyMetadata() { + UIPasteboard.general.string = Resolver.resolve(WalletMetadataService.self).metadata.value?.jsonString + Resolver.resolve(NotificationService.self).showToast(title: "Copied", text: "Metadata copied to clipboard") + } } extension DebugMenuViewModel { @@ -105,6 +133,7 @@ extension DebugMenuViewModel { case sendViaLink case solanaEthAddressEnabled case swapTransactionSimulation + case bankTransfer var title: String { switch self { @@ -121,6 +150,7 @@ extension DebugMenuViewModel { case .sendViaLink: return "Send via link" case .solanaEthAddressEnabled: return "solana ETH address enabled" case .swapTransactionSimulation: return "Swap transaction simulation" + case .bankTransfer: return "Striga" } } @@ -139,6 +169,7 @@ extension DebugMenuViewModel { case .sendViaLink: return .sendViaLinkEnabled case .solanaEthAddressEnabled: return .solanaEthAddressEnabled case .swapTransactionSimulation: return .swapTransactionSimulationEnabled + case .bankTransfer: return .bankTransfer } } } diff --git a/p2p_wallet/Scenes/Main/BankTransfer/BankTransferCoordinator.swift b/p2p_wallet/Scenes/Main/BankTransfer/BankTransferCoordinator.swift new file mode 100644 index 0000000000..29e71d2723 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/BankTransferCoordinator.swift @@ -0,0 +1,148 @@ +import BankTransfer +import Combine +import CountriesAPI +import Foundation +import Resolver +import UIKit + +final class BankTransferCoordinator: Coordinator { + @Injected private var bankTransferService: any BankTransferService + + private let viewController: UINavigationController + + init(viewController: UINavigationController) { + self.viewController = viewController + } + + enum BankTransferFlowResult { + case next + case none + case completed + } + + override func start() -> AnyPublisher { + bankTransferService.state + .prefix(1) + .receive(on: RunLoop.main) + .flatMap { [unowned self] state in + coordinator( + for: step(userData: state.value), + userData: state.value + ).flatMap { [unowned self] result in + switch result { + case .next: + return coordinate( + to: BankTransferCoordinator(viewController: viewController) + ).eraseToAnyPublisher() + case .none, .completed: + return Just(()).eraseToAnyPublisher() + } + } + } + .prefix(1) + .eraseToAnyPublisher() + } + + private func step(userData: UserData) -> BankTransferStep { + // registration + guard userData.userId != nil else { + return .registration + } + + // mobile verification + guard userData.mobileVerified else { + return .otp + } + + // kyc + switch userData.kycStatus { + case .approved: + guard let data = userData.wallet?.accounts.eur else { return .kyc } + return .transfer(data) + case .onHold, .pendingReview: + return .kycPendingReview + case .initiated, .notStarted, .rejected, .rejectedFinal: + return .kyc + } + } + + private func coordinator(for step: BankTransferStep, + userData: UserData) -> AnyPublisher + { + switch step { + case .registration: + return coordinate( + to: BankTransferInfoCoordinator(navigationController: viewController) + ).map { result in + switch result { + case .completed: + return BankTransferFlowResult.next + case .canceled: + return BankTransferFlowResult.none + } + } + .eraseToAnyPublisher() + case .otp: + return coordinate( + to: StrigaOTPCoordinator( + navigationController: viewController, + phone: userData.mobileNumber ?? "", + verifyHandler: { otp in + try await Resolver.resolve((any BankTransferService).self).verify(OTP: otp) + }, + resendHandler: { + try await Resolver.resolve((any BankTransferService).self).resendSMS() + } + ) + ) + .flatMap { [unowned self] result in + switch result { + case .verified: + return coordinate( + to: StrigaOTPSuccessCoordinator( + navigationController: viewController + ) + ) + .map { _ in BankTransferFlowResult.next } + .eraseToAnyPublisher() + case .canceled: + return Just(BankTransferFlowResult.none) + .eraseToAnyPublisher() + } + } + .eraseToAnyPublisher() + case .kyc: + return coordinate( + to: KYCCoordinator(presentingViewController: viewController) + ).map { result in + switch result { + case .pass: + return BankTransferFlowResult.next + case .canceled: + return BankTransferFlowResult.none + } + } + .eraseToAnyPublisher() + case .kycPendingReview: + return coordinate( + to: StrigaVerificationPendingSheetCoordinator(presentingViewController: viewController) + ) + .map { _ in BankTransferFlowResult.none } + .eraseToAnyPublisher() + case let .transfer(eurAccount): + return coordinate( + to: IBANDetailsCoordinator(navigationController: viewController, eurAccount: eurAccount) + ) + .map { BankTransferFlowResult.completed } + .eraseToAnyPublisher() + } + } +} + +enum BankTransferStep { + case registration + case otp + case kyc + case kycPendingReview + case transfer(EURUserAccount) +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/Claim/BankTransferClaimCoordinator.swift b/p2p_wallet/Scenes/Main/BankTransfer/Claim/BankTransferClaimCoordinator.swift new file mode 100644 index 0000000000..2db1591551 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/Claim/BankTransferClaimCoordinator.swift @@ -0,0 +1,167 @@ +import BankTransfer +import Combine +import Foundation +import Onboarding +import Resolver +import SwiftyUserDefaults +import UIKit + +/// Result for `BankTransferClaimCoordinator` +enum BankTransferClaimCoordinatorResult { + /// Transaction has been successfully created + case completed(PendingTransaction) + /// Transaction has been cancelled + case canceled +} + +/// Coordinator that controlls claim operation +final class BankTransferClaimCoordinator: Coordinator { + // MARK: - Dependencies + + @Injected private var bankTransferService: AnyBankTransferService + + // MARK: - Properties + + private let navigationController: UINavigationController + private let transaction: any StrigaConfirmableTransactionType + + private let subject = PassthroughSubject() + + // Request otp timer properties + @SwiftyUserDefault(keyPath: \.strigaOTPResendCounter, options: .cached) + private var resendCounter: ResendCounter? + + // MARK: - Initialization + + init( + navigationController: UINavigationController, + transaction: any StrigaConfirmableTransactionType + ) { + self.navigationController = navigationController + self.transaction = transaction + super.init() + increaseTimer() // We need to increase time because transaction once was called before, then resend logic will + // appear + } + + // MARK: - Methods + + override func start() -> AnyPublisher { + // Start OTP Coordinator + bankTransferService.value.state + .prefix(1) + .receive(on: RunLoop.main) + .flatMap { [unowned self] state in + guard let phone = state.value.mobileNumber else { + return Just(StrigaOTPCoordinatorResult.canceled) + .eraseToAnyPublisher() + } + + return coordinate( + to: StrigaOTPCoordinator( + navigationController: navigationController, + phone: phone, + navigation: .nextToRoot, + verifyHandler: { [unowned self] _ in + guard let userId = await bankTransferService.value.repository.getUserId() else { + throw BankTransferError.missingMetadata + } + try? await Task.sleep(seconds: 1) +// try await bankTransferService.value.repository +// .claimVerify( +// userId: userId, +// challengeId: transaction.challengeId, +// ip: getIPAddress(), +// verificationCode: otp +// ) + }, + resendHandler: { [unowned self] in + guard let userId = await bankTransferService.value.repository.getUserId() else { + throw BankTransferError.missingMetadata + } + try await bankTransferService.value.repository.claimResendSMS( + userId: userId, + challengeId: transaction.challengeId + ) + } + ) + ) + } + .handleEvents(receiveOutput: { [unowned self] result in + switch result { + case .verified: + navigationController.popToRootViewController(animated: true) + case .canceled: + navigationController.popViewController(animated: true) + } + }) + .map { [unowned self] result -> BankTransferClaimCoordinatorResult in + switch result { + case .verified: + let transactionIndex = Resolver.resolve(TransactionHandlerType.self) + .sendTransaction(transaction, status: .sending) + // return pending transaction + let pendingTransaction = PendingTransaction( + trxIndex: transactionIndex, + sentAt: Date(), + rawTransaction: transaction, + status: .sending + ) + return BankTransferClaimCoordinatorResult.completed(pendingTransaction) + case .canceled: + return BankTransferClaimCoordinatorResult.canceled + } + }.eraseToAnyPublisher() + } + + // Start OTP request timer + private func increaseTimer() { + if let resendCounter { + self.resendCounter = resendCounter.incremented() + } else { + resendCounter = .zero() + } + } +} + +// MARK: - Helpers + +private func getIPAddress() -> String { + var address: String? + var ifaddr: UnsafeMutablePointer? = nil + if getifaddrs(&ifaddr) == 0 { + var ptr = ifaddr + while ptr != nil { + defer { ptr = ptr?.pointee.ifa_next } + + guard let interface = ptr?.pointee else { return "" } + let addrFamily = interface.ifa_addr.pointee.sa_family + if addrFamily == UInt8(AF_INET) || addrFamily == UInt8(AF_INET6) { + // wifi = ["en0"] + // wired = ["en2", "en3", "en4"] + // cellular = ["pdp_ip0","pdp_ip1","pdp_ip2","pdp_ip3"] + + let name = String(cString: interface.ifa_name) + if name == "en0" || name == "en2" || name == "en3" || name == "en4" || name == "pdp_ip0" || name == + "pdp_ip1" || name == "pdp_ip2" || name == "pdp_ip3", + let socklen = try? socklen_t(interface.ifa_addr.pointee.sa_len) + { + var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + + getnameinfo( + interface.ifa_addr, + socklen, + &hostname, + socklen_t(hostname.count), + nil, + socklen_t(0), + NI_NUMERICHOST + ) + address = String(cString: hostname) + } + } + } + freeifaddrs(ifaddr) + } + return address ?? "" +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/Claim/StrigaClaimTransaction.swift b/p2p_wallet/Scenes/Main/BankTransfer/Claim/StrigaClaimTransaction.swift new file mode 100644 index 0000000000..450f016cc5 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/Claim/StrigaClaimTransaction.swift @@ -0,0 +1,128 @@ +import Foundation +import KeyAppBusiness +import KeyAppKitCore +import Resolver +import SolanaSwift + +protocol StrigaConfirmableTransactionType: RawTransactionType, Equatable { + var challengeId: String { get } +} + +/// Striga Claim trasaction type +protocol StrigaClaimTransactionType: RawTransactionType, Equatable { + var challengeId: String { get } + var token: TokenMetadata? { get } + var tokenPrice: TokenPrice? { get } + var amount: Double? { get } + var feeAmount: FeeAmount { get } + var fromAddress: String { get } + var receivingAddress: String { get } +} + +extension StrigaClaimTransactionType { + var amountInFiat: Double? { + guard let tokenPrice = tokenPrice?.doubleValue else { return nil } + return (amount ?? 0) * tokenPrice + } +} + +/// Default implemetation of `StrigaClaimTransactionType` +struct StrigaClaimTransaction: StrigaClaimTransactionType, StrigaConfirmableTransactionType { + // MARK: - Properties + + let challengeId: String + let token: TokenMetadata? + var tokenPrice: TokenPrice? + let amount: Double? + let feeAmount: FeeAmount + let fromAddress: String + let receivingAddress: String + + var mainDescription: String { + "" + } + + // MARK: - Methods + + func createRequest() async throws -> String { + // get transaction from proxy api + + // sign transaction + + // TODO: - send to blockchain + try? await Task.sleep(seconds: 1) + return .fakeTransactionSignature(id: UUID().uuidString) + } +} + +protocol StrigaWithdrawTransactionType: RawTransactionType { + var token: TokenMetadata? { get } + var tokenPrice: TokenPrice? { get } + var IBAN: String { get } + var BIC: String { get } + var feeAmount: FeeAmount { get } + var amount: Double { get } +} + +extension StrigaWithdrawTransactionType { + var amountInFiat: Double? { + guard let tokenPrice = tokenPrice?.doubleValue else { return nil } + return (amount ?? 0) * tokenPrice + } +} + +/// Default implemetation of `StrigaClaimTransactionType` +struct StrigaWithdrawTransaction: StrigaWithdrawTransactionType, StrigaConfirmableTransactionType { + // MARK: - Properties + + var challengeId: String + var IBAN: String + var BIC: String + var amount: Double + let token: TokenMetadata? + let tokenPrice: TokenPrice? + let feeAmount: FeeAmount + var mainDescription: String { + "" + } + + // MARK: - Methods + + func createRequest() async throws -> String { + // get transaction from proxy api + + // sign transaction + + // TODO: - send to blockchain + try? await Task.sleep(seconds: 1) + return .fakeTransactionSignature(id: UUID().uuidString) + } +} + +extension StrigaClaimTransaction: Equatable {} + +/// Used to wrap Send transaction into Striga Withdraw format +struct StrigaWithdrawSendTransaction: StrigaWithdrawTransactionType, RawTransactionType { + var sendTransaction: SendTransaction + var IBAN: String + var BIC: String + var amount: Double + var token: TokenMetadata? = .usdc + var tokenPrice: TokenPrice? + let feeAmount: FeeAmount + var mainDescription: String { + "" + } + + // MARK: - Methods + + func createRequest() async throws -> String { + // get transaction from proxy api + + // sign transaction + + // TODO: - send to blockchain + try? await Task.sleep(seconds: 1) + return .fakePausedTransactionSignaturePrefix + UUID().uuidString + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/CountryChoose/ChooseCountryService.swift b/p2p_wallet/Scenes/Main/BankTransfer/CountryChoose/ChooseCountryService.swift new file mode 100644 index 0000000000..75fe67590d --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/CountryChoose/ChooseCountryService.swift @@ -0,0 +1,47 @@ +import Combine +import CountriesAPI +import Foundation +import KeyAppKitCore +import Resolver + +final class ChooseCountryService: ChooseItemService { + let chosenTitle = L10n.chosenCountry + let otherTitle = L10n.allCountries + let emptyTitle = L10n.nothingWasFound + + var state: AnyPublisher, Never> { + statePublisher.eraseToAnyPublisher() + } + + @Injected private var countriesService: CountriesAPI + private let statePublisher: CurrentValueSubject, Never> + + init() { + statePublisher = CurrentValueSubject, Never>( + AsyncValueState(status: .fetching, value: []) + ) + + Task { + do { + let countries = try await self.countriesService.fetchCountries() + self.statePublisher.send( + AsyncValueState(status: .ready, value: [ChooseItemListSection(items: countries)]) + ) + } catch { + DefaultLogManager.shared.log(event: "Error", logLevel: .error, data: error.localizedDescription) + self.statePublisher.send( + AsyncValueState(status: .ready, value: [ChooseItemListSection(items: [])], error: error) + ) + } + } + } + + func sort(items: [ChooseItemListSection]) -> [ChooseItemListSection] { + let isEmpty = items.flatMap(\.items).isEmpty + return isEmpty ? [] : items + } + + func sortFiltered(by _: String, items: [ChooseItemListSection]) -> [ChooseItemListSection] { + sort(items: items) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/CountryChoose/Country+ChooseItem.swift b/p2p_wallet/Scenes/Main/BankTransfer/CountryChoose/Country+ChooseItem.swift new file mode 100644 index 0000000000..03ef7f6ef5 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/CountryChoose/Country+ChooseItem.swift @@ -0,0 +1,21 @@ +import Combine +import CountriesAPI +import Foundation + +extension Country: ChooseItemSearchableItem { + public var id: String { name } + + func matches(keyword: String) -> Bool { + name.lowercased().hasPrefix(keyword.lowercased()) + } +} + +extension Region: ChooseItemSearchableItem { + public var id: String { name } + + func matches(keyword: String) -> Bool { + name.lowercased().hasPrefix(keyword.lowercased()) + } +} + +extension Country: DefaultsSerializable {} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/CountryChoose/Country+ChooseItemRenderable.swift b/p2p_wallet/Scenes/Main/BankTransfer/CountryChoose/Country+ChooseItemRenderable.swift new file mode 100644 index 0000000000..9f25c5e3f2 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/CountryChoose/Country+ChooseItemRenderable.swift @@ -0,0 +1,14 @@ +import CountriesAPI +import Foundation + +extension Country: ChooseItemRenderable { + func render() -> EmojiTitleCellView { + EmojiTitleCellView(emoji: emoji ?? "", name: name, subtitle: nil) + } +} + +extension Region: ChooseItemRenderable { + func render() -> EmojiTitleCellView { + EmojiTitleCellView(emoji: flagEmoji ?? "", name: name, subtitle: nil) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/IBANDetails/IBANDetailsCoordinator.swift b/p2p_wallet/Scenes/Main/BankTransfer/IBANDetails/IBANDetailsCoordinator.swift new file mode 100644 index 0000000000..bf9fd2e2a9 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/IBANDetails/IBANDetailsCoordinator.swift @@ -0,0 +1,50 @@ +import BankTransfer +import Combine +import SwiftUI + +final class IBANDetailsCoordinator: Coordinator { + private let navigationController: UINavigationController + private let eurAccount: EURUserAccount + + @SwiftyUserDefault(keyPath: \.strigaIBANInfoDoNotShow, options: .cached) + private var strigaIBANInfoDoNotShow: Bool + + init(navigationController: UINavigationController, eurAccount: EURUserAccount) { + self.navigationController = navigationController + self.eurAccount = eurAccount + } + + override func start() -> AnyPublisher { + let viewModel = IBANDetailsViewModel(eurAccount: eurAccount) + let view = IBANDetailsView(viewModel: viewModel) + + let vc = view.asViewController(withoutUIKitNavBar: false) + vc.hidesBottomBarWhenPushed = true + vc.title = L10n.euroAccount + + navigationController.pushViewController(vc, animated: true) { [weak self] in + guard let self, self.strigaIBANInfoDoNotShow == false else { return } + self.openInfo() + } + + viewModel.warningTapped + .sink { [weak self] in self?.openInfo() } + .store(in: &subscriptions) + + return vc.deallocatedPublisher() + .prefix(1) + .eraseToAnyPublisher() + } + + private func openInfo() { + coordinate(to: IBANDetailsInfoCoordinator(navigationController: navigationController)) + .sink { _ in } + .store(in: &subscriptions) + } +} + +extension UserData { + var isIBANNotReady: Bool { + kycStatus == .approved && wallet?.accounts.eur?.enriched == false + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/IBANDetails/IBANDetailsView.swift b/p2p_wallet/Scenes/Main/BankTransfer/IBANDetails/IBANDetailsView.swift new file mode 100644 index 0000000000..1871708fdd --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/IBANDetails/IBANDetailsView.swift @@ -0,0 +1,90 @@ +import BankTransfer +import KeyAppUI +import SwiftUI + +struct IBANDetailsView: View { + @ObservedObject var viewModel: IBANDetailsViewModel + + var body: some View { + ColoredBackground { + ScrollView { + VStack(spacing: 20) { + BaseInformerView(data: BaseInformerViewItem( + icon: .sellPendingWarning, + iconColor: Asset.Colors.night, + title: L10n.yourBankAccountNameMustMatch(viewModel.informerName), + titleColor: Asset.Colors.cloud, + backgroundColor: Asset.Colors.night, + iconBackgroundColor: Asset.Colors.smoke + )) + .onTapGesture(perform: viewModel.warningTapped.send) + + VStack(spacing: 0) { + ForEach(viewModel.items, id: \.id) { item in + AnyRenderable(item: item) + } + } + .backgroundStyle(asset: Asset.Colors.snow) + + VStack(spacing: 0) { + buildSecondaryInformerView(title: L10n.ThisIsYourPersonalIBAN + .useThisDetailsToMakeTransfersThroughYourBankingApp, icon: .user) + + buildSecondaryInformerView(title: L10n + .weUseSEPAInstantForBankTransfersAndTypicallyMoneyWillAppearInYourAccountInLessThanAMinute, + icon: .historyFilled) + } + .backgroundStyle(asset: Asset.Colors.rain) + } + .padding(.vertical, 28) + .padding(.horizontal, 16) + } + } + } + + private func buildSecondaryInformerView(title: String, icon: UIImage) -> BaseInformerView { + BaseInformerView( + data: BaseInformerViewItem( + icon: icon, + iconColor: Asset.Colors.night, + title: title, + backgroundColor: Asset.Colors.rain, + iconBackgroundColor: Asset.Colors.smoke + ) + ) + } +} + +struct IBANDetailsView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + IBANDetailsView( + viewModel: IBANDetailsViewModel( + eurAccount: EURUserAccount( + accountID: "", + currency: "", + createdAt: "", + enriched: true, + availableBalance: nil, + iban: "IBAN", + bic: "BIC", + bankAccountHolderName: "Name Surname" + ) + ) + ) + .navigationTitle(L10n.euroAccount) + .navigationBarTitleDisplayMode(.inline) + } + } +} + +// MARK: - Helpers + +private extension VStack { + func backgroundStyle(asset: ColorAsset) -> some View { + background( + RoundedRectangle(cornerRadius: 16) + .foregroundColor(Color(asset: asset)) + ) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/IBANDetails/IBANDetailsViewModel.swift b/p2p_wallet/Scenes/Main/BankTransfer/IBANDetails/IBANDetailsViewModel.swift new file mode 100644 index 0000000000..71de872edf --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/IBANDetails/IBANDetailsViewModel.swift @@ -0,0 +1,43 @@ +import BankTransfer +import Combine +import KeyAppUI +import Resolver +import SwiftUI +import SwiftyUserDefaults + +final class IBANDetailsViewModel: BaseViewModel, ObservableObject { + @Injected private var notificationService: NotificationService + + @Published var informerName: String + @Published var items: [any Renderable] = [] + let warningTapped = PassthroughSubject() + + init(eurAccount: EURUserAccount) { + informerName = eurAccount.bankAccountHolderName ?? "" + super.init() + items = makeItems(from: eurAccount) + } + + private func makeItems(from account: EURUserAccount) -> [any Renderable] { + [ + IBANDetailsCellViewItem(title: L10n.iban, subtitle: account.iban?.formatIBAN() ?? "") { [weak self] in + self?.copy(value: account.iban) + }, + ListSpacerCellViewItem(height: 1.0, backgroundColor: Color(asset: Asset.Colors.rain), leadingPadding: 20.0), + IBANDetailsCellViewItem(title: L10n.currency, subtitle: account.currency, copyAction: nil), + IBANDetailsCellViewItem(title: L10n.bic, subtitle: account.bic ?? "") { [weak self] in + self?.copy(value: account.bic) + }, + IBANDetailsCellViewItem(title: L10n.beneficiary, + subtitle: account.bankAccountHolderName ?? "") + { [weak self] in + self?.copy(value: account.bankAccountHolderName) + }, + ] + } + + private func copy(value: String?) { + UIPasteboard.general.string = value + notificationService.showToast(title: "✅", text: L10n.copied) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/IBANDetails/Info/IBANDetailsInfoCoordinator.swift b/p2p_wallet/Scenes/Main/BankTransfer/IBANDetails/Info/IBANDetailsInfoCoordinator.swift new file mode 100644 index 0000000000..31c488ff4f --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/IBANDetails/Info/IBANDetailsInfoCoordinator.swift @@ -0,0 +1,34 @@ +import AnalyticsManager +import Combine +import Foundation +import KeyAppUI +import Resolver +import SwiftUI + +final class IBANDetailsInfoCoordinator: Coordinator { + private let navigationController: UINavigationController + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + + override func start() -> AnyPublisher { + let viewModel = IBANDetailsInfoViewModel() + let controller = BottomSheetController( + rootView: IBANDetailsInfoView(viewModel: viewModel) + ) + controller.view.backgroundColor = Asset.Colors.smoke.color + viewModel.close + .sink { [weak controller] _ in + controller?.dismiss(animated: true) + } + .store(in: &subscriptions) + + navigationController.present(controller, animated: true) + + return controller + .deallocatedPublisher() + .prefix(1) + .eraseToAnyPublisher() + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/IBANDetails/Info/IBANDetailsInfoView.swift b/p2p_wallet/Scenes/Main/BankTransfer/IBANDetails/Info/IBANDetailsInfoView.swift new file mode 100644 index 0000000000..2986bd338e --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/IBANDetails/Info/IBANDetailsInfoView.swift @@ -0,0 +1,100 @@ +import Combine +import KeyAppUI +import SwiftUI + +struct IBANDetailsInfoView: View { + @ObservedObject var viewModel: IBANDetailsInfoViewModel + + var body: some View { + VStack(spacing: 8) { + RoundedRectangle(cornerRadius: 2, style: .circular) + .fill(Color(Asset.Colors.rain.color)) + .frame(width: 31, height: 4) + .padding(.top, 6) + + title + + VStack(spacing: 0) { + infoItem( + text: L10n.useThisIBANToSendMoneyFromYourPersonalAccounts, + icon: .user + ) + Rectangle() + .frame(height: 1) + .padding(.leading, 20) + .foregroundColor(Color(asset: Asset.Colors.rain)) + infoItem( + text: L10n.yourBankAccountNameMustMatchTheNameOfYourKeyAppAccount, + icon: .buyBank + ) + } + + checkbox + + NewTextButton( + title: L10n.gotIt, + style: .second, + expandable: true, + action: viewModel.close.send + ) + } + .padding(.horizontal, 16) + .padding(.bottom, 12) + .background(Color(Asset.Colors.smoke.color)) + .cornerRadius(20) + } + + func infoItem(text: String, icon: UIImage) -> some View { + HStack(spacing: 12) { + Image(uiImage: icon) + .resizable() + .renderingMode(.template) + .foregroundColor(Color(asset: Asset.Colors.night)) + .frame(width: 24, height: 24) + + Text(text) + .apply(style: .text3) + .foregroundColor(Color(asset: Asset.Colors.night)) + .fixedSize(horizontal: false, vertical: true) + + Spacer() + } + .padding(.vertical, 12) + .padding(.horizontal, 12) + .frame(height: 64) + } +} + +private extension IBANDetailsInfoView { + var title: some View { + Text(L10n.importantNotes) + .fontWeight(.semibold) + .apply(style: .title3) + .padding(.top, 12) + .padding(.bottom, 20) + } + + var checkbox: some View { + HStack(spacing: 12) { + CheckboxView(isChecked: $viewModel.isChecked) + + Text(L10n.donTShowMeAgain) + .apply(style: .text3) + .foregroundColor(Color(asset: Asset.Colors.night)) + + Spacer() + } + .padding(.top, 24) + .padding(.horizontal, 16) + .padding(.bottom, 40) + .onTapGesture { [weak viewModel] in + viewModel?.isChecked.toggle() + } + } +} + +struct IBANDetailsInfoView_Previews: PreviewProvider { + static var previews: some View { + IBANDetailsInfoView(viewModel: IBANDetailsInfoViewModel()) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/IBANDetails/Info/IBANDetailsInfoViewModel.swift b/p2p_wallet/Scenes/Main/BankTransfer/IBANDetails/Info/IBANDetailsInfoViewModel.swift new file mode 100644 index 0000000000..eb6348152c --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/IBANDetails/Info/IBANDetailsInfoViewModel.swift @@ -0,0 +1,24 @@ +import BankTransfer +import Combine +import Foundation +import Onboarding +import Resolver +import UIKit + +final class IBANDetailsInfoViewModel: BaseViewModel, ObservableObject { + @Published var isChecked = false + let close = PassthroughSubject() + + @SwiftyUserDefault(keyPath: \.strigaIBANInfoDoNotShow, options: .cached) + private var strigaIBANInfoDoNotShow: Bool + + override init() { + super.init() + + isChecked = strigaIBANInfoDoNotShow + + $isChecked + .assignWeak(to: \.strigaIBANInfoDoNotShow, on: self) + .store(in: &subscriptions) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/IBANDetails/Subview/IBANDetailsCellView.swift b/p2p_wallet/Scenes/Main/BankTransfer/IBANDetails/Subview/IBANDetailsCellView.swift new file mode 100644 index 0000000000..6e7845853c --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/IBANDetails/Subview/IBANDetailsCellView.swift @@ -0,0 +1,43 @@ +import KeyAppUI +import SwiftUI + +struct IBANDetailsCellViewItem: Identifiable { + let title: String + let subtitle: String + let copyAction: (() -> Void)? + var id: String { title } +} + +extension IBANDetailsCellViewItem: Renderable { + func render() -> some View { + IBANDetailsCellView(data: self) + } +} + +struct IBANDetailsCellView: View { + let data: IBANDetailsCellViewItem + + var body: some View { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text(data.title) + .apply(style: .text3, weight: .semibold) + .foregroundColor(Color(asset: Asset.Colors.night)) + + Text(data.subtitle) + .apply(style: .label1) + .foregroundColor(Color(asset: Asset.Colors.mountain)) + } + + Spacer() + + if let action = data.copyAction { + Button(action: action) { + Image(uiImage: .copyLined) + .padding(.all, 8) + } + } + } + .padding(.all, 16) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/Info/BankTransferInfoCoordinator.swift b/p2p_wallet/Scenes/Main/BankTransfer/Info/BankTransferInfoCoordinator.swift new file mode 100644 index 0000000000..1722b2ce77 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/Info/BankTransferInfoCoordinator.swift @@ -0,0 +1,78 @@ +import BankTransfer +import Combine +import CountriesAPI +import Foundation +import Resolver +import SafariServices + +enum BankTransferInfoCoordinatorResult { + case canceled + case completed +} + +final class BankTransferInfoCoordinator: Coordinator { + private let navigationController: UINavigationController + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + + override func start() -> AnyPublisher { + let viewModel = BankTransferInfoViewModel() + let controller = BankTransferInfoView(viewModel: viewModel).asViewController( + withoutUIKitNavBar: false, + ignoresKeyboard: true + ) + controller.hidesBottomBarWhenPushed = true + + viewModel.openBrowser.flatMap { [weak controller] url in + let safari = SFSafariViewController(url: url) + controller?.show(safari, sender: nil) + return safari.deallocatedPublisher() + }.sink {}.store(in: &subscriptions) + + navigationController.pushViewController(controller, animated: true) + + return Publishers.Merge( + // Ignore deallocation event if open registration triggered + Publishers.Merge( + controller.deallocatedPublisher().map { true }, + viewModel.openRegistration.map { _ in false } + ) + .prefix(1) + .filter { $0 } + .map { _ in BankTransferInfoCoordinatorResult.canceled } + .eraseToAnyPublisher(), + viewModel.openRegistration + .flatMap { [unowned self] in + self.coordinate( + to: StrigaRegistrationFirstStepCoordinator( + navigationController: self.navigationController + ) + ) + } + .handleEvents(receiveOutput: { [weak self] result in + guard let self else { return } + switch result { + case .completed: + self.navigationController.setViewControllers( + [self.navigationController.viewControllers.first!], + animated: false + ) + case .canceled: + break + } + }) + .map { result in + switch result { + case .completed: + return BankTransferInfoCoordinatorResult.completed + case .canceled: + return BankTransferInfoCoordinatorResult.canceled + } + } + ) + .prefix(1) + .eraseToAnyPublisher() + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/Info/BankTransferInfoView.swift b/p2p_wallet/Scenes/Main/BankTransfer/Info/BankTransferInfoView.swift new file mode 100644 index 0000000000..8d0dc7bd2a --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/Info/BankTransferInfoView.swift @@ -0,0 +1,91 @@ +import KeyAppUI +import SwiftUI + +struct BankTransferInfoView: View { + @ObservedObject var viewModel: BankTransferInfoViewModel + + var body: some View { + ColoredBackground { + VStack(spacing: 8) { + list + .padding(.top, 8) + + Spacer() + + termsAndConditions + .padding(.bottom, 20) + + action + .padding(.bottom, 12) + } + .padding(.horizontal, 16) + .navigationBarItems(trailing: Button( + action: viewModel.openHelp.send, + label: { + Image(uiImage: Asset.MaterialIcon.helpOutline.image) + .foregroundColor(Color(Asset.Colors.night.color)) + } + )) + } + } +} + +// MARK: - Subviews + +private extension BankTransferInfoView { + var list: some View { + VStack(spacing: 0) { + ForEach(viewModel.items, id: \.id) { item in + AnyRenderable(item: item) + } + .padding(.horizontal, 20) + } + } + + var termsAndConditions: some View { + VStack(spacing: 2) { + Text(L10n.byPressingTheButtonBelowYouAgree) + .styled(color: Asset.Colors.night) + HStack(spacing: 2) { + Text(L10n.to.lowercased()) + .styled(color: Asset.Colors.night) + Text(L10n.terms) + .underline(color: Color(Asset.Colors.snow.color)) + .styled(color: Asset.Colors.sky) + .onTapGesture(perform: viewModel.requestOpenTerms.send) + Text(L10n.and) + .styled(color: Asset.Colors.night) + Text(L10n.privacyPolicy) + .underline(color: Color(Asset.Colors.snow.color)) + .styled(color: Asset.Colors.sky) + .onTapGesture(perform: viewModel.requestOpenPrivacyPolicy.send) + } + } + } + + var action: NewTextButton { + NewTextButton( + title: L10n.continue, + style: .primaryWhite, + expandable: true, + isLoading: viewModel.isLoading, + trailing: Asset.MaterialIcon.arrowForward.image.withTintColor(Asset.Colors.lime.color), + action: viewModel.requestContinue.send + ) + } +} + +struct BankTransferInfoView_Previews: PreviewProvider { + static var previews: some View { + BankTransferInfoView(viewModel: .init()) + } +} + +private extension Text { + func styled(color: ColorAsset) -> some View { + foregroundColor(Color(color.color)) + .font(.system(size: UIFont.fontSize(of: .label1))) + .lineLimit(.none) + .multilineTextAlignment(.center) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/Info/BankTransferInfoViewModel.swift b/p2p_wallet/Scenes/Main/BankTransfer/Info/BankTransferInfoViewModel.swift new file mode 100644 index 0000000000..f129336358 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/Info/BankTransferInfoViewModel.swift @@ -0,0 +1,78 @@ +import BankTransfer +import Combine +import KeyAppUI +import Resolver +import SwiftUI +import SwiftyUserDefaults + +final class BankTransferInfoViewModel: BaseViewModel, ObservableObject { + // MARK: - Dependencies + + @Injected private var helpLauncher: HelpCenterLauncher + @Injected private var notificationService: NotificationService + + // MARK: - Properties + + @Published var items: [any Renderable] = [] + @Published var isLoading = false + + // MARK: - Actions + + let openHelp = PassthroughSubject() + let openRegistration = PassthroughSubject() + let openBrowser = PassthroughSubject() + + let requestContinue = PassthroughSubject() + let requestOpenTerms = PassthroughSubject() + let requestOpenPrivacyPolicy = PassthroughSubject() + + override init() { + super.init() + bind() + items = makeItems() + } + + private func bind() { + openHelp + .sink { [weak self] in self?.helpLauncher.launch() } + .store(in: &subscriptions) + + requestContinue + .sinkAsync { [weak self] in + self?.isLoading = true + try? await Task.sleep(seconds: 3) + self?.openRegistration.send(()) + self?.isLoading = false + } + .store(in: &subscriptions) + + requestOpenPrivacyPolicy + .sink { [weak self] in + self?.notificationService.showToast(title: "👹", text: "No URL YET") +// self?.openBrowser.send(URL(string: "")) //TODO: Add URL + } + .store(in: &subscriptions) + + requestOpenTerms + .sink { [weak self] in + self?.notificationService.showToast(title: "👹", text: "No URL YET") +// self?.openBrowser.send(URL(string: "")) //TODO: Add URL + } + .store(in: &subscriptions) + } + + private func makeItems() -> [any Renderable] { + [ + BankTransferInfoImageCellViewItem(image: .walletFound), + ListSpacerCellViewItem(height: 16, backgroundColor: .clear), + BankTransferTitleCellViewItem(title: L10n.openAccountForInstantInternationalTransfers), + ListSpacerCellViewItem(height: 12, backgroundColor: .clear), + CenterTextCellViewItem( + text: L10n + .thisAccountActsAsAnIntermediaryBetweenKeyAppAndOurBankingPartnerStrigaPaymentProviderWhichOperatesWithYourFiatMoney, + style: .text3, + color: Asset.Colors.night.color + ), + ] + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/KYC/KYCCoordinator.swift b/p2p_wallet/Scenes/Main/BankTransfer/KYC/KYCCoordinator.swift new file mode 100644 index 0000000000..42849d24ba --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/KYC/KYCCoordinator.swift @@ -0,0 +1,203 @@ +import BankTransfer +import Combine +import Foundation +import IdensicMobileSDK +import Resolver +import UIKit + +enum KYCCoordinatorResult { + case pass + case canceled +} + +enum KYCCoordinatorError: Error { + case sdkInitializationFailed +} + +final class KYCCoordinator: Coordinator { + // MARK: - Dependencies + + @Injected private var bankTransferService: any BankTransferService + @Injected private var notificationService: NotificationService + + // MARK: - Properties + + private var presentingViewController: UIViewController + private let subject = PassthroughSubject() + private var sdk: SNSMobileSDK! + private var status: SNSMobileSDK.Status? + + // MARK: - Initializer + + init( + presentingViewController: UINavigationController + ) { + self.presentingViewController = presentingViewController + } + + // MARK: - Methods + + override func start() -> AnyPublisher { + // showloading + presentingViewController.view.showIndetermineHud() + + // start sdk + Task { + do { + try await startSDK() + await hideHud() + } catch let error as NSError where error.isNetworkConnectionError { + notificationService.showConnectionErrorNotification() + await cancel() + } catch BankTransferError.kycVerificationInProgress { + await hideHud() + await MainActor.run { + coordinate( + to: StrigaVerificationPendingSheetCoordinator( + presentingViewController: presentingViewController + ) + ) + .map { _ in KYCCoordinatorResult.canceled } + .sink { [weak self] in self?.subject.send($0) } + .store(in: &subscriptions) + } + } catch { + // TODO: handle BankTransferError.kycRejectedCantRetry and BankTransferError.kycAttemptLimitExceeded when more info is provided + notificationService.showDefaultErrorNotification() + await cancel() + await logAlertMessage(error: error, source: "striga api") + } + } + + return subject.prefix(1).eraseToAnyPublisher() + } + + // MARK: - Helpers + + private func startSDK() async throws { + // get token + let accessToken = try await bankTransferService.getKYCToken() + + // initialize sdk + sdk = SNSMobileSDK( + accessToken: accessToken + ) + + // check if it is ready + guard sdk.isReady else { + print("Initialization failed: " + sdk.verboseStatus) + throw KYCCoordinatorError.sdkInitializationFailed + } + + // handle token expiration + sdk.setTokenExpirationHandler { onComplete in + print("Sumsub Token has expired -- renewing...") + Task { [weak self] in + do { + guard let newToken = try await self?.bankTransferService.getKYCToken() + else { + self?.didFailToReceiveToken(error: nil) + return + } + + await MainActor.run { + onComplete(newToken) + } + } catch { + self?.didFailToReceiveToken(error: error) + } + } + } + + bindStatusChange() + + // present sdk + presentKYC() + } + + private func presentKYC() { + // present + sdk.present(from: presentingViewController) + + // handle dismissal + sdk.dismissHandler { [weak subject] _, mainVC in + mainVC.dismiss(animated: true, completion: nil) + subject?.send(.canceled) + } + } + + private func cancel() async { + await hideHud() + subject.send(.canceled) + } + + private func hideHud() async { + await MainActor.run { + presentingViewController.view.hideHud() + } + } + + private func didFailToReceiveToken(error: Error?) { + if let error { + Task { + await logAlertMessage(error: error, source: "striga api") + } + } + sdk.dismiss() + } + + private func bindStatusChange() { + sdk.onStatusDidChange { [weak self] sdk, _ in + // assign status + self?.status = sdk.status + + // handle status + switch sdk.status { + case .initial, .incomplete, .temporarilyDeclined, .approved, .actionCompleted, .ready: + break + case .failed: + let failReason = "\(sdk.failReason)" + Task { [weak self] in + await self?.logAlertMessage( + error: SumsubSDKCustomError(message: failReason), + source: "KYC SDK" + ) + } + case .finallyRejected: + Task { [weak self] in + await self?.logAlertMessage( + error: SumsubSDKCustomError(message: "Applicant has been finally rejected"), + source: "KYC SDK" + ) + } + case .pending: + sdk.dismiss() + } + } + } + + private func logAlertMessage(error: Error, source: String) async { + let loggerData = await AlertLoggerDataBuilder.buildLoggerData(error: error) + + DefaultLogManager.shared.log( + event: "Striga Registration iOS Alarm", + logLevel: .alert, + data: StrigaRegistrationAlertLoggerMessage( + userPubkey: loggerData.userPubkey, + platform: loggerData.platform, + appVersion: loggerData.appVersion, + timestamp: loggerData.timestamp, + error: .init( + source: source, + kycSDKState: String(reflecting: status ?? .initial) + .replacingOccurrences(of: "SNSMobileSDK.Status.", with: ""), + error: loggerData.otherError ?? "" + ) + ) + ) + } +} + +private struct SumsubSDKCustomError: Error, Encodable { + let message: String? +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/Models/StrigaRegistrationAlertLoggerMessage.swift b/p2p_wallet/Scenes/Main/BankTransfer/Models/StrigaRegistrationAlertLoggerMessage.swift new file mode 100644 index 0000000000..e64ac7535b --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/Models/StrigaRegistrationAlertLoggerMessage.swift @@ -0,0 +1,27 @@ +import Foundation + +// MARK: - StrigaRegistrationAlertLoggerMessage + +struct StrigaRegistrationAlertLoggerMessage: Codable { + let userPubkey, platform, appVersion, timestamp: String + let error: StrigaRegistrationError + + enum CodingKeys: String, CodingKey { + case userPubkey = "user_pubkey" + case platform + case appVersion = "app_version" + case timestamp, error + } +} + +// MARK: - StrigaRegistrationError + +struct StrigaRegistrationError: Codable { + let source, kycSDKState, error: String + + enum CodingKeys: String, CodingKey { + case source + case kycSDKState = "kyc_sdk_state" + case error + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Error/StrigaRegistrationHardErrorCoordinator.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Error/StrigaRegistrationHardErrorCoordinator.swift new file mode 100644 index 0000000000..4099b7b470 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Error/StrigaRegistrationHardErrorCoordinator.swift @@ -0,0 +1,30 @@ +import Combine +import Resolver +import SwiftUI + +final class StrigaRegistrationHardErrorCoordinator: Coordinator { + @Injected private var helpLauncher: HelpCenterLauncher + private let navigationController: UINavigationController + private let openBlank = PassthroughSubject() + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + + override func start() -> AnyPublisher { + let view = StrigaRegistrationHardErrorView( + onAction: openBlank.send, + onSupport: { [weak self] in self?.helpLauncher.launch() } + ) + let vc = view.asViewController(withoutUIKitNavBar: false) + vc.modalPresentationStyle = .fullScreen + navigationController.present(vc, animated: true) + + return Publishers.Merge( + vc.deallocatedPublisher(), + openBlank + ) + .prefix(1) + .eraseToAnyPublisher() + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Error/StrigaRegistrationHardErrorView.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Error/StrigaRegistrationHardErrorView.swift new file mode 100644 index 0000000000..18b8cf5b1c --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Error/StrigaRegistrationHardErrorView.swift @@ -0,0 +1,39 @@ +import KeyAppUI +import SwiftUI + +struct StrigaRegistrationHardErrorView: View { + let onAction: () -> Void + let onSupport: () -> Void + + var body: some View { + HardErrorView( + title: L10n.seemsLikeThisNumberIsAlreadyUsed, + subtitle: L10n.WithNewDataYouCanTUseStrigaServiceForNow.bouYouStillHaveBankCardAndCryptoOptions, + image: .invest, + content: { + VStack(spacing: 12) { + NewTextButton( + title: L10n.openMyBlank, + style: .inverted, + expandable: true + ) { + onAction() + } + NewTextButton( + title: L10n.support, + style: .primary, + expandable: true + ) { + onSupport() + } + } + } + ) + } +} + +struct StrigaRegistrationHardErrorView_Previews: PreviewProvider { + static var previews: some View { + StrigaRegistrationHardErrorView(onAction: {}, onSupport: {}) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/FirstStep/StrigaRegistrationFirstStepCoordinator.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/FirstStep/StrigaRegistrationFirstStepCoordinator.swift new file mode 100644 index 0000000000..6a4d3c4bd6 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/FirstStep/StrigaRegistrationFirstStepCoordinator.swift @@ -0,0 +1,106 @@ +import BankTransfer +import Combine +import CountriesAPI +import SwiftUI + +enum StrigaRegistrationFirstStepCoordinatorResult { + case completed + case canceled +} + +final class StrigaRegistrationFirstStepCoordinator: Coordinator { + private let navigationController: UINavigationController + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + + override func start() -> AnyPublisher { + let viewModel = StrigaRegistrationFirstStepViewModel() + let view = StrigaRegistrationFirstStepView(viewModel: viewModel) + + let vc = view.asViewController(withoutUIKitNavBar: false) + vc.hidesBottomBarWhenPushed = true + vc.title = L10n.stepOf(1, 3) + + viewModel.back + .sink { [weak self] _ in + self?.navigationController.dismiss(animated: true) + } + .store(in: &subscriptions) + + viewModel.chooseCountry + .flatMap { [unowned self] in + coordinate(to: ChooseItemCoordinator( + title: L10n.selectYourCountry, + controller: navigationController, + service: ChooseCountryService(), + chosen: $0 + )) + } + .sink { [weak viewModel] result in + switch result { + case let .item(item): + guard let item = item as? Country else { return } + viewModel?.selectedCountryOfBirth = item + case .cancel: break + } + } + .store(in: &subscriptions) + + viewModel.choosePhoneCountryCode + .flatMap { [unowned self] in + coordinate(to: ChooseItemCoordinator( + title: L10n.selectYourCountry, + controller: navigationController, + service: ChoosePhoneCodeService(), + chosen: PhoneCodeItem(country: $0) + )) + } + .sink { [weak viewModel] result in + switch result { + case let .item(item): + guard let item = item as? PhoneCodeItem else { return } + viewModel?.selectedPhoneCountryCode = item.country + case .cancel: break + } + } + .store(in: &subscriptions) + + // We know we have come from BankTransferInfoCoordinator. We need to remove in from hierarchy + var newVCs = Array(navigationController.viewControllers.dropLast()) + newVCs.append(vc) + navigationController.setViewControllers(newVCs, animated: true) + + return Publishers.Merge( + vc.deallocatedPublisher() + .map { StrigaRegistrationFirstStepCoordinatorResult.canceled }, + viewModel.openNextStep.eraseToAnyPublisher() + .flatMap { [unowned self] response in + coordinateToNextStep(response: response) + // ignoring cancel events, to not pass this event out of Coordinator + .filter { $0 != .canceled } + } + .map { result in + switch result { + case .completed: + return StrigaRegistrationFirstStepCoordinatorResult.completed + case .canceled: + return StrigaRegistrationFirstStepCoordinatorResult.canceled + } + } + ) + .prefix(1) + .eraseToAnyPublisher() + } + + private func coordinateToNextStep(response: StrigaUserDetailsResponse) + -> AnyPublisher { + coordinate( + to: StrigaRegistrationSecondStepCoordinator( + navigationController: navigationController, + data: response + ) + ) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/FirstStep/StrigaRegistrationFirstStepView.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/FirstStep/StrigaRegistrationFirstStepView.swift new file mode 100644 index 0000000000..faee32c1ce --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/FirstStep/StrigaRegistrationFirstStepView.swift @@ -0,0 +1,146 @@ +import CountriesAPI +import KeyAppUI +import SwiftUI + +private typealias TextField = StrigaRegistrationTextField +private typealias Cell = StrigaFormCell +private typealias DetailedButton = StrigaRegistrationDetailedButton + +struct StrigaRegistrationFirstStepView: View { + @ObservedObject private var viewModel: StrigaRegistrationFirstStepViewModel + + @State private var focus: StrigaRegistrationField? + + init(viewModel: StrigaRegistrationFirstStepViewModel) { + self.viewModel = viewModel + } + + var body: some View { + if viewModel.isLoading { + ProgressView() + } else { + ColoredBackground { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + fields + ListSpacerCellView(height: 10, backgroundColor: .clear) + + NewTextButton( + title: viewModel.actionTitle.uppercaseFirst, + style: .primaryWhite, + expandable: true, + isEnabled: viewModel.isDataValid, + trailing: viewModel.isDataValid ? .arrowForward : nil, + action: { + resignFirstResponder() + viewModel.actionPressed.send() + } + ) + .padding(.bottom, 20) + } + .padding(.horizontal, 16) + } + } + .onDisappear { + resignFirstResponder() + } + } + } + + var fields: some View { + VStack(alignment: .leading, spacing: 14) { + StrigaRegistrationSectionView(title: L10n.personalInformation) + .padding(.horizontal, 9) + VStack(spacing: 23) { + Cell( + title: L10n.fullLegalFirstAndMiddleNames, + status: viewModel.fieldsStatuses[.firstName], + hint: L10n.spellYourNameExactlyAsItSShownOnYourPassportOrIDCard + ) { + TextField( + field: .firstName, + placeholder: L10n.firstName, + text: $viewModel.firstName, + focus: $focus, + onSubmit: { focus = .surname }, + submitLabel: .next + ) + } + + Cell( + title: L10n.fullLegalLastNameS, + status: viewModel.fieldsStatuses[.surname], + hint: L10n.spellYourNameExactlyAsItSShownOnYourPassportOrIDCard + ) { + TextField( + field: .surname, + placeholder: L10n.lastName, + text: $viewModel.surname, + focus: $focus, + onSubmit: { focus = .phoneNumber }, + submitLabel: .next + ) + } + + Cell( + title: L10n.phoneNumber, + status: viewModel.fieldsStatuses[.phoneNumber] + ) { + StrigaRegistrationPhoneTextField( + text: $viewModel.phoneNumber, + phoneNumber: $viewModel.phoneNumberModel, + country: $viewModel.selectedPhoneCountryCode, + countryTapped: { [weak viewModel] in + guard let viewModel else { return } + viewModel.choosePhoneCountryCode.send(viewModel.selectedPhoneCountryCode) + resignFirstResponder() + }, + focus: $focus + ) + } + + Cell( + title: L10n.dateOfBirth, + status: viewModel.fieldsStatuses[.dateOfBirth] + ) { + StrigaRegistrationDateTextField( + text: $viewModel.dateOfBirth, + focus: $focus + ) + } + + Cell( + title: L10n.countryOfBirth, + status: viewModel.fieldsStatuses[.countryOfBirth] + ) { + DetailedButton( + value: $viewModel.countryOfBirth, + action: { [weak viewModel] in + guard let viewModel else { return } + viewModel.chooseCountry.send(viewModel.selectedCountryOfBirth) + resignFirstResponder() + } + ) + } + } + } + } + + // When screen disappear via some action, you should called this method twice for some reason: on action and on + // disappear function + // Seems like a UI bug of iOS https://stackoverflow.com/a/74124962 + private func resignFirstResponder() { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + } +} + +struct StrigaRegistrationFirstStepView_Previews: PreviewProvider { + static var previews: some View { + StrigaRegistrationFirstStepView(viewModel: StrigaRegistrationFirstStepViewModel()) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/FirstStep/StrigaRegistrationFirstStepViewModel.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/FirstStep/StrigaRegistrationFirstStepViewModel.swift new file mode 100644 index 0000000000..0263992214 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/FirstStep/StrigaRegistrationFirstStepViewModel.swift @@ -0,0 +1,300 @@ +import BankTransfer +import Combine +import CountriesAPI +import Foundation +import Onboarding +import PhoneNumberKit +import Resolver + +final class StrigaRegistrationFirstStepViewModel: BaseViewModel, ObservableObject { + // Dependencies + @Injected private var service: any BankTransferService + @Injected private var countriesService: CountriesAPI + @Injected private var strigaMetadata: StrigaMetadataProvider + private let phoneNumberKit = PhoneNumberKit() + + // Data + private var data: StrigaUserDetailsResponse? + + // Loading state + @Published var isLoading = false + + // Fields + @Published var firstName: String = "" + @Published var surname: String = "" + @Published var dateOfBirth: String = "" + @Published var countryOfBirth: String = "" + // PhoneTextField + @Published var phoneNumber: String = "" + @Published var selectedPhoneCountryCode: Country? + @Published var phoneNumberModel: PhoneNumber? + + // Other views + @Published var actionTitle: String = L10n.next + @Published var isDataValid = + true // We need this flag to allow user enter at first whatever he/she likes and then validate everything + let actionPressed = PassthroughSubject() + let openNextStep = PassthroughSubject() + let chooseCountry = PassthroughSubject() + let choosePhoneCountryCode = PassthroughSubject() + let back = PassthroughSubject() + + var fieldsStatuses = [StrigaRegistrationField: StrigaFormTextFieldStatus]() + + @Published var selectedCountryOfBirth: Country? + @Published private var dateOfBirthModel: StrigaUserDetailsResponse.DateOfBirth? + + private lazy var birthMaxYear: Int = Date().year - Constants.maxYearGap + + private lazy var birthMinYear: Int = Date().year - Constants.minYearGap + + override init() { + super.init() + fetchSavedData() + + actionPressed + .sink { [weak self] _ in + guard let self = self else { return } + self.isDataValid = isValid() + if isValid(), let data = self.data { + self.openNextStep.send(data) + } + } + .store(in: &subscriptions) + + $isDataValid + .map { $0 ? L10n.next : L10n.checkRedFields } + .assignWeak(to: \.actionTitle, on: self) + .store(in: &subscriptions) + + $dateOfBirth + .map { $0.split(separator: ".").map { String($0) } } + .map { components in + if components.count == Constants.dateFormat.split(separator: ".").count { + return StrigaUserDetailsResponse.DateOfBirth( + year: components[2], + month: components[1], + day: components[0] + ) + } + return nil + } + .assignWeak(to: \.dateOfBirthModel, on: self) + .store(in: &subscriptions) + + $selectedCountryOfBirth + .map { model in + if let model { + return [model.emoji, model.name].compactMap { $0 }.joined(separator: " ") + } else { + return "" + } + } + .assignWeak(to: \.countryOfBirth, on: self) + .store(in: &subscriptions) + + $selectedPhoneCountryCode + .map { [weak self] value in + guard let self, let value else { return nil } + let number = try? self.phoneNumberKit.parse("\(value.dialCode)\(phoneNumber)") + return number + } + .assignWeak(to: \.phoneNumberModel, on: self) + .store(in: &subscriptions) + + bindToFieldValues() + } +} + +private extension StrigaRegistrationFirstStepViewModel { + func fetchSavedData() { + // Mark as isLoading + isLoading = true + + Task { + do { + guard let data = try await service.getRegistrationData() as? StrigaUserDetailsResponse + else { + throw StrigaProviderError.invalidResponse + } + + if let countries = try? await self.countriesService.fetchCountries() { + selectedCountryOfBirth = countries + .first(where: { $0.alpha3Code.lowercased() == data.placeOfBirth?.lowercased() }) + await fetchPhoneNumber(data: data, countries: countries) + } + + await MainActor.run { + // save data + self.data = data + isLoading = false + firstName = data.firstName + surname = data.lastName + dateOfBirthModel = data.dateOfBirth + dateOfBirth = [data.dateOfBirth?.day, data.dateOfBirth?.month, data.dateOfBirth?.year] + .compactMap { $0 } + .filter { $0 != "0" } + .map { + if $0.count == 1 { + return "0\($0)" + } + return $0 + } + .joined(separator: ".") + } + } catch { + self.data = StrigaUserDetailsResponse.empty + await MainActor.run { + isLoading = false + } + } + } + } + + func fetchPhoneNumber(data: StrigaUserDetailsResponse, countries: Countries) async { + let metadataService: WalletMetadataService = Resolver.resolve() + // Web3 phone by default + var metaPhoneNumber: String = metadataService.metadata.value?.phoneNumber ?? "" + // Use a phone from the local state if we have one + if !data.mobile.isEmpty, let dataMobileNumber = data.mobileNumber { + metaPhoneNumber = dataMobileNumber + } else if data.mobile.isEmpty, let strigaPhoneNumber = await strigaMetadata.getStrigaMetadata()?.phoneNumber { + metaPhoneNumber = strigaPhoneNumber + } + await MainActor.run { + phoneNumber = metaPhoneNumber + if let number = try? phoneNumberKit.parse(phoneNumber) { + phoneNumberModel = number + selectedPhoneCountryCode = countries.first(where: { + if let regionId = number.regionID { + return $0.code.lowercased() == regionId.lowercased() + } else { + return $0.dialCode == "+\(number.countryCode)" + } + }) + phoneNumber = phoneNumberKit.format(number, toType: .international, withPrefix: false) + .replacingOccurrences(of: "-", with: "") + } else { + selectedPhoneCountryCode = countries.first(where: { $0.dialCode == "\(data.mobile.countryCode)" }) + phoneNumber = data.mobile.number + } + } + } + + func isValid() -> Bool { + validatePhone() + validate(credential: firstName, field: .firstName) + validate(credential: surname, field: .surname) + validatePlaceOfBirth() + validateDate() + if countryOfBirth.isEmpty { + fieldsStatuses[.countryOfBirth] = .invalid(error: L10n.couldNotBeEmpty) + } + return !fieldsStatuses.contains(where: { $0.value != .valid }) + } + + func validatePlaceOfBirth() { + if selectedCountryOfBirth == nil { + fieldsStatuses[.countryOfBirth] = .invalid(error: L10n.couldNotBeEmpty) + } else { + fieldsStatuses[.countryOfBirth] = .valid + } + } + + func validatePhone() { + if phoneNumber.isEmpty || selectedPhoneCountryCode == nil { + fieldsStatuses[.phoneNumber] = .invalid(error: L10n.couldNotBeEmpty) + } else if phoneNumberModel != nil { + fieldsStatuses[.phoneNumber] = .valid + } else { + fieldsStatuses[.phoneNumber] = .invalid(error: L10n.incorrectNumber) + } + } + + func validate(credential: String, field: StrigaRegistrationField) { + if credential.trimmed().isEmpty { + fieldsStatuses[field] = .invalid(error: L10n.couldNotBeEmpty) + } else if credential.count < Constants.minCredentialSymbols { + fieldsStatuses[field] = .invalid(error: L10n.couldNotBeLessThanSymbols(Constants.minCredentialSymbols)) + } else if credential.count > Constants.maxCredentialSymbols { + fieldsStatuses[field] = .invalid(error: L10n.couldNotBeMoreThanSymbols(Constants.maxCredentialSymbols)) + } else { + fieldsStatuses[field] = .valid + } + } + + func validateDate() { + let year = Int(dateOfBirthModel?.year ?? "") ?? 0 + let month = Int(dateOfBirthModel?.month ?? "") ?? 0 + let day = Int(dateOfBirthModel?.day ?? "") ?? 0 + let components = DateComponents(year: year, month: month, day: day) + if dateOfBirth.isEmpty { + fieldsStatuses[.dateOfBirth] = .invalid(error: L10n.couldNotBeEmpty) + } else if year > birthMaxYear { + fieldsStatuses[.dateOfBirth] = .invalid(error: L10n.couldNotBeLater(birthMaxYear)) + } else if year < birthMinYear { + fieldsStatuses[.dateOfBirth] = .invalid(error: L10n.couldNotBeEarlier(birthMinYear)) + } else if month > 12 { + fieldsStatuses[.dateOfBirth] = .invalid(error: L10n.incorrectMonth) + } else if !components.isValidDate(in: Calendar.current) { + fieldsStatuses[.dateOfBirth] = .invalid(error: L10n.incorrectDay) + } else { + fieldsStatuses[.dateOfBirth] = .valid + } + } + + func bindToFieldValues() { + let contacts = Publishers.CombineLatest($selectedPhoneCountryCode, $phoneNumber) + let credentials = Publishers.CombineLatest($firstName, $surname) + let dateOfBirth = Publishers.CombineLatest($dateOfBirthModel, $selectedCountryOfBirth) + Publishers.CombineLatest3(contacts, credentials, dateOfBirth) + .debounce(for: 0.5, scheduler: DispatchQueue.main) + .sinkAsync { [weak self] contacts, credentials, dateOfBirth in + guard let self else { return } + + let mobile = StrigaUserDetailsResponse.Mobile( + countryCode: contacts.0?.dialCode ?? "", + number: contacts.1.replacingOccurrences(of: " ", with: "") + ) + + let currentData: StrigaUserDetailsResponse = (try? await service + .getRegistrationData() as? StrigaUserDetailsResponse) ?? .empty + let newData = currentData.updated( + firstName: credentials.0.trimmed(), + lastName: credentials.1.trimmed(), + mobile: mobile, + dateOfBirth: dateOfBirth.0, + placeOfBirth: dateOfBirth.1?.alpha3Code + ) + self.data = newData + try? await self.service.updateLocally(data: newData) + + if self.isDataValid == false { + self.isDataValid = self.isValid() + } + } + .store(in: &subscriptions) + } +} + +private extension StrigaRegistrationFirstStepViewModel { + enum Constants { + static let dateFormat = "dd.mm.yyyy" + static let minYearGap = 103 + static let maxYearGap = 8 + static let minCredentialSymbols = 2 + static let maxCredentialSymbols = 40 + } +} + +private extension Date { + var year: Int { + Calendar.current.dateComponents([.year], from: self).year ?? 0 + } +} + +private extension String { + func trimmed() -> String { + trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Models/StrigaRegistrationField.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Models/StrigaRegistrationField.swift new file mode 100644 index 0000000000..883547bda5 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Models/StrigaRegistrationField.swift @@ -0,0 +1,17 @@ +enum StrigaRegistrationField: Int, Identifiable { + var id: Int { rawValue } + + case email + case phoneNumber + case firstName + case surname + case dateOfBirth + case countryOfBirth + case occupationIndustry + case sourceOfFunds + case country + case city + case addressLine + case postalCode + case stateRegion +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/ChooseIndustry/ChooseIndustryDataLocalProvider.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/ChooseIndustry/ChooseIndustryDataLocalProvider.swift new file mode 100644 index 0000000000..a5564a2748 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/ChooseIndustry/ChooseIndustryDataLocalProvider.swift @@ -0,0 +1,46 @@ +import BankTransfer + +final class ChooseIndustryDataLocalProvider { + private func getEmoji(industry: StrigaUserIndustry) -> String { + switch industry { + case .accounting: return "🧮" + case .selfEmployed: return "😎" + case .audit: return "🔍" + case .finance: return "💰" + case .publicSectorAdministration: return "🏛️" + case .artEntertainment: return "🎨" + case .autoAviation: return "📐" + case .bankingLending: return "💵" + case .businessConsultancyLegal: return "⚖️" + case .constructionRepair: return "🧱" + case .educationProfessionalServices: return "📚" + case .informationalTechnologies: return "🖥" + case .tobaccoAlcohol: return "🍺" + case .gamingGambling: return "🕹️" + case .medicalServices: return "🌡️" + case .manufacturing: return "🏭" + case .prMarketing: return "🎉" + case .preciousGoodsJewelry: return "💎" + case .nonGovernmentalOrganization: return "🏢" + case .insuranceSecurity: return "📊" + case .retailWholesale: return "🛍️" + case .travelTourism: return "🏖️" + case .freelancer: return "👾" + case .student: return "🎓" + case .retired: return "🧢" + case .unemployed: return "😜" + } + } + + func getIndustries() -> [Industry] { + StrigaUserIndustry.allCases.map { industry in + Industry(emoji: getEmoji(industry: industry), title: industry.rawValue.formatted(), rawValue: industry) + } + } +} + +private extension String { + func formatted() -> String { + replacingOccurrences(of: "_", with: " ").lowercased().uppercaseFirst + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/ChooseIndustry/ChooseIndustryService.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/ChooseIndustry/ChooseIndustryService.swift new file mode 100644 index 0000000000..f820e50cc7 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/ChooseIndustry/ChooseIndustryService.swift @@ -0,0 +1,31 @@ +import Combine +import Foundation +import KeyAppKitCore + +final class ChooseIndustryService: ChooseItemService { + let chosenTitle = L10n.chosen + let otherTitle = L10n.allIndustries + let emptyTitle = L10n.notFound + + var state: AnyPublisher, Never> { + statePublisher.eraseToAnyPublisher() + } + + private let statePublisher: CurrentValueSubject, Never> + + init() { + let provider = ChooseIndustryDataLocalProvider() + statePublisher = CurrentValueSubject, Never>( + AsyncValueState(status: .ready, value: [ChooseItemListSection(items: provider.getIndustries())]) + ) + } + + func sort(items: [ChooseItemListSection]) -> [ChooseItemListSection] { + let isEmpty = items.flatMap(\.items).isEmpty + return isEmpty ? [] : items + } + + func sortFiltered(by _: String, items: [ChooseItemListSection]) -> [ChooseItemListSection] { + sort(items: items) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/ChooseIndustry/Industry.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/ChooseIndustry/Industry.swift new file mode 100644 index 0000000000..5218c0ce0d --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/ChooseIndustry/Industry.swift @@ -0,0 +1,24 @@ +import BankTransfer + +struct Industry: Identifiable { + let emoji: String + let title: String + let rawValue: StrigaUserIndustry + + var id: String { rawValue.rawValue } + var wholeName: String { + [emoji, title].joined(separator: " ") + } +} + +extension Industry: ChooseItemSearchableItem { + func matches(keyword: String) -> Bool { + title.hasPrefix(keyword) || title.contains(keyword) + } +} + +extension Industry: ChooseItemRenderable { + func render() -> EmojiTitleCellView { + EmojiTitleCellView(emoji: emoji, name: title, subtitle: nil) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/ChooseSourceOfFunds/ChooseSourceOfFundsService.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/ChooseSourceOfFunds/ChooseSourceOfFundsService.swift new file mode 100644 index 0000000000..7ce7a4c52d --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/ChooseSourceOfFunds/ChooseSourceOfFundsService.swift @@ -0,0 +1,30 @@ +import BankTransfer +import Combine +import KeyAppKitCore + +final class ChooseSourceOfFundsService: ChooseItemService { + let chosenTitle = L10n.chosen + let otherTitle = L10n.allSources + let emptyTitle = L10n.notFound + + var state: AnyPublisher, Never> { + statePublisher.eraseToAnyPublisher() + } + + private let statePublisher: CurrentValueSubject, Never> + + init() { + statePublisher = CurrentValueSubject, Never>( + AsyncValueState(status: .ready, value: [ChooseItemListSection(items: StrigaSourceOfFunds.allCases)]) + ) + } + + func sort(items: [ChooseItemListSection]) -> [ChooseItemListSection] { + let isEmpty = items.flatMap(\.items).isEmpty + return isEmpty ? [] : items + } + + func sortFiltered(by _: String, items: [ChooseItemListSection]) -> [ChooseItemListSection] { + sort(items: items) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/ChooseSourceOfFunds/StrigaSourceOfFunds+Extensions.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/ChooseSourceOfFunds/StrigaSourceOfFunds+Extensions.swift new file mode 100644 index 0000000000..cf3b751dc3 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/ChooseSourceOfFunds/StrigaSourceOfFunds+Extensions.swift @@ -0,0 +1,23 @@ +import BankTransfer + +extension StrigaSourceOfFunds { + var title: String { + rawValue.replacingOccurrences(of: "_", with: " ").lowercased().uppercaseFirst + } +} + +extension StrigaSourceOfFunds: Identifiable { + public var id: String { rawValue } +} + +extension StrigaSourceOfFunds: ChooseItemSearchableItem { + func matches(keyword: String) -> Bool { + title.hasPrefix(keyword) || title.contains(keyword) + } +} + +extension StrigaSourceOfFunds: ChooseItemRenderable { + func render() -> TitleCellView { + TitleCellView(title: title) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/StrigaRegistrationSecondStepCoordinator.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/StrigaRegistrationSecondStepCoordinator.swift new file mode 100644 index 0000000000..02203f0203 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/StrigaRegistrationSecondStepCoordinator.swift @@ -0,0 +1,101 @@ +import BankTransfer +import Combine +import CountriesAPI +import Resolver +import SwiftUI + +enum StrigaRegistrationSecondStepCoordinatorResult { + case canceled + case completed +} + +final class StrigaRegistrationSecondStepCoordinator: Coordinator { + private let result = PassthroughSubject() + private let navigationController: UINavigationController + private let data: StrigaUserDetailsResponse + + init(navigationController: UINavigationController, data: StrigaUserDetailsResponse) { + self.navigationController = navigationController + self.data = data + } + + override func start() -> AnyPublisher { + let viewModel = StrigaRegistrationSecondStepViewModel(data: data) + let view = StrigaRegistrationSecondStepView(viewModel: viewModel) + let vc = view.asViewController(withoutUIKitNavBar: false) + vc.title = L10n.stepOf(2, 3) + navigationController.pushViewController(vc, animated: true) + + viewModel.chooseIndustry + .flatMap { value in + self.coordinate(to: ChooseItemCoordinator( + title: L10n.selectYourIndustry, + controller: self.navigationController, + service: ChooseIndustryService(), + chosen: value, + isSearchEnabled: false + )) + } + .sink { [weak viewModel] result in + switch result { + case let .item(item): + viewModel?.selectedIndustry = item as? Industry + case .cancel: break + } + } + .store(in: &subscriptions) + + viewModel.chooseSourceOfFunds + .flatMap { value in + self.coordinate(to: ChooseItemCoordinator( + title: L10n.selectYourSourceOfFunds, + controller: self.navigationController, + service: ChooseSourceOfFundsService(), + chosen: value, + isSearchEnabled: false + )) + } + .sink { [weak viewModel] result in + switch result { + case let .item(item): + viewModel?.selectedSourceOfFunds = item as? StrigaSourceOfFunds + case .cancel: break + } + } + .store(in: &subscriptions) + + viewModel.chooseCountry.flatMap { value in + self.coordinate(to: ChooseItemCoordinator( + title: L10n.selectYourCountry, + controller: self.navigationController, + service: ChooseCountryService(), + chosen: value + )) + }.sink { [weak viewModel] result in + switch result { + case let .item(item): + viewModel?.selectedCountry = item as? Country + case .cancel: break + } + }.store(in: &subscriptions) + + viewModel.openHardError + .flatMap { [unowned self] in + self.coordinate(to: StrigaRegistrationHardErrorCoordinator(navigationController: navigationController)) + } + .sink { [weak self] in + self?.navigationController.popViewController(animated: false) + self?.navigationController.dismiss(animated: true) + } + .store(in: &subscriptions) + + return Publishers.Merge( + vc.deallocatedPublisher() + .map { StrigaRegistrationSecondStepCoordinatorResult.canceled }, + viewModel.openNextStep + .map { StrigaRegistrationSecondStepCoordinatorResult.completed } + ) + .prefix(1) + .eraseToAnyPublisher() + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/StrigaRegistrationSecondStepView.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/StrigaRegistrationSecondStepView.swift new file mode 100644 index 0000000000..585a2a2942 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/StrigaRegistrationSecondStepView.swift @@ -0,0 +1,157 @@ +import KeyAppUI +import SwiftUI + +private typealias TextField = StrigaRegistrationTextField +private typealias Cell = StrigaFormCell +private typealias DetailedButton = StrigaRegistrationDetailedButton + +struct StrigaRegistrationSecondStepView: View { + @ObservedObject private var viewModel: StrigaRegistrationSecondStepViewModel + @State private var focus: StrigaRegistrationField? + + init(viewModel: StrigaRegistrationSecondStepViewModel) { + self.viewModel = viewModel + } + + var body: some View { + ColoredBackground { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + sourceOfFundsSection + ListSpacerCellView(height: 10, backgroundColor: .clear) + addressSection + ListSpacerCellView(height: 12, backgroundColor: .clear) + NewTextButton( + title: viewModel.actionTitle.uppercaseFirst, + style: .primaryWhite, + expandable: true, + isEnabled: viewModel.isDataValid, + isLoading: viewModel.isLoading, + trailing: viewModel.isDataValid ? .arrowForward : nil, + action: viewModel.actionPressed.send + ) + .padding(.bottom, 14) + } + .padding(.horizontal, 16) + } + } + } + + var sourceOfFundsSection: some View { + VStack(alignment: .leading, spacing: 14) { + StrigaRegistrationSectionView(title: L10n.sourceOfFunds) + .padding(.horizontal, 9) + VStack(spacing: 23) { + Cell( + title: L10n.jobsIndustry, + status: viewModel.fieldsStatuses[.occupationIndustry] + ) { + DetailedButton(value: $viewModel.occupationIndustry, action: { + viewModel.chooseIndustry.send(viewModel.selectedIndustry) + }) + } + + Cell( + title: L10n.employmentIndustry, + status: viewModel.fieldsStatuses[.sourceOfFunds] + ) { + DetailedButton(value: $viewModel.sourceOfFunds, action: { + viewModel.chooseSourceOfFunds.send(viewModel.selectedSourceOfFunds) + }) + } + } + } + } + + var addressSection: some View { + VStack(alignment: .leading, spacing: 14) { + StrigaRegistrationSectionView(title: L10n.currentAddress) + .padding(.horizontal, 9) + VStack(spacing: 23) { + Cell( + title: L10n.country, + status: viewModel.fieldsStatuses[.country] + ) { + DetailedButton(value: $viewModel.country, action: { + viewModel.chooseCountry.send(viewModel.selectedCountry) + }) + } + + Cell( + title: L10n.city, + status: viewModel.fieldsStatuses[.city] + ) { + TextField( + field: .city, + placeholder: L10n.fullCityName, + text: $viewModel.city, + focus: $focus, + onSubmit: { focus = .addressLine }, + submitLabel: .next + ) + } + + Cell( + title: L10n.addressLine, + status: viewModel.fieldsStatuses[.addressLine] + ) { + TextField( + field: .addressLine, + placeholder: L10n.yourStreetAndFlatNumber, + text: $viewModel.addressLine, + focus: $focus, + onSubmit: { focus = .postalCode }, + submitLabel: .next + ) + } + + Cell( + title: L10n.postalCode, + status: viewModel.fieldsStatuses[.postalCode] + ) { + TextField( + field: .postalCode, + placeholder: L10n.yourPostalCode, + text: $viewModel.postalCode, + focus: $focus, + onSubmit: { focus = .stateRegion }, + submitLabel: .next + ) + } + + Cell( + title: L10n.stateOrRegion, + status: viewModel.fieldsStatuses[.stateRegion] + ) { + TextField( + field: .stateRegion, + placeholder: L10n.recommended, + text: $viewModel.stateRegion, + focus: $focus, + onSubmit: { focus = nil }, + submitLabel: .done + ) + } + } + } + } +} + +struct StrigaRegistrationSecondStepView_Previews: PreviewProvider { + static var previews: some View { + StrigaRegistrationSecondStepView( + viewModel: StrigaRegistrationSecondStepViewModel( + data: .init( + firstName: "test", + lastName: "test", + email: "test@test.com", + mobile: .init(countryCode: "", number: ""), + KYC: .init( + status: .notStarted, + mobileVerified: false + ) + ) + ) + ) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/StrigaRegistrationSecondStepViewModel.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/StrigaRegistrationSecondStepViewModel.swift new file mode 100644 index 0000000000..65f5db3a00 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/SecondStep/StrigaRegistrationSecondStepViewModel.swift @@ -0,0 +1,240 @@ +import BankTransfer +import Combine +import CountriesAPI +import Foundation +import Onboarding +import Resolver +import SwiftyUserDefaults + +final class StrigaRegistrationSecondStepViewModel: BaseViewModel, ObservableObject { + // Dependencies + @Injected private var service: any BankTransferService + @Injected private var notificationService: NotificationService + @Injected private var countriesService: CountriesAPI + private let industryProvider: ChooseIndustryDataLocalProvider + + // Request otp timer properties + @SwiftyUserDefault(keyPath: \.strigaOTPResendCounter, options: .cached) + private var resendCounter: ResendCounter? + + // Fields + @Published var occupationIndustry: String = "" + @Published var sourceOfFunds: String = "" + @Published var country: String = "" + @Published var city: String = "" + @Published var addressLine: String = "" + @Published var postalCode: String = "" + @Published var stateRegion: String = "" + + // Other views + @Published var isLoading = false + @Published var actionTitle: String = L10n.confirm + @Published var isDataValid = + true // We need this flag to allow user enter at first whatever he/she likes and then validate everything + let actionPressed = PassthroughSubject() + let openNextStep = PassthroughSubject() + let chooseIndustry = PassthroughSubject() + let chooseSourceOfFunds = PassthroughSubject() + let chooseCountry = PassthroughSubject() + let openHardError = PassthroughSubject() + + var fieldsStatuses = [StrigaRegistrationField: StrigaFormTextFieldStatus]() + + @Published var selectedCountry: Country? + @Published var selectedIndustry: Industry? + @Published var selectedSourceOfFunds: StrigaSourceOfFunds? + + init(data: StrigaUserDetailsResponse) { + industryProvider = ChooseIndustryDataLocalProvider() + super.init() + setInitial(userData: data) + + actionPressed + .sink { [weak self] _ in + guard let self = self else { return } + self.isDataValid = isValid() + guard isValid() else { return } + self.createUser() + } + .store(in: &subscriptions) + + $isDataValid + .map { $0 ? L10n.confirm : L10n.checkRedFields } + .assignWeak(to: \.actionTitle, on: self) + .store(in: &subscriptions) + + $selectedCountry + .map { value in + if let value { + return [value.emoji, value.name].compactMap { $0 }.joined(separator: " ") + } + return "" + } + .assignWeak(to: \.country, on: self) + .store(in: &subscriptions) + + $selectedIndustry + .map { $0?.wholeName ?? "" } + .assignWeak(to: \.occupationIndustry, on: self) + .store(in: &subscriptions) + + $selectedSourceOfFunds + .map { $0?.title ?? "" } + .assignWeak(to: \.sourceOfFunds, on: self) + .store(in: &subscriptions) + + bindToFieldValues() + } +} + +private extension StrigaRegistrationSecondStepViewModel { + func setInitial(userData: StrigaUserDetailsResponse) { + if let industry = userData.occupation { + selectedIndustry = industryProvider.getIndustries().first(where: { $0.rawValue == industry }) + } + + selectedSourceOfFunds = userData.sourceOfFunds + city = userData.address?.city ?? "" + addressLine = userData.address?.addressLine1 ?? "" + postalCode = userData.address?.postalCode ?? "" + stateRegion = userData.address?.state ?? "" + + Task { + let countries = try await self.countriesService.fetchCountries() + if let country = countries.first(where: { + $0.code.lowercased() == userData.address?.country?.lowercased() + }) { + self.selectedCountry = country + } + } + } + + func isValid() -> Bool { + validate(value: city, field: .city, minLimit: 2, maxLimit: 40) + validate(value: addressLine, field: .addressLine, maxLimit: 160) + validate(value: postalCode, field: .postalCode, maxLimit: 20) + validate(value: country, field: .country) + validate(value: occupationIndustry, field: .occupationIndustry) + validate(value: sourceOfFunds, field: .sourceOfFunds) + return !fieldsStatuses.contains(where: { $0.value != .valid }) + } + + func validate(value: String, field: StrigaRegistrationField, minLimit: Int? = nil, maxLimit: Int? = nil) { + if value.trimmed().isEmpty { + fieldsStatuses[field] = .invalid(error: L10n.couldNotBeEmpty) + } else if let minLimit, value.count < minLimit { + fieldsStatuses[field] = .invalid(error: L10n.couldNotBeLessThanSymbols(minLimit)) + } else if let maxLimit, value.count > maxLimit { + fieldsStatuses[field] = .invalid(error: L10n.couldNotBeMoreThanSymbols(maxLimit)) + } else { + fieldsStatuses[field] = .valid + } + } + + func bindToFieldValues() { + let sourceOfFunds = Publishers.CombineLatest($selectedIndustry, $selectedSourceOfFunds) + let address1 = Publishers.CombineLatest($selectedCountry, $city) + let address2 = Publishers.CombineLatest3($addressLine, $postalCode, $stateRegion) + Publishers.CombineLatest3(sourceOfFunds, address1, address2) + .debounce(for: 0.5, scheduler: DispatchQueue.main) + .sinkAsync { [weak self] sourceOfFunds, address1, address2 in + guard let self else { return } + + let currentData: StrigaUserDetailsResponse = (try? await service + .getRegistrationData() as? StrigaUserDetailsResponse) ?? .empty + + let newData = currentData.updated( + address: StrigaUserDetailsResponse.Address( + addressLine1: address2.0.trimmed(), + addressLine2: nil, + city: address1.1.trimmed(), + postalCode: address2.1.trimmed(), + state: address2.2.trimmed().isEmpty ? nil : address2.2, + country: address1.0?.code + ), + occupation: .some(sourceOfFunds.0?.rawValue), + sourceOfFunds: .some(sourceOfFunds.1) + ) + + try? await self.service.updateLocally(data: newData) + + if self.isDataValid == false { + self.isDataValid = self.isValid() + } + } + .store(in: &subscriptions) + } + + func createUser() { + isLoading = true + Task { + do { + // get registration data + guard let currentData = try await service.getRegistrationData() as? StrigaUserDetailsResponse + else { throw NSError() } + + // create user + try await service.createUser(data: currentData) + await MainActor.run { + self.isLoading = false + } + self.increaseTimer() + self.openNextStep.send(()) + } catch BankTransferError.mobileAlreadyExists { + await MainActor.run { + self.isLoading = false + self.openHardError.send(()) + } + await logAlertMessage(error: BankTransferError.mobileAlreadyExists) + } catch let error as NSError where error.isNetworkConnectionError { + notificationService.showConnectionErrorNotification() + await MainActor.run { + self.isLoading = false + } + } catch { + self.notificationService.showDefaultErrorNotification() + await MainActor.run { + self.isLoading = false + } + await logAlertMessage(error: error) + } + } + } + + // MARK: - Helpers + + private func logAlertMessage(error: Error) async { + let loggerData = await AlertLoggerDataBuilder.buildLoggerData(error: error) + + DefaultLogManager.shared.log( + event: "Striga Registration iOS Alarm", + logLevel: .alert, + data: StrigaRegistrationAlertLoggerMessage( + userPubkey: loggerData.userPubkey, + platform: loggerData.platform, + appVersion: loggerData.appVersion, + timestamp: loggerData.timestamp, + error: .init( + source: "striga api", + kycSDKState: "initial", + error: loggerData.otherError ?? "" + ) + ) + ) + } + + // Start OTP request timer + func increaseTimer() { + if let resendCounter { + self.resendCounter = resendCounter.incremented() + } else { + resendCounter = .zero() + } + } +} + +private extension String { + func trimmed() -> String { + trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StrigaRegistrationDateTextField.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StrigaRegistrationDateTextField.swift new file mode 100644 index 0000000000..1ff41886c1 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StrigaRegistrationDateTextField.swift @@ -0,0 +1,83 @@ +import KeyAppUI +import SwiftUI + +struct StrigaRegistrationDateTextField: View { + private let field = StrigaRegistrationField.dateOfBirth + private let charLimit: Int = 10 + + @State private var text: String = "" + @Binding private var underlyingString: String + + @Binding private var focus: StrigaRegistrationField? + @FocusState private var isFocused: StrigaRegistrationField? + + init( + text: Binding, + focus: Binding + ) { + _underlyingString = text + _focus = focus + } + + var body: some View { + TextField( + L10n.Dd.Mm.yyyy, + text: $text, + onEditingChanged: { editing in + guard editing else { return } + focus = field + }, + onCommit: updateUnderlyingValue + ) + .font(uiFont: .font(of: .title2)) + .foregroundColor(Color(asset: Asset.Colors.night)) + .padding(EdgeInsets(top: 14, leading: 16, bottom: 14, trailing: 20)) + .frame(height: 56) + .keyboardType(.numberPad) + .onAppear(perform: { updateEnteredString(newUnderlyingString: underlyingString) }) + .onChange(of: text, perform: updateUndelyingString) + .onChange(of: underlyingString, perform: updateEnteredString) + .onChange(of: focus, perform: { newValue in + self.isFocused = newValue + }) + .focused(self.$isFocused, equals: field) + } + + func updateEnteredString(newUnderlyingString: String) { + text = String(newUnderlyingString.prefix(charLimit)) + } + + func updateUndelyingString(newEnteredString: String) { + if newEnteredString.count > charLimit { + text = String(newEnteredString.prefix(charLimit)) + } else if newEnteredString.count < underlyingString.count { + if newEnteredString.count == 2 || newEnteredString.count == 5 { + text = String(newEnteredString.dropLast(1)) + } else { + text = newEnteredString + } + } else if newEnteredString.count == 2 { + text = newEnteredString.appending(".") + } else if newEnteredString.count == 5 { + text = newEnteredString.appending(".") + } + underlyingString = text + } + + func updateUnderlyingValue() { + underlyingString = text + } +} + +struct StrigaRegistrationDateTextField_Previews: PreviewProvider { + static var previews: some View { + VStack { + StrigaRegistrationDateTextField( + text: .constant(""), + focus: .constant(nil) + ) + } + .padding(16) + .background(Color(asset: Asset.Colors.sea)) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StrigaRegistrationDetailedButton.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StrigaRegistrationDetailedButton.swift new file mode 100644 index 0000000000..bcbb126173 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StrigaRegistrationDetailedButton.swift @@ -0,0 +1,29 @@ +import KeyAppUI +import SwiftUI + +struct StrigaRegistrationDetailedButton: View { + @Binding private var value: String + private let action: () -> Void + + init(value: Binding, action: @escaping () -> Void) { + _value = value + self.action = action + } + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + Text(value.isEmpty ? L10n.selectFromList : value) + .apply(style: .title2) + .foregroundColor(Color(asset: Asset.Colors.night)) + .lineLimit(1) + Spacer() + Image(asset: Asset.MaterialIcon.chevronRight) + .renderingMode(.template) + .foregroundColor(Color(asset: Asset.Colors.night)) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + } + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StrigaRegistrationPhoneTextField.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StrigaRegistrationPhoneTextField.swift new file mode 100644 index 0000000000..71e3f97e3a --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StrigaRegistrationPhoneTextField.swift @@ -0,0 +1,151 @@ +import CountriesAPI +import KeyAppUI +import PhoneNumberKit +import SwiftUI + +struct StrigaRegistrationPhoneTextField: View { + private let field = StrigaRegistrationField.phoneNumber + + @Binding private var trackedText: String + @Binding private var phoneNumber: PhoneNumber? + @Binding private var country: Country? + + @State private var text: String = "" + @State private var underlyingString: String = "" + + private let phoneNumberKit: PhoneNumberKit + private let countryTapped: () -> Void + private let partialFormatter: PartialFormatter + + @Binding private var focus: StrigaRegistrationField? + @FocusState private var isFocused: StrigaRegistrationField? + + init( + text: Binding, + phoneNumber: Binding, + country: Binding, + countryTapped: @escaping () -> Void, + focus: Binding + ) { + _focus = focus + _trackedText = text + underlyingString = text.wrappedValue + _phoneNumber = phoneNumber + _country = country + let numberKit = PhoneNumberKit() + phoneNumberKit = numberKit + partialFormatter = PartialFormatter(phoneNumberKit: numberKit, withPrefix: true) + self.countryTapped = countryTapped + } + + var body: some View { + HStack(spacing: 12) { + HStack(spacing: 6) { + Text(country?.emoji ?? "🏴") + .fontWeight(.bold) + .apply(style: .title1) + Image(uiImage: .expandIcon) + .renderingMode(.template) + .foregroundColor(Color(asset: Asset.Colors.night)) + .frame(width: 8, height: 5) + }.onTapGesture { + countryTapped() + } + + HStack(spacing: 4) { + Text(country?.dialCode ?? "") + .apply(style: .title2) + .foregroundColor(Color(asset: Asset.Colors.night)) + + TextField( + L10n.enter, + text: $text, + onEditingChanged: { changed in + if changed { + focus = field + } else { + updateUnderlyingValue() // updating value on unfocus + } + }, + onCommit: updateUnderlyingValue + ) + .font(uiFont: .font(of: .title2)) + .foregroundColor(Color(asset: Asset.Colors.night)) + .keyboardType(.numberPad) + .onAppear(perform: { updateEnteredString(newUnderlyingString: underlyingString) }) + .onChange(of: text, perform: updateUndelyingString) + .onChange(of: underlyingString, perform: updateEnteredString) + .onChange(of: focus, perform: { newValue in + self.isFocused = newValue + }) + .focused(self.$isFocused, equals: field) + + if !text.isEmpty { + Button(action: { text = "" }) { + Image(uiImage: .closeIcon) + .resizable() + .renderingMode(.template) + .foregroundColor(Color(asset: Asset.Colors.night)) + .frame(width: 14, height: 14) + } + } + } + } + .padding(EdgeInsets(top: 14, leading: 16, bottom: 14, trailing: 20)) + .frame(height: 56) + } + + private func updateEnteredString(newUnderlyingString: String) { + text = newUnderlyingString + } + + private func updateUndelyingString(newEnteredString: String) { + var newText = newEnteredString + if let code = country?.dialCode, let examplePhoneNumber = exampleNumberWith( + phone: "+\(code)\(text.replacingOccurrences(of: " ", with: ""))" + ) { + let formattedExample = phoneNumberKit.format(examplePhoneNumber, toType: .international, withPrefix: false) + .replacingOccurrences(of: "[0-9]", with: "X", options: .regularExpression) + .replacingOccurrences(of: "-", with: " ") + .appending("XXXXX") + newText = format(with: formattedExample, phone: newEnteredString) + phoneNumber = try? phoneNumberKit.parse("+\(code)\(text.replacingOccurrences(of: " ", with: ""))") + } else { + phoneNumber = nil + } + text = newText + trackedText = text + } + + private func updateUnderlyingValue() { + underlyingString = text + } + + private func exampleNumberWith(phone: String? = "") -> PhoneNumber? { + _ = partialFormatter.nationalNumber(from: phone ?? "") + let country = partialFormatter.currentRegion + return phoneNumberKit.getExampleNumber(forCountry: country) + } + + private func format(with mask: String, phone: String) -> String { + if phone == "+" { return phone } + let numbers = phone.replacingOccurrences(of: "[^0-9]", with: "", options: .regularExpression) + var result = "" + var index = numbers.startIndex // numbers iterator + + // iterate over the mask characters until the iterator of numbers ends + for ch in mask where index < numbers.endIndex { + if ch == "X" { + // mask requires a number in this place, so take the next one + result.append(numbers[index]) + + // move numbers iterator to the next index + index = numbers.index(after: index) + + } else { + result.append(ch) // just append a mask character + } + } + return result + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StrigaRegistrationSectionView.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StrigaRegistrationSectionView.swift new file mode 100644 index 0000000000..cdfa608f39 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StrigaRegistrationSectionView.swift @@ -0,0 +1,16 @@ +import KeyAppUI +import SwiftUI + +struct StrigaRegistrationSectionView: View { + let title: String + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Spacer() + Text(title.uppercased()) + .apply(style: .caps) + .foregroundColor(Color(Asset.Colors.night.color)) + } + .frame(minHeight: 33) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StrigaRegistrationTextField.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StrigaRegistrationTextField.swift new file mode 100644 index 0000000000..a4d027ba9c --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StrigaRegistrationTextField.swift @@ -0,0 +1,98 @@ +import KeyAppUI +import SwiftUI + +struct StrigaRegistrationTextField: View { + let field: TextFieldType + let fontStyle: UIFont.Style + let placeholder: String + let isEnabled: Bool + let onSubmit: () -> Void + let submitLabel: SubmitLabel + let showClearButton: Bool + + @Binding var text: String + @Binding var focus: TextFieldType? + + @FocusState private var isFocused: TextFieldType? + + init( + field: TextFieldType, + fontStyle: UIFont.Style = .title2, + placeholder: String, + text: Binding, + isEnabled: Bool = true, + showClearButton: Bool = false, + focus: Binding, + onSubmit: @escaping () -> Void, + submitLabel: SubmitLabel + ) { + self.field = field + self.fontStyle = fontStyle + self.placeholder = placeholder + _text = text + self.isEnabled = isEnabled + self.showClearButton = showClearButton + _focus = focus + self.onSubmit = onSubmit + self.submitLabel = submitLabel + } + + var body: some View { + HStack(spacing: 12) { + TextField(placeholder, text: $text, onEditingChanged: { editing in + guard editing else { return } + focus = field + }) + .font(uiFont: .font(of: fontStyle)) + .foregroundColor(isEnabled ? Color(asset: Asset.Colors.night) : Color(asset: Asset.Colors.night) + .opacity(0.3)) + .padding(EdgeInsets(top: 14, leading: 16, bottom: 14, trailing: 20)) + .frame(height: 58) + .disabled(!isEnabled) + .onChange(of: focus, perform: { newValue in + self.isFocused = newValue + }) + .focused(self.$isFocused, equals: field) + .submitLabel(submitLabel) + .onSubmit { + self.onSubmit() + } + + if showClearButton, !text.isEmpty { + Button(action: { text = "" }) { + Image(uiImage: .closeIcon) + .resizable() + .renderingMode(.template) + .foregroundColor(Color(asset: Asset.Colors.night)) + .frame(width: 16, height: 16) + }.padding(.trailing, 16) + } + } + } +} + +struct StrigaRegistrationTextField_Previews: PreviewProvider { + static var previews: some View { + VStack { + StrigaRegistrationTextField( + field: StrigaRegistrationField.email, + placeholder: "Enter email", + text: .constant(""), + focus: .constant(nil), + onSubmit: {}, + submitLabel: .next + ) + + StrigaRegistrationTextField( + field: StrigaRegistrationField.phoneNumber, + placeholder: "Enter phone", + text: .constant(""), + focus: .constant(nil), + onSubmit: {}, + submitLabel: .done + ) + } + .padding(16) + .background(Color(asset: Asset.Colors.sea)) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StringRegistrationCell.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StringRegistrationCell.swift new file mode 100644 index 0000000000..5dd1220075 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaRegistration/Subviews/StringRegistrationCell.swift @@ -0,0 +1,58 @@ +import KeyAppUI +import SwiftUI + +enum StrigaFormTextFieldStatus: Equatable { + case valid + case invalid(error: String) +} + +struct StrigaFormCell: View { + let title: String + let status: StrigaFormTextFieldStatus + let hint: String? + @ViewBuilder private var content: () -> Content + + init( + title: String, + status: StrigaFormTextFieldStatus? = .valid, + hint: String? = nil, + @ViewBuilder content: @escaping () -> Content + ) { + self.title = title + self.status = status ?? .valid + self.hint = hint + self.content = content + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .foregroundColor(Color(asset: Asset.Colors.mountain)) + .apply(style: .label1) + .padding(.leading, 9) + .frame(minHeight: 16) + + content() + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(asset: Asset.Colors.snow)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(status == .valid ? .clear : Color(asset: Asset.Colors.rose), lineWidth: 1) + ) + + if case let .invalid(error) = status { + Text(error) + .apply(style: .label1) + .foregroundColor(Color(asset: Asset.Colors.rose)) + .padding(.leading, 8) + } else if let hint { + Text(hint) + .apply(style: .label1) + .foregroundColor(Color(asset: Asset.Colors.mountain)) + .padding(.leading, 8) + } + } + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaVerificationPendingSheet/StrigaVerificationPendingSheetCoordinator.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaVerificationPendingSheet/StrigaVerificationPendingSheetCoordinator.swift new file mode 100644 index 0000000000..d1f73a80e3 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaVerificationPendingSheet/StrigaVerificationPendingSheetCoordinator.swift @@ -0,0 +1,28 @@ +import Combine +import UIKit + +final class StrigaVerificationPendingSheetCoordinator: Coordinator { + private let presentingViewController: UIViewController + + init(presentingViewController: UIViewController) { + self.presentingViewController = presentingViewController + } + + override func start() -> AnyPublisher { + let view = StrigaVerificationPendingSheetView { [weak self] in + self?.presentingViewController.dismiss(animated: true) + } + + let controller = UIBottomSheetHostingController( + rootView: view, + ignoresKeyboard: true + ) + + controller.view.layer.cornerRadius = 20 + presentingViewController.present(controller, interactiveDismissalType: .standard) + + return controller.deallocatedPublisher() + .prefix(1) + .eraseToAnyPublisher() + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/StrigaVerificationPendingSheet/StrigaVerificationPendingSheetView.swift b/p2p_wallet/Scenes/Main/BankTransfer/StrigaVerificationPendingSheet/StrigaVerificationPendingSheetView.swift new file mode 100644 index 0000000000..1ae4f96693 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/StrigaVerificationPendingSheet/StrigaVerificationPendingSheetView.swift @@ -0,0 +1,46 @@ +import KeyAppUI +import SwiftUI + +struct StrigaVerificationPendingSheetView: View { + let action: () -> Void + + var body: some View { + ColoredBackground { + VStack { + RoundedRectangle(cornerRadius: 2, style: .circular) + .fill(Color(Asset.Colors.rain.color)) + .frame(width: 31, height: 4) + .padding(.top, 6) + + VStack(spacing: 24) { + Image(uiImage: .kycClock) + .frame(width: 100, height: 100) + + Text(L10n.yourKYCVerificationIsPending) + .apply(style: .title2, weight: .bold) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Text(L10n.weWillUpdateTheStatusOnceItIsFinished) + .apply(style: .text3) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + NewTextButton(title: L10n.gotIt, style: .primary, expandable: true, action: action) + .padding(.top, 40) + } + .padding(.horizontal, 20) + .padding(.top, 20) + } + } + .cornerRadius(20) + } +} + +struct StrigaVerificationPendingSheetView_Previews: PreviewProvider { + static var previews: some View { + VStack { + StrigaVerificationPendingSheetView(action: {}) + } + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/View/BankTransferInfoImageView.swift b/p2p_wallet/Scenes/Main/BankTransfer/View/BankTransferInfoImageView.swift new file mode 100644 index 0000000000..ab28553a13 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/View/BankTransferInfoImageView.swift @@ -0,0 +1,171 @@ +import KeyAppUI +import SkeletonUI +import SwiftUI + +struct BankTransferInfoImageCellViewItem: Identifiable { + var id: String = UUID().uuidString + var image: UIImage +} + +extension BankTransferInfoImageCellViewItem: Renderable { + func render() -> some View { + BankTransferInfoImageView(image: image) + } +} + +struct BankTransferInfoImageView: View { + let image: UIImage + + var body: some View { + Image(uiImage: image) + .fixedSize() + } +} + +struct BankTransferInfoImageView_Previews: PreviewProvider { + static var previews: some View { + BankTransferInfoImageView(image: .accountCreationFee) + } +} + +// MARK: - Title + +struct BankTransferTitleCellViewItem: Identifiable { + var id: String { title } + var title: String +} + +extension BankTransferTitleCellViewItem: Renderable { + func render() -> some View { + BankTransferTitleCellView(title: title) + } +} + +struct BankTransferTitleCellView: View { + let title: String + + var body: some View { + Text(title) + .apply(style: .title2, weight: .bold) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 20) + } +} + +// MARK: - Country + +struct BankTransferCountryCellViewItem: Identifiable { + var id: String { name + flag } + var name: String + var flag: String + var isLoading: Bool +} + +extension BankTransferCountryCellViewItem: Renderable { + func render() -> some View { + BankTransferCountryCellView(name: name, flag: flag, isLoading: isLoading) + } +} + +struct BankTransferCountryCellView: View { + let name: String + let flag: String + let isLoading: Bool + + var body: some View { + HStack(spacing: 12) { + flagView + .padding(.leading, 16) + VStack(alignment: .leading, spacing: 6) { + Text(name) + .apply(style: .text3, weight: .semibold) + .foregroundColor(Color(Asset.Colors.night.color)) + .skeleton( + with: isLoading, + size: CGSize(width: 150, height: 20), + animated: .default + ) + Text(L10n.yourCountry) + .apply(style: .label1) + .foregroundColor(Color(Asset.Colors.mountain.color)) + } + Spacer() + Image(uiImage: Asset.MaterialIcon.chevronRight.image) + .foregroundColor(Color(Asset.Colors.mountain.color)) + .padding(.trailing, 19) + } + .padding(.vertical, 12) + .background(Color(Asset.Colors.snow.color)) + .cornerRadius(radius: 16, corners: .allCorners) + } + + var flagView: some View { + Rectangle() + .fill(Color(Asset.Colors.smoke.color)) + .cornerRadius(radius: 24, corners: .allCorners) + .frame(width: 48, height: 48) + .overlay { + Text(flag) + .apply(style: .title1, weight: .bold) + .skeleton( + with: isLoading, + size: CGSize(width: 32, height: 32), + animated: .default + ) + } + } +} + +// MARK: - + +struct BankTransferInfoCountriesTextCellViewItem: Identifiable { + var id: String = UUID().uuidString +} + +extension BankTransferInfoCountriesTextCellViewItem: Renderable { + func render() -> some View { + BankTransferInfoCountriesTextCellView() + } +} + +struct BankTransferInfoCountriesTextCellView: View { + var body: some View { + HStack(spacing: 0) { + Text(L10n.checkTheListOfCountries + " ") + .apply(style: .text3) + .foregroundColor(Color(Asset.Colors.night.color)) + Text(L10n.here) + .apply(style: .text3) + .foregroundColor(Color(Asset.Colors.sky.color)) + } + } +} + +// MARK: - Check list of countries + +struct CenterTextCellViewItem: Identifiable { + var id: String = UUID().uuidString + let text: String + let style: UIFont.Style + let color: UIColor +} + +extension CenterTextCellViewItem: Renderable { + func render() -> some View { + CenterTextCellItemView(text: text, style: style, color: Color(color)) + } +} + +struct CenterTextCellItemView: View { + let text: String + let style: UIFont.Style + let color: Color + + var body: some View { + Text(text) + .apply(style: style) + .multilineTextAlignment(.center) + .foregroundColor(color) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/Withdraw/WithdrawCoordinator.swift b/p2p_wallet/Scenes/Main/BankTransfer/Withdraw/WithdrawCoordinator.swift new file mode 100644 index 0000000000..1593864aad --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/Withdraw/WithdrawCoordinator.swift @@ -0,0 +1,61 @@ +import BankTransfer +import Combine +import Foundation +import Resolver +import SwiftUI + +struct WithdrawConfirmationParameters: Equatable { + let accountId: String + let amount: String +} + +enum WithdrawStrategy { + /// Used to collect IBAN + case gathering + /// Used to confirm withdrawal + case confirmation(WithdrawConfirmationParameters) +} + +final class WithdrawCoordinator: Coordinator { + let navigationController: UINavigationController + let strategy: WithdrawStrategy + let withdrawalInfo: StrigaWithdrawalInfo + + init( + navigationController: UINavigationController, + strategy: WithdrawStrategy = .gathering, + withdrawalInfo: StrigaWithdrawalInfo + ) { + self.navigationController = navigationController + self.strategy = strategy + self.withdrawalInfo = withdrawalInfo + super.init() + } + + override func start() -> AnyPublisher { + let viewModel = WithdrawViewModel(withdrawalInfo: withdrawalInfo, strategy: strategy) + let view = WithdrawView( + viewModel: viewModel + ) + let viewController = UIHostingController(rootView: view) + viewController.hidesBottomBarWhenPushed = true + navigationController.pushViewController(viewController, animated: true) + return Publishers.Merge3( + viewController.deallocatedPublisher() + .map { WithdrawCoordinator.Result.canceled }, + viewModel.gatheringCompletedPublisher + .map { WithdrawCoordinator.Result.verified(IBAN: $0.IBAN, BIC: $0.BIC) }, + viewModel.paymentInitiatedPublisher + .map { WithdrawCoordinator.Result.paymentInitiated(challengeId: $0) } + ) + .prefix(1).eraseToAnyPublisher() + } +} + +extension WithdrawCoordinator { + enum Result { + case paymentInitiated(challengeId: String) + case verified(IBAN: String, BIC: String) + case canceled + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/Withdraw/WithdrawView.swift b/p2p_wallet/Scenes/Main/BankTransfer/Withdraw/WithdrawView.swift new file mode 100644 index 0000000000..0aa020430b --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/Withdraw/WithdrawView.swift @@ -0,0 +1,136 @@ +import BankTransfer +import Combine +import KeyAppUI +import Resolver +import SwiftUI + +struct WithdrawView: View { + @ObservedObject var viewModel: WithdrawViewModel + @State private var focus: WithdrawViewField? + + var body: some View { + ColoredBackground { + VStack { + ScrollView { + form + .padding(.top, 16) + .animation(.spring(blendDuration: 0.01), value: viewModel.fieldsStatuses) + } + } + .safeAreaInset(edge: .bottom, content: { + NewTextButton( + title: viewModel.actionTitle.uppercaseFirst, + style: .primaryWhite, + expandable: true, + isEnabled: viewModel.isDataValid || !viewModel.actionHasBeenTapped, + isLoading: viewModel.isLoading, + trailing: viewModel.isDataValid ? .arrowForward : nil, + action: { + resignFirstResponder() + Task { + await viewModel.action() + } + } + ) + .padding(.top, 12) + .padding(.bottom, 36) + .background(Color(Asset.Colors.smoke.color).edgesIgnoringSafeArea(.bottom)) + }) + .padding(.horizontal, 16) + } + .toolbar { + ToolbarItem(placement: .principal) { + Text(L10n.withdraw) + .fontWeight(.semibold) + } + } + .onDisappear { + resignFirstResponder() + } + } + + var form: some View { + VStack(spacing: 12) { + StrigaFormCell( + title: L10n.yourIBAN, + status: viewModel.fieldsStatuses[.IBAN] + ) { + StrigaRegistrationTextField( + field: .IBAN, + fontStyle: .text3, + placeholder: "", + text: $viewModel.IBAN, + showClearButton: true, + focus: $focus, + onSubmit: { focus = .BIC }, + submitLabel: .next + ) + } + + StrigaFormCell( + title: L10n.bic, + status: viewModel.fieldsStatuses[.BIC] + ) { + StrigaRegistrationTextField( + field: .BIC, + fontStyle: .text3, + placeholder: "", + text: $viewModel.BIC, + showClearButton: true, + focus: $focus, + onSubmit: { focus = nil }, + submitLabel: .done + ) + } + + VStack(spacing: 4) { + StrigaFormCell( + title: L10n.receiver, + status: .valid + ) { + StrigaRegistrationTextField( + field: .receiver, + fontStyle: .text3, + placeholder: "", + text: $viewModel.receiver, + isEnabled: false, + focus: $focus, + onSubmit: { focus = nil }, + submitLabel: .next + ) + } + Text(L10n.yourBankAccountNameMustMatchTheNameRegisteredToYourKeyAppAccount) + .apply(style: .label1) + .foregroundColor(Color(Asset.Colors.night.color)) + } + } + } +} + +// struct WithdrawView_Previews: PreviewProvider { +// static var previews: some View { +// WithdrawView(viewModel: WithdrawViewModel(provider: Resolver.resolve(), withdrawalInfo: nil)) +// } +// } + +enum WithdrawViewField: Int, Identifiable { + var id: Int { rawValue } + + case IBAN + case BIC + case receiver +} + +extension WithdrawView { + // When screen disappear via some action, you should called this method twice for some reason: on action and on + // disappear function + // Seems like a UI bug of iOS https://stackoverflow.com/a/74124962 + private func resignFirstResponder() { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/Withdraw/WithdrawViewModel.swift b/p2p_wallet/Scenes/Main/BankTransfer/Withdraw/WithdrawViewModel.swift new file mode 100644 index 0000000000..646c58d6a7 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/Withdraw/WithdrawViewModel.swift @@ -0,0 +1,214 @@ +import BankTransfer +import Combine +import Foundation +import Resolver + +class WithdrawViewModel: BaseViewModel, ObservableObject { + typealias FieldStatus = StrigaFormTextFieldStatus + + // MARK: - Dependencies + + @Injected private var bankTransferService: AnyBankTransferService + @Injected private var notificationService: NotificationService + + // MARK: - Properties + + @Published var IBAN: String = "" + @Published var BIC: String = "" + @Published var receiver: String = "" + @Published var actionTitle: String = L10n.withdraw + @Published var isDataValid = false + @Published var fieldsStatuses = [WithdrawViewField: FieldStatus]() + @Published var isLoading = false + @Published var actionHasBeenTapped = false + + private let gatheringCompletedSubject = PassthroughSubject<(IBAN: String, BIC: String), Never>() + public var gatheringCompletedPublisher: AnyPublisher<(IBAN: String, BIC: String), Never> { + gatheringCompletedSubject.eraseToAnyPublisher() + } + + private let paymentInitiatedSubject = PassthroughSubject() + public var paymentInitiatedPublisher: AnyPublisher { + paymentInitiatedSubject.eraseToAnyPublisher() + } + + private let strategy: WithdrawStrategy + + init( + withdrawalInfo: StrigaWithdrawalInfo, + strategy: WithdrawStrategy + + ) { + self.strategy = strategy + super.init() + + IBAN = withdrawalInfo.IBAN ?? "" + BIC = withdrawalInfo.BIC ?? "" + receiver = withdrawalInfo.receiver + + Publishers.CombineLatest3($IBAN, $BIC, $actionHasBeenTapped) + .drop(while: { _, _, actionHasBeenTapped in + !actionHasBeenTapped + }) + .map { [unowned self] iban, bic, _ in + [ + WithdrawViewField.IBAN: checkIBAN(iban), + WithdrawViewField.BIC: checkBIC(bic), + ] + } + .handleEvents(receiveOutput: { [unowned self] fields in + isDataValid = fields.values.filter { status in + status == .valid + }.count == fields.keys.count + actionTitle = isDataValid ? L10n.withdraw : L10n.checkYourData + }) + .assignWeak(to: \.fieldsStatuses, on: self) + .store(in: &subscriptions) + + $IBAN + .debounce(for: 0.0, scheduler: DispatchQueue.main) + .removeDuplicates() + .map { $0.formatIBAN() } + .assignWeak(to: \.IBAN, on: self) + .store(in: &subscriptions) + + $BIC + .debounce(for: 0.1, scheduler: DispatchQueue.main) + .removeDuplicates() + .map { $0.uppercased() } + .assignWeak(to: \.BIC, on: self) + .store(in: &subscriptions) + } + + func action() async { + actionHasBeenTapped = true + guard !isLoading, checkIBAN(IBAN) == .valid, checkBIC(BIC) == .valid else { + return + } + + isLoading = true + defer { + isLoading = false + } + let info = StrigaWithdrawalInfo(IBAN: IBAN.filterIBAN(), BIC: BIC, receiver: receiver) + // Save to local + await save(info: info) + + switch strategy { + case .gathering: + // Complete the flow + gatheringCompletedSubject.send((IBAN.filterIBAN(), BIC)) + case let .confirmation(params): + // Initiate SEPA payment + guard let challengeId = await initiateSEPAPayment(params: params, info: info) else { return } + paymentInitiatedSubject.send(challengeId) + } + } + + private func save(info: StrigaWithdrawalInfo) async { + do { + try await bankTransferService.value.saveWithdrawalInfo(info: info) + } catch { + notificationService.showDefaultErrorNotification() + } + } + + private func initiateSEPAPayment(params: WithdrawConfirmationParameters, + info: StrigaWithdrawalInfo) async -> String? + { + do { + guard let userId = await bankTransferService.value.repository.getUserId() else { + throw BankTransferError.missingUserId + } + let challengeId = try await bankTransferService.value.repository.initiateSEPAPayment( + userId: userId, + accountId: params.accountId, + amount: params.amount, + iban: info.IBAN ?? "", + bic: info.BIC ?? "" + ) + return challengeId + } catch let error as NSError where error.isNetworkConnectionError { + notificationService.showConnectionErrorNotification() + return nil + } catch { + notificationService.showDefaultErrorNotification() + return nil + } + } + + private func checkIBAN(_ iban: String) -> FieldStatus { + let filteredIBAN = iban.filterIBAN() + if filteredIBAN.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return .invalid(error: WithdrawViewFieldError.empty.text) + } + return filteredIBAN.passesMod97Check() ? .valid : .invalid(error: WithdrawViewFieldError.invalidIBAN.text) + } + + private func checkBIC(_ bic: String) -> FieldStatus { + let bic = bic.trimmingCharacters(in: .whitespacesAndNewlines) + if bic.isEmpty { + return .invalid(error: WithdrawViewFieldError.empty.text) + } + return bic.passesBICCheck() ? .valid : .invalid(error: WithdrawViewFieldError.invalidBIC.text) + } +} + +// Validation +private extension String { + private func mod97() -> Int { + let symbols: [Character] = Array(self) + let swapped = symbols.dropFirst(4) + symbols.prefix(4) + + let mod: Int = swapped.reduce(0) { previousMod, char in + let value = Int(String(char), radix: 36)! // "0" => 0, "A" => 10, "Z" => 35 + let factor = value < 10 ? 10 : 100 + return (factor * previousMod + value) % 97 + } + return mod + } + + func passesMod97Check() -> Bool { + guard count >= 4 else { + return false + } + + let uppercase = uppercased() + + guard uppercase.range(of: "^[0-9A-Z]*$", options: .regularExpression) != nil else { + return false + } + return uppercase.mod97() == 1 + } + + func passesBICCheck() -> Bool { + let bicRegex = "^([A-Za-z]{4}[A-Za-z]{2})([A-Za-z0-9]{2})([A-Za-z0-9]{3})?$" + let bicTest = NSPredicate(format: "SELF MATCHES %@", bicRegex) + return bicTest.evaluate(with: self) + } + + func filterIBAN() -> String { + // Use a character set containing allowed characters (alphanumeric and spaces) + let allowedCharacterSet = + CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") + // Remove any characters not in the allowed set + return components(separatedBy: allowedCharacterSet.inverted).joined() + } +} + +enum WithdrawViewFieldError { + case empty + case invalidIBAN + case invalidBIC + + var text: String { + switch self { + case .empty: + return L10n.couldNotBeEmpty + case .invalidIBAN: + return L10n.invalidIBAN + case .invalidBIC: + return L10n.invalidBIC + } + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/WithdrawCalculator/Models/WithdrawCalculatorAction.swift b/p2p_wallet/Scenes/Main/BankTransfer/WithdrawCalculator/Models/WithdrawCalculatorAction.swift new file mode 100644 index 0000000000..e0877524c7 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/WithdrawCalculator/Models/WithdrawCalculatorAction.swift @@ -0,0 +1,7 @@ +struct WithdrawCalculatorAction { + let isEnabled: Bool + let title: String + + static let zero = WithdrawCalculatorAction(isEnabled: false, title: L10n.enterAmount) + static let failure = WithdrawCalculatorAction(isEnabled: false, title: L10n.canTWithdrawNow) +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/WithdrawCalculator/Subviews/WithdrawCalculatorSwapIcon.swift b/p2p_wallet/Scenes/Main/BankTransfer/WithdrawCalculator/Subviews/WithdrawCalculatorSwapIcon.swift new file mode 100644 index 0000000000..996e8f8130 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/WithdrawCalculator/Subviews/WithdrawCalculatorSwapIcon.swift @@ -0,0 +1,15 @@ +import KeyAppUI +import SwiftUI + +struct WithdrawCalculatorSwapIcon: View { + var body: some View { + Image(uiImage: .chevronDown) + .renderingMode(.template) + .foregroundColor(Color(Asset.Colors.silver.color)) + .background( + Circle() + .foregroundColor(Color(Asset.Colors.rain.color)) + .frame(width: 36, height: 36) + ) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/WithdrawCalculator/WithdrawCalculatorCoordinator.swift b/p2p_wallet/Scenes/Main/BankTransfer/WithdrawCalculator/WithdrawCalculatorCoordinator.swift new file mode 100644 index 0000000000..234bba0c3d --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/WithdrawCalculator/WithdrawCalculatorCoordinator.swift @@ -0,0 +1,172 @@ +import BankTransfer +import Combine +import KeyAppBusiness +import KeyAppKitCore +import Resolver +import SolanaSwift +import UIKit + +final class WithdrawCalculatorCoordinator: Coordinator { + private let navigationController: UINavigationController + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + + override func start() -> AnyPublisher { + let viewModel = WithdrawCalculatorViewModel() + let view = WithdrawCalculatorView(viewModel: viewModel) + let vc = view.asViewController(withoutUIKitNavBar: false) + vc.title = L10n.withdraw + vc.hidesBottomBarWhenPushed = true + navigationController.pushViewController(vc, animated: true) + + viewModel.openBankTransfer + .sink { [weak self] _ in self?.openBankTransfer() } + .store(in: &subscriptions) + + return Publishers.Merge( + viewModel.openWithdraw + .flatMap { [unowned self] model, amount in + openWithdraw(model: model, amount: amount) + } + .handleEvents(receiveOutput: { [unowned self] tx in + if let tx { + navigationController.popViewController(animated: true) + } + }) + .compactMap { $0 } + .map { WithdrawCalculatorCoordinator.Result.transaction($0) } + .eraseToAnyPublisher(), + vc.deallocatedPublisher() + .map { WithdrawCalculatorCoordinator.Result.canceled } + .eraseToAnyPublisher() + ).prefix(1).eraseToAnyPublisher() + } + + private func openBankTransfer() { + coordinate(to: BankTransferCoordinator(viewController: navigationController)) + .sink(receiveValue: {}) + .store(in: &subscriptions) + } + + private func openWithdraw(model: StrigaWithdrawalInfo, amount: Double) -> AnyPublisher { + coordinate(to: WithdrawCoordinator( + navigationController: navigationController, + withdrawalInfo: model + )) + .asyncMap { result -> (WithdrawCoordinator.Result, TokenPrice?) in + let priceService = Resolver.resolve(PriceService.self) + let prices = try? await priceService.getPrice( + token: SolanaToken.usdc, + fiat: Defaults.fiat.rawValue + ) + return (result, prices) + } + .receive(on: DispatchQueue.main) + .handleEvents(receiveOutput: { [unowned self] result, _ in + switch result { + case .verified: + navigationController.popToRootViewController(animated: true) + case .canceled, .paymentInitiated: + break + } + }) + .map { result, prices -> PendingTransaction? in + switch result { + case let .paymentInitiated(challangeId): + let transaction = StrigaWithdrawTransaction( + challengeId: challangeId, + IBAN: model.IBAN ?? "", + BIC: model.BIC ?? "", + amount: amount, + token: .usdc, + tokenPrice: prices, + feeAmount: FeeAmount( + transaction: 0, + accountBalances: 0 + ) + ) + + // delegate work to transaction handler + let transactionIndex = Resolver.resolve(TransactionHandlerType.self) + .sendTransaction(transaction, status: .sending) + + // return pending transaction + let pendingTransaction = PendingTransaction( + trxIndex: transactionIndex, + sentAt: Date(), + rawTransaction: transaction, + status: .sending + ) + return pendingTransaction + case let .verified(IBAN, BIC): + // Fake transaction for now + let sendTransaction = SendTransaction( + isFakeSendTransaction: false, + isFakeSendTransactionError: false, + isFakeSendTransactionNetworkError: false, + isLinkCreationAvailable: false, // TODO: Check + recipient: .init( + address: "", + category: .solanaAddress, + attributes: .funds + ), + sendViaLinkSeed: nil, + amount: amount, + amountInFiat: 0.01, + walletToken: .nativeSolana(pubkey: "adfasdf", lamport: 200_000_000), + address: "adfasdf", + payingFeeWallet: .nativeSolana(pubkey: "adfasdf", lamport: 200_000_000), + feeAmount: .init(transaction: 10000, accountBalances: 2_039_280), + currency: "USD", + analyticEvent: .sendNewConfirmButtonClick( + sendFlow: "", + token: "", + max: false, + amountToken: 0, + amountUSD: 0, + fee: false, + fiatInput: false, + signature: "", + pubKey: nil + ) + ) + + let transaction = StrigaWithdrawSendTransaction( + sendTransaction: sendTransaction, + IBAN: IBAN, + BIC: BIC, + amount: amount, + feeAmount: .zero + ) + + // delegate work to transaction handler + let transactionIndex = Resolver.resolve(TransactionHandlerType.self) + .sendTransaction( + transaction, + status: .confirmationNeeded + ) + + // return pending transaction + let pendingTransaction = PendingTransaction( + trxIndex: transactionIndex, + sentAt: Date(), + rawTransaction: transaction, + status: .confirmationNeeded + ) + return pendingTransaction + case .canceled: + return nil + } + } + .eraseToAnyPublisher() + } +} + +extension WithdrawCalculatorCoordinator { + enum Result { + case transaction(PendingTransaction) + case canceled + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/WithdrawCalculator/WithdrawCalculatorView.swift b/p2p_wallet/Scenes/Main/BankTransfer/WithdrawCalculator/WithdrawCalculatorView.swift new file mode 100644 index 0000000000..df1fda1946 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/WithdrawCalculator/WithdrawCalculatorView.swift @@ -0,0 +1,130 @@ +import KeyAppUI +import SwiftUI + +struct WithdrawCalculatorView: View { + @ObservedObject var viewModel: WithdrawCalculatorViewModel + + var body: some View { + ColoredBackground { + VStack { + ScrollView { + contentView + } + .safeAreaInset(edge: .bottom, content: { + NewTextButton( + title: viewModel.actionData.title, + style: .primaryWhite, + expandable: true, + isEnabled: viewModel.actionData.isEnabled, + isLoading: viewModel.isLoading, + trailing: viewModel.actionData.isEnabled ? .arrowForward + .withRenderingMode(.alwaysTemplate) : nil, + action: viewModel.actionPressed.send + ) + .padding(.top, 12) + .padding(.bottom, 16) + .background(Color(Asset.Colors.smoke.color).edgesIgnoringSafeArea(.bottom)) + }) + .scrollDismissesKeyboard() + } + .padding(.horizontal, 16) + } + .onAppear { + viewModel.isViewAppeared.send(true) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + // We need delay because BigInputView is UITextField + viewModel.isFromFirstResponder = true + } + } + .onDisappear { + viewModel.isViewAppeared.send(false) + } + } + + var contentView: some View { + VStack(spacing: .zero) { + // Header + headerText + + // Inputs + ZStack { + VStack(spacing: 8) { + fromInput + toInput + } + + WithdrawCalculatorSwapIcon() + } + .padding(.top, 36) + + // Disclaimer + disclaimerText + } + .onTapGesture { UIApplication.shared.endEditing() } + } + + var headerText: some View { + Text(viewModel.exchangeRatesInfo) + .apply(style: .label1) + .padding(.top, 4) + .foregroundColor(Color(Asset.Colors.night.color)) + .if(viewModel.arePricesLoading) { view in + view.skeleton(with: true, size: CGSize(width: 160, height: 16)) + } + .frame(height: 16) + } + + var fromInput: some View { + BigInputView( + allButtonPressed: viewModel.allButtonPressed.send, + amountFieldTap: nil, + changeTokenPressed: nil, + accessibilityIdPrefix: "\(WithdrawCalculatorView.self).from", + title: L10n.youPay, + isBalanceVisible: false, + amount: $viewModel.fromAmount, + amountTextColor: $viewModel.fromAmountTextColor, + isFirstResponder: $viewModel.isFromFirstResponder, + decimalLength: $viewModel.decimalLength, + isEditable: $viewModel.isFromEnabled, + balance: $viewModel.fromBalance, + balanceText: $viewModel.fromBalanceText, + tokenSymbol: $viewModel.fromTokenSymbol, + isAmountLoading: $viewModel.arePricesLoading + ) + } + + var toInput: some View { + BigInputView( + allButtonPressed: nil, + amountFieldTap: nil, + changeTokenPressed: nil, + accessibilityIdPrefix: "\(WithdrawCalculatorView.self).to", + title: L10n.youReceive, + isBalanceVisible: false, + amount: $viewModel.toAmount, + isFirstResponder: $viewModel.isToFirstResponder, + decimalLength: $viewModel.decimalLength, + isEditable: $viewModel.isToEnabled, + balance: .constant(nil), + balanceText: .constant(""), + tokenSymbol: $viewModel.toTokenSymbol, + isAmountLoading: $viewModel.arePricesLoading + ) + } + + var disclaimerText: some View { + Text(L10n.forThisTransferTheExchangeRateIsnTGuaranteedWeWillUseTheRateAtTheMomentOfReceivingMoney) + .apply(style: .label1) + .foregroundColor(Color(Asset.Colors.mountain.color)) + .padding(.top, 16) + } +} + +struct WithdrawCalculatorView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + WithdrawCalculatorView(viewModel: WithdrawCalculatorViewModel()) + }.navigationTitle(L10n.withdraw) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/WithdrawCalculator/WithdrawCalculatorViewModel.swift b/p2p_wallet/Scenes/Main/BankTransfer/WithdrawCalculator/WithdrawCalculatorViewModel.swift new file mode 100644 index 0000000000..b526535039 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/WithdrawCalculator/WithdrawCalculatorViewModel.swift @@ -0,0 +1,318 @@ +import BankTransfer +import Combine +import Foundation +import KeyAppBusiness +import KeyAppKitCore +import KeyAppUI +import Reachability +import Resolver +import SolanaSwift +import UIKit + +final class WithdrawCalculatorViewModel: BaseViewModel, ObservableObject { + // MARK: - Dependencies + + @Injected private var bankTransferService: AnyBankTransferService + @Injected private var notificationService: NotificationService + @Injected private var reachability: Reachability + @Injected private var solanaAccountsService: SolanaAccountsService + + // MARK: - Properties + + let actionPressed = PassthroughSubject() + let allButtonPressed = PassthroughSubject() + let openBankTransfer = PassthroughSubject() + let openWithdraw = PassthroughSubject<(StrigaWithdrawalInfo, Double), Never>() + let proceedWithdraw = PassthroughSubject() + let isViewAppeared = PassthroughSubject() + + @Published var actionData = WithdrawCalculatorAction.zero + @Published var isLoading = false + + @Published var arePricesLoading = false + @Published var exchangeRatesInfo = "" + + @Published var fromAmount: Double? + @Published var toAmount: Double? + + @Published var fromAmountTextColor: UIColor = Asset.Colors.rose.color + + @Published var isFromFirstResponder = false + @Published var isToFirstResponder = false + @Published var isFromEnabled = true + @Published var isToEnabled = false + + @Published var fromBalance: Double? + @Published var fromBalanceText = "" + + @Published var fromTokenSymbol = TokenMetadata.usdc.symbol.uppercased() + @Published var toTokenSymbol = Constants.EUR.symbol + + @Published var decimalLength = Constants.decimals + + private var exchangeRatesFailCount = 0 + private var exchangeRatesTimer: Timer? + @Published private var exchangeRates: StrigaExchangeRates? + + override init() { + super.init() + bindProperties() + bindReachibility() + bindAccounts() + } + + deinit { + exchangeRatesTimer?.invalidate() + } +} + +private extension WithdrawCalculatorViewModel { + func bindProperties() { + Publishers.CombineLatest( + $fromAmount.eraseToAnyPublisher(), + $exchangeRates.eraseToAnyPublisher() // Calculate only toAmount with newRates and not visa versa + ) + .sink { [weak self] amount, rates in + guard let self, let rates else { return } + var newToAmount: Double? + if let amount { + newToAmount = amount * Double(rates.sell) + } + guard self.toAmount != newToAmount else { return } + self.toAmount = newToAmount + } + .store(in: &subscriptions) + + $toAmount + .sink { [weak self] amount in + guard let self, let rates = self.exchangeRates else { return } + var newFromAmount: Double? + if let amount { + newFromAmount = amount / Double(rates.sell) + } + guard self.fromAmount != newFromAmount else { return } + self.fromAmount = newFromAmount + } + .store(in: &subscriptions) + + // Validation + Publishers.CombineLatest3( + $exchangeRates.eraseToAnyPublisher(), + $fromAmount.eraseToAnyPublisher(), + $toAmount.eraseToAnyPublisher() + ) + .filter { $0.0 != nil } // Only if $exchangeRates is not failed. Otherwise it has own state + .sink { [weak self] _, fromAmount, toAmount in + guard let self else { return } + switch (fromAmount, toAmount) { + case (nil, _), (Double.zero, _): + self.actionData = .zero + self.fromAmountTextColor = Asset.Colors.night.color + case (fromAmount, toAmount) where toAmount > Constants.EUR.max: + actionData = WithdrawCalculatorAction( + isEnabled: false, + title: L10n.onlyPerOneTransfer(Constants.EUR.max.formattedFiat(currency: .eur)) + ) + self.fromAmountTextColor = Asset.Colors.rose.color + case (fromAmount, toAmount) where toAmount < Constants.EUR.min: + actionData = WithdrawCalculatorAction( + isEnabled: false, + title: L10n.asMinimalAmountForTransfer(Constants.EUR.min.formattedFiat(currency: .eur)) + ) + self.fromAmountTextColor = Asset.Colors.rose.color + case (fromAmount, toAmount) where fromAmount > self.fromBalance: + self.actionData = WithdrawCalculatorAction(isEnabled: false, title: L10n.notEnoughMoney) + self.fromAmountTextColor = Asset.Colors.rose.color + default: + actionData = WithdrawCalculatorAction(isEnabled: true, title: L10n.next.uppercaseFirst) + self.fromAmountTextColor = Asset.Colors.night.color + } + } + .store(in: &subscriptions) + + $fromBalance + .receive(on: RunLoop.main) + .map { [weak self] balance in + guard let self, let balance else { return "" } + return balance.toString(maximumFractionDigits: self.decimalLength) + } + .assignWeak(to: \.fromBalanceText, on: self) + .store(in: &subscriptions) + + allButtonPressed + .map { [weak self] in self?.fromBalance } + .receive(on: RunLoop.main) + .assignWeak(to: \.fromAmount, on: self) + .store(in: &subscriptions) + + $exchangeRates + .receive(on: RunLoop.main) + .map { [weak self] rates in + guard let self, let rates else { return "" } + return "\(1) \(self.toTokenSymbol) ≈ \(rates.sell) \(self.fromTokenSymbol)" + } + .assignWeak(to: \.exchangeRatesInfo, on: self) + .store(in: &subscriptions) + + actionPressed + .withLatestFrom(bankTransferService.value.state) + .sinkAsync { [weak self] state in + guard let self else { return } + if state.value.kycStatus != .approved { + self.openBankTransfer.send() + } else if state.value.isIBANNotReady { + self.isLoading = true + await self.bankTransferService.value.reload() + self.isLoading = false + self.proceedWithdraw.send() + } else { + self.proceedWithdraw.send() + } + } + .store(in: &subscriptions) + + proceedWithdraw + .withLatestFrom(bankTransferService.value.state) + .sinkAsync { [weak self] state in + guard let self else { return } + if state.value.isIBANNotReady { + self.notificationService.showDefaultErrorNotification() + } else { + let info = await self.getWithdrawalInfo() + self.openWithdraw.send((info, fromAmount ?? 0)) + } + } + .store(in: &subscriptions) + + $arePricesLoading + .filter { $0 } + .map { _ in WithdrawCalculatorAction(isEnabled: false, title: L10n.gettingRates) } + .receive(on: RunLoop.main) + .assignWeak(to: \.actionData, on: self) + .store(in: &subscriptions) + + isViewAppeared + .sink { [weak self] isAppeared in + if isAppeared { + self?.loadRates() + } else { + self?.cancelUpdate() + } + } + .store(in: &subscriptions) + } + + func bindAccounts() { + solanaAccountsService.statePublisher + .map { $0.value.first(where: { $0.mintAddress == TokenMetadata.usdc.mintAddress })?.cryptoAmount.amount } + .map { value in + if let value { + return Double(exactly: value) + } + return nil + } + .receive(on: RunLoop.main) + .assignWeak(to: \.fromBalance, on: self) + .store(in: &subscriptions) + } + + func bindReachibility() { + reachability.status + .withPrevious() + .filter { prev, current in + prev == .unavailable && prev != current // Only if value changed from unavailable + } + .sink { [weak self] _ in self?.loadRates() } + .store(in: &subscriptions) + + reachability.status + .filter { $0 == .unavailable } + .sink { [weak self] _ in + self?.notificationService.showConnectionErrorNotification() + self?.commonErrorHandling() + } + .store(in: &subscriptions) + } + + func loadRates() { + arePricesLoading = true + changeEditing(isEnabled: false) + Task { + do { + let response = try await bankTransferService.value.repository.exchangeRates( + from: fromTokenSymbol, + to: toTokenSymbol + ) + if response.sell.isEmpty { + throw StrigaProviderError.invalidResponse + } + exchangeRates = response + arePricesLoading = false + exchangeRatesFailCount = 0 + scheduleRatesUpdate() + changeEditing(isEnabled: true) + isFromFirstResponder = true + } catch let error as NSError where error.isNetworkConnectionError { + notificationService.showConnectionErrorNotification() + commonErrorHandling() + } catch { + commonErrorHandling() + exchangeRatesFailCount = exchangeRatesFailCount + 1 + if exchangeRatesFailCount < Constants.exchangeRatesMaxFailNumber { + loadRates() // Call request again + } else { + notificationService.showDefaultErrorNotification() + } + } + } + } + + func commonErrorHandling() { + cancelUpdate() + arePricesLoading = false + actionData = WithdrawCalculatorAction.failure + exchangeRates = nil + changeEditing(isEnabled: false) + } + + func changeEditing(isEnabled: Bool) { + isFromEnabled = isEnabled + isToEnabled = isEnabled + } + + // Timer for exchangeRates request + func scheduleRatesUpdate() { + cancelUpdate() + exchangeRatesTimer = .scheduledTimer( + withTimeInterval: Constants.exchangeRatesInterval, + repeats: true + ) { [weak self] _ in + self?.loadRates() + } + } + + func cancelUpdate() { + exchangeRatesTimer?.invalidate() + } + + func getWithdrawalInfo() async -> StrigaWithdrawalInfo { + isLoading = true + let info = try? await bankTransferService.value.getWithdrawalInfo() + let regData = try? await bankTransferService.value.getRegistrationData() + let receiver = [regData?.firstName, regData?.lastName].compactMap { $0 }.joined(separator: " ") + isLoading = false + return info ?? StrigaWithdrawalInfo(receiver: receiver) + } +} + +private enum Constants { + static let exchangeRatesMaxFailNumber = 3 + static let exchangeRatesInterval = TimeInterval(60) + static let decimals = 2 + + enum EUR { + static let symbol = "EUR" + static let min: Double = 10 + static let max: Double = 15000 + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/otp/Error/StrigaOTPHardErrorView.swift b/p2p_wallet/Scenes/Main/BankTransfer/otp/Error/StrigaOTPHardErrorView.swift new file mode 100644 index 0000000000..5dd38ae59c --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/otp/Error/StrigaOTPHardErrorView.swift @@ -0,0 +1,45 @@ +import Foundation +import KeyAppUI +import SwiftUI + +struct StrigaOTPHardErrorView: View { + let title: String + let subtitle: String + let onAction: () -> Void + let onSupport: () -> Void + + var body: some View { + HardErrorView( + title: title, + subtitle: subtitle, + content: { + VStack(spacing: 30) { + NewTextButton( + title: L10n.openWalletScreen, + style: .inverted, + expandable: true + ) { + onAction() + } + NewTextButton( + title: L10n.writeToSuppot, + style: .primaryWhite, + expandable: true + ) { + onSupport() + } + } + } + ) + } +} + +struct StrigaOTPHardErrorView_Previews: PreviewProvider { + static var previews: some View { + StrigaOTPHardErrorView( + title: L10n.pleaseWait1DayForTheNextTry, + subtitle: L10n.after5SMSRequestsWeDisabledItFor1DayToSecureYourAccount, + onAction: {}, onSupport: {} + ) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/otp/StrigaOTPCompletedView.swift b/p2p_wallet/Scenes/Main/BankTransfer/otp/StrigaOTPCompletedView.swift new file mode 100644 index 0000000000..3a1b5fbebe --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/otp/StrigaOTPCompletedView.swift @@ -0,0 +1,71 @@ +import KeyAppUI +import SwiftUI + +struct StrigaOTPCompletedView: View { + let image: UIImage + let title: String? + let subtitle: String + let actionTitle: String + let onAction: (() -> Void)? + let onHelp: (() -> Void)? + + public init( + image: UIImage, + title: String? = nil, + subtitle: String? = nil, + actionTitle: String, + onAction: (() -> Void)? = nil, + onHelp: (() -> Void)? = nil + ) { + self.image = image + self.title = title + self.subtitle = subtitle ?? "" + self.actionTitle = actionTitle + self.onAction = onAction + self.onHelp = onHelp + } + + public var body: some View { + NavigationView { + VStack(spacing: 0) { + Image(uiImage: image) + .padding(.top, 77.adaptiveHeight) + .padding(.bottom, title == nil ? 32 : 16) + + if let title { + Text(title) + .fontWeight(.bold) + .apply(style: .title2) + .padding(.bottom, 12) + } + + Text(subtitle) + .apply(style: .text1) + .multilineTextAlignment(.center) + + Spacer() + if let onAction { + NewTextButton( + title: actionTitle, + style: .primaryWhite, + expandable: true, + trailing: .arrowForward, + action: onAction + ) + } + } + .padding(.bottom, 40) + .padding(.horizontal, 16) + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + onHelp?() + } label: { + Image(uiImage: Asset.MaterialIcon.helpOutline.image) + } + } + } + .navigationBarBackButtonHidden(true) + } +} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/otp/StrigaOTPCoordinator.swift b/p2p_wallet/Scenes/Main/BankTransfer/otp/StrigaOTPCoordinator.swift new file mode 100644 index 0000000000..2dc5c641b4 --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/otp/StrigaOTPCoordinator.swift @@ -0,0 +1,265 @@ +import BankTransfer +import Combine +import Foundation +import KeyAppKitCore +import Onboarding +import Reachability +import Resolver +import SwiftyUserDefaults +import UIKit + +enum StrigaOTPCoordinatorResult { + case canceled + case verified +} + +enum StrigaOTPNavigation { + case `default` + case nextToRoot +} + +final class StrigaOTPCoordinator: Coordinator { + // MARK: - Dependencies + + @Injected private var helpLauncher: HelpCenterLauncher + @Injected private var reachability: Reachability + + // MARK: - Properties + + @SwiftyUserDefault(keyPath: \.strigaOTPResendCounter, options: .cached) + private var resendCounter: ResendCounter? + @SwiftyUserDefault(keyPath: \.strigaOTPConfirmErrorDate, options: .cached) + private var lastConfirmErrorData: Date? + @SwiftyUserDefault(keyPath: \.strigaOTPResendErrorDate, options: .cached) + private var lastResendErrorDate: Date? + + private let resultSubject = PassthroughSubject() + + private let navigationController: UINavigationController + private let phone: String + private let navigation: StrigaOTPNavigation + /// Injectable verify opt request + private let verifyHandler: (String) async throws -> Void + + /// Injectable resend opt request + private let resendHandler: () async throws -> Void + + // MARK: - Initialization + + init( + navigationController: UINavigationController, + phone: String, + navigation: StrigaOTPNavigation = .default, + verifyHandler: @escaping (String) async throws -> Void, + resendHandler: @escaping () async throws -> Void + ) { + self.navigationController = navigationController + self.phone = phone + self.navigation = navigation + self.verifyHandler = verifyHandler + self.resendHandler = resendHandler + } + + // MARK: - Methods + + override func start() -> AnyPublisher { + // Initialize timer + var timerHasJustInitialized = false + if resendCounter == nil { + resendCounter = .zero() + timerHasJustInitialized = true + } + + // Create viewModel + let viewModel = EnterSMSCodeViewModel( + phone: phone, + attemptCounter: Wrapper(resendCounter ?? .zero()), + strategy: .striga + ) + // Create viewController + let controller = EnterSMSCodeViewController(viewModel: viewModel) + controller.hidesBottomBarWhenPushed = true + controller.navigationItem.largeTitleDisplayMode = .never + + // Handle on confirm + viewModel.coordinatorIO.onConfirm + .sinkAsync { [weak self, weak viewModel] otp in + viewModel?.isLoading = true + defer { + viewModel?.isLoading = false + } + do { + try await self?.verifyHandler(otp) + self?.resendCounter = nil + self?.resultSubject.send(.verified) + } catch BankTransferError.otpExceededVerification { + self?.lastConfirmErrorData = Date().addingTimeInterval(60 * 60 * 24) + self?.handleOTPConfirmLimitError() + await self?.logAlertMessage(error: BankTransferError.otpExceededVerification) + } catch BankTransferError.otpInvalidCode { + viewModel?.coordinatorIO.error.send(APIGatewayError.invalidOTP) + await self?.logAlertMessage(error: BankTransferError.otpInvalidCode) + } catch { + viewModel?.coordinatorIO.error.send(error) + await self?.logAlertMessage(error: error) + } + } + .store(in: &subscriptions) + + // Handle show info + viewModel.coordinatorIO.showInfo + .sink(receiveValue: { [weak self] in + self?.helpLauncher.launch() + }) + .store(in: &subscriptions) + + // Handle on resend + viewModel.coordinatorIO.onResend + .sinkAsync { [weak self, weak viewModel] process in + process.start { + guard let self, let viewModel else { return } + await self.resendSMS(viewModel: viewModel, increaseTimer: !timerHasJustInitialized) + timerHasJustInitialized = false + } + } + .store(in: &subscriptions) + + // Handle going back + viewModel.coordinatorIO.goBack + .sinkAsync { [weak self, weak viewModel, unowned controller] in + viewModel?.isLoading = true + self?.navigationController.showAlert( + title: L10n.areYouSure, + message: L10n.youCanConfirmThePhoneNumberAndFinishTheRegistrationLater, + actions: [ + .init( + title: L10n.yesLeftThePage, + style: .default, + handler: { [weak controller] _ in + guard let controller else { return } + self?.dismiss(controller: controller) + } + ), + .init(title: L10n.noContinue, style: .cancel), + ] + ) + viewModel?.isLoading = false + } + .store(in: &subscriptions) + + // Handle initial event + if let lastResendErrorDate, lastResendErrorDate.timeIntervalSinceNow > 0 { + handleOTPExceededDailyLimitError() + } else if let lastConfirmErrorData, lastConfirmErrorData.timeIntervalSinceNow > 0 { + handleOTPConfirmLimitError() + } else { + _ = reachability.check() + present(controller: controller) + + if timerHasJustInitialized || resendCounter?.until.timeIntervalSinceNow < 0 { + // Send request only if lastResendErrorDate and lastConfirmErrorData are nil + // Resend OTP explicitly if timer is off (it cand be also launched on previous screen) + viewModel.resendButtonTapped() + } + } + + return resultSubject.prefix(1).eraseToAnyPublisher() + } + + private func resendSMS(viewModel: EnterSMSCodeViewModel, increaseTimer: Bool) async { + if increaseTimer { + self.increaseTimer(viewModel: viewModel) + } + + do { + try await resendHandler() + } catch BankTransferError.otpExceededDailyLimit { + handleOTPExceededDailyLimitError() + lastResendErrorDate = Date().addingTimeInterval(60 * 60 * 24) + await logAlertMessage(error: BankTransferError.otpExceededDailyLimit) + } catch { + viewModel.coordinatorIO.error.send(error) + await logAlertMessage(error: error) + } + } + + private func increaseTimer(viewModel: EnterSMSCodeViewModel) { + resendCounter = resendCounter?.incremented() + if let resendCounter { + viewModel.attemptCounter = Wrapper(resendCounter) + } + } + + private func handleOTPExceededDailyLimitError() { + let title = L10n.pleaseWait1DayForTheNextSMSRequest + let subtitle = L10n.after5SMSRequestsWeDisabledItFor1DayToSecureYourAccount + let errorController = StrigaOTPHardErrorView( + title: title, + subtitle: subtitle, + onAction: { [weak self] in + self?.navigationController.popToRootViewController(animated: true) + self?.resultSubject.send(.canceled) + }, onSupport: { [weak self] in + self?.helpLauncher.launch() + } + ).asViewController(withoutUIKitNavBar: true) + errorController.hidesBottomBarWhenPushed = true + navigationController.pushViewController(errorController, animated: true) + } + + private func handleOTPConfirmLimitError() { + let title = L10n.pleaseWait1DayForTheNextTry + let subtitle = L10n.after5IncorrectAttemptsWeDisabledSMSVerificationFor1DayToSecureYourAccount + let errorController = StrigaOTPHardErrorView( + title: title, + subtitle: subtitle, + onAction: { [weak self] in + self?.navigationController.popToRootViewController(animated: true) + self?.resultSubject.send(.canceled) + }, onSupport: { [weak self] in + self?.helpLauncher.launch() + } + ).asViewController(withoutUIKitNavBar: true) + errorController.hidesBottomBarWhenPushed = true + navigationController.pushViewController(errorController, animated: true) + } + + private func present(controller: UIViewController) { + switch navigation { + case .default: + navigationController.pushViewController(controller, animated: true) + case .nextToRoot: + navigationController.setViewControllers( + [navigationController.viewControllers.first!, controller], + animated: true + ) + } + } + + private func dismiss(controller _: UIViewController) { + navigationController.popViewController(animated: true) + resultSubject.send(.canceled) + } + + private func logAlertMessage(error: Error) async { + let loggerData = await AlertLoggerDataBuilder.buildLoggerData(error: error) + + DefaultLogManager.shared.log( + event: "Striga Registration iOS Alarm", + logLevel: .alert, + data: StrigaRegistrationAlertLoggerMessage( + userPubkey: loggerData.userPubkey, + platform: loggerData.platform, + appVersion: loggerData.appVersion, + timestamp: loggerData.timestamp, + error: .init( + source: "striga api", + kycSDKState: "initial", + error: loggerData.otherError ?? "" + ) + ) + ) + } +} + +extension ResendCounter: DefaultsSerializable {} diff --git a/p2p_wallet/Scenes/Main/BankTransfer/otp/StrigaOTPSuccessCoordinator.swift b/p2p_wallet/Scenes/Main/BankTransfer/otp/StrigaOTPSuccessCoordinator.swift new file mode 100644 index 0000000000..83de585b4d --- /dev/null +++ b/p2p_wallet/Scenes/Main/BankTransfer/otp/StrigaOTPSuccessCoordinator.swift @@ -0,0 +1,42 @@ +import Combine +import Foundation +import Resolver +import UIKit + +final class StrigaOTPSuccessCoordinator: Coordinator { + @Injected private var helpLauncher: HelpCenterLauncher + + private let navigationController: UINavigationController + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + + override func start() -> AnyPublisher { + let view = StrigaOTPCompletedView( + image: .catSuccess, + title: L10n.verifyYourIdentity, + subtitle: L10n.TheLastStepIsDocumentAndSelfieVerification + .thisIsAOneTimeProcedureToEnsureSafetyOfYourAccount, + actionTitle: L10n.continue, + onAction: { [weak self] in + self?.navigationController.popToRootViewController(animated: true) + } + ) { [weak self] in + self?.helpLauncher.launch() + } + + let controller = view.asViewController(withoutUIKitNavBar: false) + controller.navigationItem.hidesBackButton = true + controller.hidesBottomBarWhenPushed = true + navigationController.setViewControllers([ + navigationController.viewControllers.first, + controller, + ].compactMap { $0 }, animated: true) + + return controller + .deallocatedPublisher() + .prefix(1) + .eraseToAnyPublisher() + } +} diff --git a/p2p_wallet/Scenes/Main/Buy/BuyCoordinator.swift b/p2p_wallet/Scenes/Main/Buy/BuyCoordinator.swift index 5d51592b90..82a9c2e51f 100644 --- a/p2p_wallet/Scenes/Main/Buy/BuyCoordinator.swift +++ b/p2p_wallet/Scenes/Main/Buy/BuyCoordinator.swift @@ -16,7 +16,6 @@ final class BuyCoordinator: Coordinator { private let defaultPaymentType: PaymentType? private let targetTokenSymbol: String? - private let vcPresentedPercentage = PassthroughSubject() @Injected private var analyticsManager: AnalyticsManager init( @@ -56,7 +55,6 @@ final class BuyCoordinator: Coordinator { } } else { if shouldPush { - navigationController?.interactivePopGestureRecognizer?.addTarget(self, action: #selector(onGesture)) navigationController?.pushViewController(viewController, animated: true) } else { let navigation = UINavigationController(rootViewController: viewController) @@ -134,12 +132,6 @@ final class BuyCoordinator: Coordinator { .assignWeak(to: \.value, on: viewModel.coordinatorIO.fiatSelected) .store(in: &subscriptions) - vcPresentedPercentage.eraseToAnyPublisher() - .sink(receiveValue: { val in - viewModel.coordinatorIO.navigationSlidingPercentage.send(val) - }) - .store(in: &subscriptions) - viewModel.coordinatorIO.buy.sink(receiveValue: { [weak self] url in let vc = SFSafariViewController(url: url) vc.modalPresentationStyle = .automatic @@ -157,60 +149,15 @@ final class BuyCoordinator: Coordinator { viewController.present(vc, animated: true) }) .store(in: &subscriptions) + viewModel.coordinatorIO.close .sink(receiveValue: { [unowned self] in navigationController.popViewController(animated: true) }) .store(in: &subscriptions) - viewModel.coordinatorIO.chooseCountry - .sink(receiveValue: { [weak self] selectedCountry in - guard let self else { return } - - let selectCountryViewModel = SelectCountryViewModel(selectedCountry: selectedCountry) - let selectCountryViewController = SelectCountryView(viewModel: selectCountryViewModel) - .asViewController(withoutUIKitNavBar: false) - viewController.navigationController?.pushViewController(selectCountryViewController, animated: true) - - selectCountryViewModel.selectCountry - .sink(receiveValue: { item in - viewModel.countrySelected(item.0, buyAllowed: item.buyAllowed) - viewController.navigationController?.popViewController(animated: true) - }) - .store(in: &self.subscriptions) - selectCountryViewModel.currentSelected - .sink(receiveValue: { - viewController.navigationController?.popViewController(animated: true) - }) - .store(in: &self.subscriptions) - }) - .store(in: &subscriptions) return result.prefix(1).eraseToAnyPublisher() } - - // MARK: - Gesture - - private var currentTransitionCoordinator: UIViewControllerTransitionCoordinator? - - @objc private func onGesture(sender: UIGestureRecognizer) { - switch sender.state { - case .began, .changed: - if let ct = navigationController.transitionCoordinator { - currentTransitionCoordinator = ct - } - case .cancelled, .ended: - // currentTransitionCoordinator = nil - break - case .possible, .failed: - break - @unknown default: - break - } -// if let currentTransitionCoordinator = currentTransitionCoordinator { -// vcPresentedPercentage.send(currentTransitionCoordinator.percentComplete) -// } - vcPresentedPercentage.send(navigationController.transitionCoordinator?.percentComplete ?? 1) - } } // MARK: - Context diff --git a/p2p_wallet/Scenes/Main/Buy/BuyView.swift b/p2p_wallet/Scenes/Main/Buy/BuyView.swift index 7669bd38c0..06a4580b51 100644 --- a/p2p_wallet/Scenes/Main/Buy/BuyView.swift +++ b/p2p_wallet/Scenes/Main/Buy/BuyView.swift @@ -24,24 +24,8 @@ struct BuyView: View, KeyboardVisibilityReadable { content.toolbar { ToolbarItem(placement: .principal) { HStack { - Spacer() - Spacer() Text(L10n.buyWithMoonpay) .fontWeight(.semibold) - Spacer() - Button( - action: { - viewModel.flagClicked() - }, - label: { - HStack(spacing: 10) { - Text(viewModel.flag) - .font(uiFont: .font(of: .title1, weight: .bold)) - Image(uiImage: .chevronDown) - .foregroundColor(Color(Asset.Colors.mountain.color)) - } - } - ) } } } @@ -49,23 +33,6 @@ struct BuyView: View, KeyboardVisibilityReadable { @ViewBuilder private var content: some View { - switch viewModel.state { - case .usual: - basicContent - case let .buyNotAllowed(model): - ChangeCountryErrorView( - model: model, - buttonAction: { - viewModel.goBackClicked() - }, - subButtonAction: { - viewModel.changeTheRegionClicked() - } - ) - } - } - - private var basicContent: some View { VStack(spacing: 0) { ScrollView { // Tutorial @@ -293,6 +260,10 @@ struct BuyView: View, KeyboardVisibilityReadable { .apply(style: .label1) .foregroundColor(Color(Asset.Colors.mountain.color)) Image(uiImage: item.icon) + .renderingMode(.template) + .resizable() + .frame(width: 16, height: 16) + .foregroundColor(Color(Asset.Colors.mountain.color)) .padding(.leading, -4) .padding(.top, -1) Spacer() diff --git a/p2p_wallet/Scenes/Main/Buy/BuyViewModel.swift b/p2p_wallet/Scenes/Main/Buy/BuyViewModel.swift index 0240558e85..78d5fd5aaf 100644 --- a/p2p_wallet/Scenes/Main/Buy/BuyViewModel.swift +++ b/p2p_wallet/Scenes/Main/Buy/BuyViewModel.swift @@ -21,7 +21,6 @@ final class BuyViewModel: ObservableObject { // MARK: - To View - @Published var state: State = .usual @Published var flag = String.neutralFlag @Published var availableMethods = [PaymentTypeItem]() @Published var token: TokenMetadata @@ -241,45 +240,6 @@ final class BuyViewModel: ObservableObject { self.areMethodsLoading = false } } - - getBuyAvailability() - } - - private func getBuyAvailability() { - Task { - let ipInfo = try await moonpayProvider.ipAddresses() - await MainActor.run { - setNewCountryInfo( - flag: ipInfo.alpha2.asFlag ?? .neutralFlag, - title: ipInfo.countryTitle, - isBuyAllowed: ipInfo.isBuyAllowed - ) - } - } - } - - private func setNewCountryInfo(flag: String, title: String, isBuyAllowed: Bool) { - guard !title.isEmpty else { - state = .usual - self.flag = .neutralFlag - return - } - - if isBuyAllowed { - state = .usual - } else { - let model = ChangeCountryErrorView.ChangeCountryModel( - image: .connectionErrorCat, - title: L10n.sorry, - subtitle: L10n.unfortunatelyYouCanNotBuyInButYouCanStillUseOtherKeyAppFeatures(title), - buttonTitle: L10n.goBack, - subButtonTitle: L10n.changeTheRegionManually - ) - state = .buyNotAllowed(model: model) - analyticsManager.log(event: .buyBlockedScreenOpen) - } - self.flag = flag - countryTitle = title } // MARK: - From View @@ -288,22 +248,6 @@ final class BuyViewModel: ObservableObject { coordinatorIO.close.send() } - func changeTheRegionClicked() { - coordinatorIO.chooseCountry.send(SelectCountryViewModel.Model( - flag: flag, - title: countryTitle ?? "" - )) - analyticsManager.log(event: .buyBlockedRegionClick) - } - - func flagClicked() { - coordinatorIO.chooseCountry.send(SelectCountryViewModel.Model( - flag: flag, - title: countryTitle ?? "" - )) - analyticsManager.log(event: .buyChangeCountryClick) - } - // MARK: - @MainActor func didSelectPayment(_ payment: PaymentTypeItem) { @@ -567,10 +511,6 @@ final class BuyViewModel: ObservableObject { } } - func countrySelected(_ country: SelectCountryViewModel.Model, buyAllowed: Bool) { - setNewCountryInfo(flag: country.flag, title: country.title, isBuyAllowed: buyAllowed) - } - struct CoordinatorIO { // To Coordinator let showDetail = PassthroughSubject<( @@ -581,8 +521,6 @@ final class BuyViewModel: ObservableObject { ), Never>() let showTokenSelect = PassthroughSubject<[TokenCellViewItem], Never>() let showFiatSelect = PassthroughSubject<[Fiat], Never>() - let navigationSlidingPercentage = PassthroughSubject() - let chooseCountry = PassthroughSubject() // From Coordinator let tokenSelected = CurrentValueSubject(nil) @@ -598,20 +536,3 @@ final class BuyViewModel: ObservableObject { var enabled: Bool } } - -// MARK: - State - -extension BuyViewModel { - enum State { - case usual - case buyNotAllowed(model: ChangeCountryErrorView.ChangeCountryModel) - } -} - -// MARK: - Country Title - -extension Moonpay.Provider.IpAddressResponse { - var countryTitle: String { - country + (alpha2 == "US" ? " (\(state))" : "") - } -} diff --git a/p2p_wallet/Scenes/Main/Common/ChooseItem/ChooseItemCoordinator.swift b/p2p_wallet/Scenes/Main/Common/ChooseItem/ChooseItemCoordinator.swift new file mode 100644 index 0000000000..903a28fc88 --- /dev/null +++ b/p2p_wallet/Scenes/Main/Common/ChooseItem/ChooseItemCoordinator.swift @@ -0,0 +1,75 @@ +import Combine +import Foundation +import UIKit + +enum ChooseItemCoordinatorResult { + case item(item: any ChooseItemSearchableItem) + case cancel +} + +final class ChooseItemCoordinator: Coordinator { + private let title: String? + private let controller: UIViewController + private let service: any ChooseItemService + private let chosen: (any ChooseItemSearchableItem)? + private let showDoneButton: Bool + private let isSearchEnabled: Bool + private weak var viewController: UIViewController? + + init( + title: String? = nil, + controller: UIViewController, + service: any ChooseItemService, + chosen: (any ChooseItemSearchableItem)?, + showDoneButton: Bool = false, + isSearchEnabled: Bool = true + ) { + self.title = title + self.controller = controller + self.service = service + self.chosen = chosen + self.showDoneButton = showDoneButton + self.isSearchEnabled = isSearchEnabled + } + + override func start() -> AnyPublisher { + let isWrapped = controller is UINavigationController + let viewModel = ChooseItemViewModel( + service: service, + chosenItem: chosen, + isSearchEnabled: isSearchEnabled + ) + let view = ChooseItemView(viewModel: viewModel) { model in + (model.item as? T)?.render() + } + let vc = view.asViewController(withoutUIKitNavBar: false, ignoresKeyboard: true) + vc.navigationItem.title = title + controller.show( + isWrapped ? vc : UINavigationController(rootViewController: vc), + sender: nil + ) + if showDoneButton { + vc.navigationItem.rightBarButtonItem = UIBarButtonItem( + title: L10n.done, + style: .plain, + target: self, + action: #selector(dismiss) + ) + } + + viewController = vc + return Publishers.Merge( + controller.deallocatedPublisher().map { ChooseItemCoordinatorResult.cancel }, + viewModel.chooseTokenSubject.map { ChooseItemCoordinatorResult.item(item: $0) } + .handleEvents(receiveOutput: { [weak self] _ in + isWrapped ? (self?.controller as? UINavigationController)? + .popViewController(animated: true, completion: {}) : self?.controller.dismiss(animated: true) + }) + ).prefix(1).eraseToAnyPublisher() + } + + @objc func dismiss() { + // Dismiss since Done shouldn't be displayed on navigation? + viewController?.dismiss(animated: true) + } +} diff --git a/p2p_wallet/Scenes/Main/Common/ChooseItem/ChooseItemService.swift b/p2p_wallet/Scenes/Main/Common/ChooseItem/ChooseItemService.swift index 7173da131c..9d28c73aee 100644 --- a/p2p_wallet/Scenes/Main/Common/ChooseItem/ChooseItemService.swift +++ b/p2p_wallet/Scenes/Main/Common/ChooseItem/ChooseItemService.swift @@ -2,7 +2,9 @@ import Combine import KeyAppKitCore protocol ChooseItemService { - var otherTokensTitle: String { get } + var otherTitle: String { get } + var chosenTitle: String { get } + var emptyTitle: String { get } var state: AnyPublisher, Never> { get } func sort(items: [ChooseItemListSection]) -> [ChooseItemListSection] diff --git a/p2p_wallet/Scenes/Main/Common/ChooseItem/ChooseItemView.swift b/p2p_wallet/Scenes/Main/Common/ChooseItem/ChooseItemView.swift index 29fb1fc56c..7e06ae0d88 100644 --- a/p2p_wallet/Scenes/Main/Common/ChooseItem/ChooseItemView.swift +++ b/p2p_wallet/Scenes/Main/Common/ChooseItem/ChooseItemView.swift @@ -2,9 +2,19 @@ import KeyAppUI import SolanaSwift import SwiftUI +/// In case of successful experiment make a base Renderable protocol +protocol ChooseItemRenderable: Identifiable where ID == String { + associatedtype ViewType: View + + var id: String { get } + + @ViewBuilder func render() -> ViewType +} + struct ChooseItemView: View { @ObservedObject private var viewModel: ChooseItemViewModel @ViewBuilder private let content: (ChooseItemSearchableItemViewModel) -> Content + @FocusState private var isSearchFieldFocused init( viewModel: ChooseItemViewModel, @@ -15,26 +25,33 @@ struct ChooseItemView: View { } var body: some View { - ColoredBackground { - VStack(spacing: 16) { + VStack(spacing: 16) { + if viewModel.isSearchEnabled { // Search field SearchField( searchText: $viewModel.searchText, - isSearchFieldFocused: $viewModel.isSearchFieldFocused + isFocused: _isSearchFieldFocused ) .padding(.horizontal, 16) .padding(.top, 16) + } - // List of tokens - if viewModel.isLoading { - loadingView - } else if viewModel.sections.isEmpty { - emptyView - } else { - listView - } + // List of tokens + if viewModel.isLoading { + loadingView + } else if viewModel.sections.isEmpty { + emptyView + } else { + listView } - .ignoresSafeArea(.keyboard) + } + .background(Color(asset: Asset.Colors.smoke).ignoresSafeArea()) + .ignoresSafeArea(.keyboard) + .onAppear { + isSearchFieldFocused = true + } + .onDisappear { + isSearchFieldFocused = false } } @@ -58,7 +75,7 @@ struct ChooseItemView: View { private extension ChooseItemView { private var emptyView: some View { Group { - NotFoundView(text: L10n.TokenNotFound.tryAnotherOne) + NotFoundView(text: viewModel.emptyTitle) .accessibility(identifier: "ChooseItemView.NotFoundView") .padding(.top, 30) Spacer() @@ -71,23 +88,23 @@ private extension ChooseItemView { private var listView: some View { WrappedList { - if !viewModel.isSearchGoing { + if let chosen = viewModel.chosenItem, !viewModel.isSearchGoing { // Chosen token - Text(L10n.chosenToken.uppercased()) + Text(viewModel.chosenTitle.uppercased()) .sectionStyle() ChooseItemSearchableItemView( content: content, state: .single, - item: viewModel.chosenToken, + item: chosen, isChosen: true ) .onTapGesture { - viewModel.chooseTokenSubject.send(viewModel.chosenToken) + viewModel.chooseTokenSubject.send(chosen) } } // Search resuls or all tokens - Text(viewModel.isSearchGoing ? L10n.hereSWhatWeFound.uppercased() : viewModel.otherTokensTitle.uppercased()) + Text(viewModel.isSearchGoing ? L10n.hereSWhatWeFound.uppercased() : viewModel.otherTitle.uppercased()) .sectionStyle() ForEach(viewModel.sections) { section in diff --git a/p2p_wallet/Scenes/Main/Common/ChooseItem/ChooseItemViewModel.swift b/p2p_wallet/Scenes/Main/Common/ChooseItem/ChooseItemViewModel.swift index bd79b5f17a..b4901a8fa0 100644 --- a/p2p_wallet/Scenes/Main/Common/ChooseItem/ChooseItemViewModel.swift +++ b/p2p_wallet/Scenes/Main/Common/ChooseItem/ChooseItemViewModel.swift @@ -8,22 +8,25 @@ final class ChooseItemViewModel: BaseViewModel, ObservableObject { @Published var sections: [ChooseItemListSection] = [] @Published var searchText: String = "" - @Published var isSearchFieldFocused: Bool = false @Published var isSearchGoing: Bool = false @Published var isLoading: Bool = true + let isSearchEnabled: Bool - var otherTokensTitle: String { service.otherTokensTitle } + var otherTitle: String { service.otherTitle } + var chosenTitle: String { service.chosenTitle } + var emptyTitle: String { service.emptyTitle } - let chosenToken: any ChooseItemSearchableItem + let chosenItem: (any ChooseItemSearchableItem)? private let service: ChooseItemService private var allItems: [ChooseItemListSection] = [] // All available items @Injected private var notifications: NotificationService - init(service: ChooseItemService, chosenToken: any ChooseItemSearchableItem) { - self.chosenToken = chosenToken + init(service: ChooseItemService, chosenItem: (any ChooseItemSearchableItem)?, isSearchEnabled: Bool) { + self.chosenItem = chosenItem self.service = service + self.isSearchEnabled = isSearchEnabled super.init() bind() } @@ -40,7 +43,7 @@ private extension ChooseItemViewModel { _ = state.apply { data in let dataWithoutChosen = data.map { section in ChooseItemListSection( - items: section.items.filter { $0.id != self.chosenToken.id } + items: section.items.filter { $0.id != self.chosenItem?.id } ) } self.allItems = self.service.sort(items: dataWithoutChosen) @@ -67,22 +70,24 @@ private extension ChooseItemViewModel { $searchText .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main) - .sinkAsync(receiveValue: { [weak self] value in - guard let self else { return } - self.isSearchGoing = !value.isEmpty - if value.isEmpty { - self.sections = self.allItems - } else { - // Do not split up sections if there is a keyword - let searchedItems = self.allItems - .flatMap(\.items) - .filter { $0.matches(keyword: value.lowercased()) } - self.sections = self.service.sortFiltered( - by: value.lowercased(), - items: [ChooseItemListSection(items: searchedItems)] - ) - } + .receive(on: RunLoop.main) + .handleEvents(receiveOutput: { [unowned self] value in + isSearchGoing = !value.isEmpty }) + .map { [unowned self] value in + guard !value.isEmpty else { + return allItems + } + let searchedItems = allItems + .flatMap(\.items) + .filter { $0.matches(keyword: value.lowercased()) } + return service.sortFiltered( + by: value.lowercased(), + items: [ChooseItemListSection(items: searchedItems)] + ) + } + .receive(on: RunLoop.main) + .assignWeak(to: \.sections, on: self) .store(in: &subscriptions) } } diff --git a/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/Aggregator/CryptoAccountsAggregator.swift b/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/Aggregator/CryptoAccountsAggregator.swift index 0f3c5cddf4..0566c5d0d9 100644 --- a/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/Aggregator/CryptoAccountsAggregator.swift +++ b/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/Aggregator/CryptoAccountsAggregator.swift @@ -21,7 +21,7 @@ struct CryptoAccountsAggregator: DataAggregator { // Claimable transfer accounts let transferAccounts = allEthereumAccounts.filter { ethAccount in switch ethAccount.status { - case .readyToClaim, .isClaiming: + case .ready, .isProcessing: return true default: return false diff --git a/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/Aggregator/CryptoEthereumAccountsAggregator.swift b/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/Aggregator/CryptoEthereumAccountsAggregator.swift index d5a7d16762..12044f1ffc 100644 --- a/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/Aggregator/CryptoEthereumAccountsAggregator.swift +++ b/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/Aggregator/CryptoEthereumAccountsAggregator.swift @@ -44,7 +44,7 @@ struct CryptoEthereumAccountsAggregator: DataAggregator { // Compare using fiat. if balanceInFiat >= CurrencyAmount(usd: 5) { // Balance is greater than $1, user can claim. - status = .readyToClaim + status = .ready } else { // Balance is to low. status = .balanceToLow @@ -56,7 +56,7 @@ struct CryptoEthereumAccountsAggregator: DataAggregator { } else { // Claiming is running. - status = .isClaiming + status = .isProcessing } return RenderableEthereumAccount( diff --git a/p2p_wallet/Scenes/Main/Crypto/Container/CryptoCoordinator.swift b/p2p_wallet/Scenes/Main/Crypto/Container/CryptoCoordinator.swift index 496863539f..3a4a1ad6a6 100644 --- a/p2p_wallet/Scenes/Main/Crypto/Container/CryptoCoordinator.swift +++ b/p2p_wallet/Scenes/Main/Crypto/Container/CryptoCoordinator.swift @@ -62,7 +62,7 @@ final class CryptoCoordinator: Coordinator { actionsPanelViewModel: actionsPanelViewModel, accountsViewModel: accountsViewModel ) - let cryptoVC = UIHostingController(rootView: cryptoView) + let cryptoVC = cryptoView.asViewController(withoutUIKitNavBar: false) cryptoVC.title = L10n.myCrypto navigationController.setViewControllers([cryptoVC], animated: false) diff --git a/p2p_wallet/Scenes/Main/History/New/Aggregator/HistoryPendingTransactionAggregator.swift b/p2p_wallet/Scenes/Main/History/New/Aggregator/HistoryPendingTransactionAggregator.swift index a2e9841f80..977e2eac46 100644 --- a/p2p_wallet/Scenes/Main/History/New/Aggregator/HistoryPendingTransactionAggregator.swift +++ b/p2p_wallet/Scenes/Main/History/New/Aggregator/HistoryPendingTransactionAggregator.swift @@ -33,6 +33,8 @@ class HistoryPendingTransactionAggregator: DataAggregator { transaction.destinationWallet.mintAddress == mint case let transaction as ClaimSentViaLinkTransaction: return transaction.claimableTokenInfo.mintAddress == mint + case let transaction as any StrigaClaimTransactionType: + return transaction.token?.address == mint default: return false } diff --git a/p2p_wallet/Scenes/Main/History/New/Aggregator/HistoryViewModelAggregator.swift b/p2p_wallet/Scenes/Main/History/New/Aggregator/HistoryViewModelAggregator.swift index 55cd3e50ed..7c7e1f7506 100644 --- a/p2p_wallet/Scenes/Main/History/New/Aggregator/HistoryViewModelAggregator.swift +++ b/p2p_wallet/Scenes/Main/History/New/Aggregator/HistoryViewModelAggregator.swift @@ -36,6 +36,8 @@ enum HistoryViewModelAggregator { transaction.destinationWallet.mintAddress == mint case let transaction as ClaimSentViaLinkTransaction: return transaction.claimableTokenInfo.mintAddress == mint + case let transaction as any StrigaClaimTransactionType: + return transaction.token?.address == mint default: return false } diff --git a/p2p_wallet/Scenes/Main/History/New/Model/RendableListTransactionItem+PendingTransaction.swift b/p2p_wallet/Scenes/Main/History/New/Model/RendableListTransactionItem+PendingTransaction.swift index 3aec0fe47f..c5b0adea35 100644 --- a/p2p_wallet/Scenes/Main/History/New/Model/RendableListTransactionItem+PendingTransaction.swift +++ b/p2p_wallet/Scenes/Main/History/New/Model/RendableListTransactionItem+PendingTransaction.swift @@ -66,6 +66,15 @@ struct RendableListPendingTransactionItem: RendableListTransactionItem { } else { return .icon(.transactionReceive) } + case let transaction as any StrigaClaimTransactionType: + if + let urlStr = transaction.token?.logoURI, + let url = URL(string: urlStr) + { + return .single(url) + } else { + return .icon(.transactionReceive) + } default: return .icon(.planet) } @@ -92,6 +101,8 @@ struct RendableListPendingTransactionItem: RendableListTransactionItem { return L10n .from(RecipientFormatter .shortFormat(destination: transaction.claimableTokenInfo.keypair.publicKey.base58EncodedString)) + case let transaction as any StrigaClaimTransactionType: + return L10n.from(RecipientFormatter.shortFormat(destination: transaction.fromAddress)) default: return L10n.unknown } @@ -109,6 +120,8 @@ struct RendableListPendingTransactionItem: RendableListTransactionItem { case _ as ClaimSentViaLinkTransaction: return "\(L10n.sendViaLink)" + case _ as any StrigaClaimTransactionType: + return "\(L10n.claim)" default: return "\(L10n.transactionFailed)" } @@ -136,6 +149,9 @@ struct RendableListPendingTransactionItem: RendableListTransactionItem { return "\(L10n.processing)" } + case _ as any StrigaClaimTransactionType: + return "\(L10n.pending.capitalized)..." + default: if trx.transactionId == nil { return "\(L10n.processing).." @@ -162,6 +178,11 @@ struct RendableListPendingTransactionItem: RendableListTransactionItem { return (.unchanged, "") } return (.positive, "+\(amountInFiat.fiatAmountFormattedString())") + case let transaction as any StrigaClaimTransactionType: + guard let amountInFiat = transaction.amountInFiat else { + return (.unchanged, "") + } + return (.unchanged, "+\(amountInFiat.fiatAmountFormattedString())") default: return (.unchanged, "") } @@ -178,6 +199,9 @@ struct RendableListPendingTransactionItem: RendableListTransactionItem { case let transaction as ClaimSentViaLinkTransaction: return "+\(transaction.tokenAmount.tokenAmountFormattedString(symbol: transaction.token.symbol))" + case let transaction as any StrigaClaimTransactionType: + guard let amount = transaction.amount else { return "" } + return "+\(amount.tokenAmountFormattedString(symbol: transaction.token?.symbol ?? ""))" default: return "" } diff --git a/p2p_wallet/Scenes/Main/NewHome/HomeCoordinator.swift b/p2p_wallet/Scenes/Main/NewHome/HomeCoordinator.swift index 336d7976c3..d5e04f4b40 100644 --- a/p2p_wallet/Scenes/Main/NewHome/HomeCoordinator.swift +++ b/p2p_wallet/Scenes/Main/NewHome/HomeCoordinator.swift @@ -1,4 +1,5 @@ import AnalyticsManager +import BankTransfer import Combine import Foundation import KeyAppBusiness @@ -11,17 +12,19 @@ import Wormhole enum HomeNavigation: Equatable { // HomeWithTokens - case buy case receive(publicKey: PublicKey) - case send - case swap case cashOut - case earn case solanaAccount(SolanaAccount) case claim(EthereumAccount, WormholeClaimUserAction?) - case actions([WalletActionType]) + case bankTransferClaim(StrigaClaimTransaction) + case bankTransferConfirm(StrigaWithdrawTransaction) // HomeEmpty case topUpCoin(TokenMetadata) + case topUp // Top up via bank transfer, bank card or crypto receive + case withdrawActions // Withdraw actions My bank account / somone else + case bankTransfer // Only bank transfer + case withdrawCalculator + case withdrawInfo(StrigaWithdrawalInfo, WithdrawConfirmationParameters) // Error case error(show: Bool) @@ -42,6 +45,8 @@ final class HomeCoordinator: Coordinator { var tokensViewModel: HomeAccountsViewModel? let navigation = PassthroughSubject() + /// A list of actions required to check if country is selected + private let regionSelectionReqired = [HomeNavigation.withdrawActions, .addMoney] // MARK: - Initializers @@ -90,13 +95,25 @@ final class HomeCoordinator: Coordinator { } // handle navigation - navigation - .flatMap { [unowned self] in - navigate(to: $0, homeView: homeView) + navigation.flatMap { [unowned self] action in + if regionSelectionReqired.contains(action), Defaults.region == nil { + return coordinate(to: SelectRegionCoordinator(navigationController: navigationController)) + .handleEvents(receiveOutput: { [unowned self] _ in + navigationController.popViewController(animated: true) + }) + .flatMap { [unowned self] result in + switch result { + case .selected: + return navigate(to: action, homeView: homeView) + case .cancelled: + return Just(()).eraseToAnyPublisher() + } + }.eraseToAnyPublisher() } - .sink(receiveValue: {}) - .store(in: &subscriptions) - + return navigate(to: action, homeView: homeView) + } + .sink(receiveValue: {}) + .store(in: &subscriptions) // return publisher return resultSubject.prefix(1).eraseToAnyPublisher() } @@ -105,11 +122,7 @@ final class HomeCoordinator: Coordinator { private func navigate(to scene: HomeNavigation, homeView: UIViewController) -> AnyPublisher { switch scene { - case .buy: - return coordinate(to: BuyCoordinator(navigationController: navigationController, context: .fromHome)) - .map { _ in () } - .eraseToAnyPublisher() - case let .receive(publicKey): + case .receive: if available(.ethAddressEnabled) { let coordinator = SupportedTokensCoordinator( presentation: SmartCoordinatorPushPresentation(navigationController) @@ -118,37 +131,12 @@ final class HomeCoordinator: Coordinator { .eraseToAnyPublisher() } else { let coordinator = ReceiveCoordinator( - network: .solana(tokenSymbol: "SOL", tokenImage: .image(.solanaIcon)), + network: .solana(tokenSymbol: Token.nativeSolana.symbol, tokenImage: .image(.solanaIcon)), presentation: SmartCoordinatorPushPresentation(navigationController) ) return coordinate(to: coordinator).eraseToAnyPublisher() } - case .send: - return coordinate( - to: SendCoordinator( - rootViewController: navigationController, - preChosenWallet: nil, - hideTabBar: true, - allowSwitchingMainAmountType: true - ) - ) - .receive(on: RunLoop.main) - .handleEvents(receiveOutput: { [weak self] result in - switch result { - case let .sent(model): - self?.navigationController.popToRootViewController(animated: true) - self?.showSendTransactionStatus(model: model) - case let .wormhole(trx): - self?.navigationController.popToRootViewController(animated: true) - self?.showUserAction(userAction: trx) - case .sentViaLink: - self?.navigationController.popToRootViewController(animated: true) - case .cancelled: - break - } - }) - .map { _ in () } - .eraseToAnyPublisher() + case let .claim(account, userAction): if let userAction, userAction.status == .processing { return coordinate(to: TransactionDetailCoordinator( @@ -181,19 +169,14 @@ final class HomeCoordinator: Coordinator { .map { _ in () } .eraseToAnyPublisher() } - case .swap: - analyticsManager.log(event: .swapViewed(lastScreen: "main_screen")) - return coordinate( - to: JupiterSwapCoordinator( - navigationController: navigationController, - params: JupiterSwapParameters( - dismissAfterCompletion: true, - openKeyboardOnStart: true, - source: .actionPanel - ) - ) - ) - .eraseToAnyPublisher() + case let .bankTransferClaim(transaction): + return openBankTransferClaimCoordinator(transaction: transaction) + .eraseToAnyPublisher() + + case let .bankTransferConfirm(transaction): + return openBankTransferClaimCoordinator(transaction: transaction) + .eraseToAnyPublisher() + case .cashOut: analyticsManager.log(event: .sellClicked(source: "Main")) return coordinate( @@ -216,9 +199,6 @@ final class HomeCoordinator: Coordinator { }) .map { _ in () } .eraseToAnyPublisher() - case .earn: - return Just(()) - .eraseToAnyPublisher() case let .solanaAccount(solanaAccount): analyticsManager.log(event: .mainScreenTokenDetailsOpen(tokenTicker: solanaAccount.token.symbol)) @@ -231,9 +211,69 @@ final class HomeCoordinator: Coordinator { ) .map { _ in () } .eraseToAnyPublisher() - case .actions: - return Just(()) + case .topUp: + return Just(()).eraseToAnyPublisher() + case .bankTransfer: + return coordinate(to: BankTransferCoordinator(viewController: navigationController)) .eraseToAnyPublisher() + case .withdrawActions: + return coordinate(to: WithdrawActionsCoordinator(viewController: navigationController)) + .flatMap { [unowned self] result in + switch result { + case let .action(action): + switch action { + case .transfer: + return self.navigate(to: .withdrawCalculator, homeView: homeView) + case .user, .wallet: + return coordinate(to: SendCoordinator( + rootViewController: navigationController, + preChosenWallet: nil, + allowSwitchingMainAmountType: true + )) + .map { _ in }.eraseToAnyPublisher() + } + case .cancel: + return Just(()).eraseToAnyPublisher() + } + }.eraseToAnyPublisher() + case .withdrawCalculator: + return coordinate(to: WithdrawCalculatorCoordinator( + navigationController: navigationController + )) + .flatMap { [unowned self] result in + switch result { + case let .transaction(transaction): + return openPendingTransactionDetails(transaction: transaction) + .eraseToAnyPublisher() + case .canceled: + return Just(()).eraseToAnyPublisher() + } + }.eraseToAnyPublisher() + case let .withdrawInfo(model, params): + return coordinate(to: WithdrawCoordinator( + navigationController: navigationController, + strategy: .confirmation(params), + withdrawalInfo: model + )) + .compactMap { result -> (any StrigaConfirmableTransactionType)? in + switch result { + case let .paymentInitiated(challengeId): + return StrigaClaimTransaction( + challengeId: challengeId, + token: .usdc, + amount: Double(params.amount), + feeAmount: .zero, + fromAddress: "", + receivingAddress: "" + ) + case .canceled, .verified: + return nil + } + } + .flatMap { [unowned self] transaction in + openBankTransferClaimCoordinator(transaction: transaction) + } + .eraseToAnyPublisher() case let .topUpCoin(token): // SOL, USDC if [TokenMetadata.nativeSolana, .usdc].contains(token) { @@ -280,38 +320,31 @@ final class HomeCoordinator: Coordinator { .eraseToAnyPublisher() case .addMoney: - coordinate(to: ActionsCoordinator(viewController: tabBarController)) - .sink(receiveValue: { [weak self] result in + return coordinate(to: TopupCoordinator(viewController: tabBarController)) + .handleEvents(receiveOutput: { [weak self] result in switch result { + case let .action(action): + self?.handleAction(action) case .cancel: break - case let .action(type): - self?.handleAction(type) } }) - .store(in: &subscriptions) - return Just(()) + .map { _ in () } .eraseToAnyPublisher() } } - private func handleAction(_ action: ActionsViewActionType) { + private func handleAction(_ action: TopupActionsViewModel.Action) { guard let navigationController = tabBarController.selectedViewController as? UINavigationController else { return } switch action { - case .bankTransfer: - let buyCoordinator = BuyCoordinator( - navigationController: navigationController, - context: .fromHome, - defaultToken: .nativeSolana, - defaultPaymentType: .bank - ) - coordinate(to: buyCoordinator) - .sink(receiveValue: {}) + case .transfer: + coordinate(to: BankTransferCoordinator(viewController: navigationController)) + .sink { _ in } .store(in: &subscriptions) - case .bankCard: + case .card: let buyCoordinator = BuyCoordinator( navigationController: navigationController, context: .fromHome, @@ -350,4 +383,34 @@ final class HomeCoordinator: Coordinator { .sink(receiveValue: {}) .store(in: &subscriptions) } + + private func openBankTransferClaimCoordinator(transaction: any StrigaConfirmableTransactionType) + -> AnyPublisher { + coordinate(to: BankTransferClaimCoordinator( + navigationController: navigationController, + transaction: transaction + )) + .flatMap { [unowned self] result in + switch result { + case let .completed(transaction): + return openPendingTransactionDetails(transaction: transaction) + .eraseToAnyPublisher() + case .canceled: + return Just(()).eraseToAnyPublisher() + } + }.eraseToAnyPublisher() + } + + private func openPendingTransactionDetails(transaction: PendingTransaction) -> AnyPublisher { + // We need this delay to handle pop animation + Just(()).delay(for: 0.8, scheduler: RunLoop.main) + .flatMap { [unowned self] in + coordinate(to: TransactionDetailCoordinator( + viewModel: TransactionDetailViewModel(pendingTransaction: transaction), + presentingViewController: navigationController + )) + } + .map { _ in () } + .eraseToAnyPublisher() + } } diff --git a/p2p_wallet/Scenes/Main/NewHome/HomeViewModel.swift b/p2p_wallet/Scenes/Main/NewHome/HomeViewModel.swift index 698c08a316..912044f18e 100644 --- a/p2p_wallet/Scenes/Main/NewHome/HomeViewModel.swift +++ b/p2p_wallet/Scenes/Main/NewHome/HomeViewModel.swift @@ -1,4 +1,5 @@ import AnalyticsManager +import BankTransfer import Combine import Foundation import KeyAppBusiness @@ -24,11 +25,13 @@ class HomeViewModel: ObservableObject { @Injected private var nameStorage: NameStorageType @Injected private var createNameService: CreateNameService @Injected private var sellDataService: any SellDataService + @Injected private var bankTransferService: any BankTransferService // MARK: - Published properties @Published var state = State.pending @Published var address = "" + @Published private var shouldUpdateBankTransfer = false // MARK: - Properties @@ -42,7 +45,9 @@ class HomeViewModel: ObservableObject { bind() // reload - Task { await reload() } + Task { + await reload() + } } // MARK: - Methods @@ -80,6 +85,10 @@ class HomeViewModel: ObservableObject { analyticsManager.log( event: .mainScreenOpened(isSellEnabled: sellDataService.isAvailable) ) + + if shouldUpdateBankTransfer { + Task { await bankTransferService.reload() } + } } } @@ -100,9 +109,7 @@ private extension HomeViewModel { // Monitor user action let userActionService: UserActionService = Resolver.resolve() - userActionService - .actions - .withPrevious() + userActionService.actions.withPrevious() .sink { [weak self] prev, next in for updatedUserAction in next { if let oldUserAction = prev?.first(where: { $0.id == updatedUserAction.id }) { @@ -144,6 +151,10 @@ private extension HomeViewModel { .statePublisher .map { $0.status != .initializing } + let bankTransferServicePublisher = bankTransferService.state + .filter { $0.value.wallet?.accounts.usdc != nil } + .map { $0.value.wallet?.accounts.usdc } + // Merge two services. Publishers .CombineLatest(solanaInitialization, ethereumInitialization) @@ -152,11 +163,14 @@ private extension HomeViewModel { .store(in: &subscriptions) // state, address, error, log - Publishers - .CombineLatest(solanaAccountsService.statePublisher, ethereumAccountsService.statePublisher) + .CombineLatest3( + solanaAccountsService.statePublisher, + ethereumAccountsService.statePublisher, + bankTransferServicePublisher.prepend(nil) + ) .receive(on: RunLoop.main) - .sink { [weak self] solanaState, ethereumState in + .sink { [weak self] solanaState, ethereumState, _ in guard let self else { return } let solanaTotalBalance = solanaState.value.reduce(into: 0) { partialResult, account in @@ -190,6 +204,12 @@ private extension HomeViewModel { self?.updateAddressIfNeeded() } .store(in: &subscriptions) + + bankTransferService.state + .receive(on: DispatchQueue.main) + .map { $0.value.userId != nil && $0.value.mobileVerified } + .assignWeak(to: \.shouldUpdateBankTransfer, on: self) + .store(in: &subscriptions) } } diff --git a/p2p_wallet/Scenes/Main/NewHome/Model/HomeBannerParameters.swift b/p2p_wallet/Scenes/Main/NewHome/Model/HomeBannerParameters.swift new file mode 100644 index 0000000000..f4bb162eaa --- /dev/null +++ b/p2p_wallet/Scenes/Main/NewHome/Model/HomeBannerParameters.swift @@ -0,0 +1,119 @@ +import BankTransfer +import KeyAppUI +import SwiftUI + +struct HomeBannerParameters { + struct Button { + let title: String + var isLoading: Bool + let handler: () -> Void + } + + let id: String + let backgroundColor: UIColor + let image: UIImage + let imageSize: CGSize + let title: String + let subtitle: String? + var button: Button? + + init( + id: String, + backgroundColor: UIColor, + image: UIImage, + imageSize: CGSize, + title: String, + subtitle: String?, + button: Button? + ) { + self.id = id + self.title = title + self.subtitle = subtitle + self.button = button + self.image = image + self.imageSize = imageSize + self.backgroundColor = backgroundColor + } + + init( + status: StrigaKYCStatus, + action: @escaping () -> Void, + isLoading: Bool, + isSmallBanner: Bool + ) { + id = status.rawValue + switch status { + case .notStarted, .initiated: + backgroundColor = Asset.Colors.lightSea.color + image = .kycFinish + if isSmallBanner { + imageSize = CGSize(width: 121, height: 91) + } else { + imageSize = CGSize(width: 153, height: 123) + } + if isSmallBanner { + title = L10n.HomeSmallBanner.finishIdentityVerificationToSendMoneyWorldwide + } else { + title = L10n.finishIdentityVerificationToSendMoneyWorldwide + } + subtitle = nil + button = Button(title: L10n.continue, isLoading: isLoading, handler: action) + + case .pendingReview, .onHold: + backgroundColor = Asset.Colors.lightSea.color + image = .kycClock + if isSmallBanner { + imageSize = CGSize(width: 100, height: 100) + } else { + imageSize = CGSize(width: 120, height: 117) + } + title = L10n.HomeBanner.yourDocumentsVerificationIsPending + subtitle = L10n.usuallyItTakesAFewHours + button = nil + + case .approved: + backgroundColor = Asset.Colors.lightGrass.color + image = .kycSend + if isSmallBanner { + imageSize = CGSize(width: 132, height: 117) + } else { + imageSize = CGSize(width: 132, height: 117) + } + title = L10n.verificationIsDone + subtitle = L10n.continueYourTopUpViaABankTransfer + button = Button(title: L10n.topUp, isLoading: isLoading, handler: action) + + case .rejected: + backgroundColor = Asset.Colors.lightSun.color + image = .kycShow + if isSmallBanner { + imageSize = CGSize(width: 115, height: 112) + } else { + imageSize = CGSize(width: 115, height: 112) + } + title = L10n.actionRequired + subtitle = L10n.pleaseCheckTheDetailsAndUpdateYourData + button = Button(title: L10n.checkDetails, isLoading: isLoading, handler: action) + + case .rejectedFinal: + backgroundColor = Asset.Colors.lightRose.color + image = .kycFail + if isSmallBanner { + imageSize = CGSize(width: 126, height: 87) + } else { + imageSize = CGSize(width: 169, height: 111) + } + title = L10n.verificationIsRejected + subtitle = L10n.addMoneyViaBankTransferIsUnavailable + button = Button(title: L10n.seeDetails, isLoading: isLoading, handler: action) + } + } +} + +extension HomeBannerParameters: Equatable { + static func == (lhs: HomeBannerParameters, rhs: HomeBannerParameters) -> Bool { + lhs.title == rhs.title && lhs.button?.title == rhs.button?.title && lhs.backgroundColor == rhs + .backgroundColor && lhs.image == rhs.image && lhs.imageSize == rhs.imageSize && lhs.subtitle == rhs + .subtitle && lhs.button?.isLoading == rhs.button?.isLoading + } +} diff --git a/p2p_wallet/Scenes/Main/NewHome/Model/HomeBannerVisibility.swift b/p2p_wallet/Scenes/Main/NewHome/Model/HomeBannerVisibility.swift new file mode 100644 index 0000000000..daf6edb484 --- /dev/null +++ b/p2p_wallet/Scenes/Main/NewHome/Model/HomeBannerVisibility.swift @@ -0,0 +1,6 @@ +import SwiftyUserDefaults + +struct HomeBannerVisibility: DefaultsSerializable, Codable { + let id: String + let closed: Bool +} diff --git a/p2p_wallet/Scenes/Main/NewHome/Service/HomeAccountsSynchronisationService.swift b/p2p_wallet/Scenes/Main/NewHome/Service/HomeAccountsSynchronisationService.swift index 7642fae6bf..16936093f2 100644 --- a/p2p_wallet/Scenes/Main/NewHome/Service/HomeAccountsSynchronisationService.swift +++ b/p2p_wallet/Scenes/Main/NewHome/Service/HomeAccountsSynchronisationService.swift @@ -1,3 +1,4 @@ +import BankTransfer import Foundation import KeyAppBusiness import Resolver @@ -8,37 +9,22 @@ class HomeAccountsSynchronisationService { @Injected var ethereumAccountsService: EthereumAccountsService @Injected var priceService: PriceService @Injected var userActionService: UserActionService + @Injected var bankTransfer: any BankTransferService func refresh() async { // Update wormhole userActionService.handle(event: WormholeClaimUserActionEvent.refresh) + async let _ = ( + try? await priceService.clear(), + try? await solanaAccountsService.fetch(), + try? await ethereumAccountsService.fetch(), + try? await loadEthereumAccountsService() + ) + await bankTransfer.reload() + } - do { - try await withThrowingTaskGroup(of: Void.self) { group in - // Clear price cache - group.addTask { [weak self] in - try await self?.priceService.clear() - } - - // solana - group.addTask { [weak self] in - guard let self else { return } - try await self.solanaAccountsService.fetch() - } - - // ethereum - if available(.ethAddressEnabled) { - group.addTask { [weak self] in - guard let self else { return } - try await self.ethereumAccountsService.fetch() - } - } - - // another chains goes here - - // await values - for try await _ in group {} - } - } catch {} + func loadEthereumAccountsService() async throws { + guard available(.ethAddressEnabled) else { return } + try await ethereumAccountsService.fetch() } } diff --git a/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Aggregator/HomeAccountsAggregator.swift b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Aggregator/HomeAccountsAggregator.swift index 8a15ff0282..ef74ccba79 100644 --- a/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Aggregator/HomeAccountsAggregator.swift +++ b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Aggregator/HomeAccountsAggregator.swift @@ -7,13 +7,14 @@ struct HomeAccountsAggregator: DataAggregator { func transform( input: ( solanaAccounts: [RenderableSolanaAccount], - ethereumAccounts: [RenderableEthereumAccount] + ethereumAccounts: [RenderableEthereumAccount], + bankTransferAccounts: [any RenderableAccount] ) ) -> (primary: [any RenderableAccount], secondary: [any RenderableAccount]) { - let (solanaAccounts, ethereumAccounts) = input + let (solanaAccounts, ethereumAccounts, bankTransferAccounts) = input - var mergedAccounts: [any RenderableAccount] = ethereumAccounts + solanaAccounts + var mergedAccounts: [any RenderableAccount] = ethereumAccounts + solanaAccounts + bankTransferAccounts // Filter hidden accounts mergedAccounts = mergedAccounts.filter { account in diff --git a/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Aggregator/HomeEthereumAccountsAggregator.swift b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Aggregator/HomeEthereumAccountsAggregator.swift index 7df9bfa599..98d4beea09 100644 --- a/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Aggregator/HomeEthereumAccountsAggregator.swift +++ b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Aggregator/HomeEthereumAccountsAggregator.swift @@ -31,7 +31,7 @@ struct HomeEthereumAccountsAggregator: DataAggregator { // Compare using fiat. if balanceInFiat >= CurrencyAmount(usd: 5) { // Balance is greater than $1, user can claim. - status = .readyToClaim + status = .ready } else { // Balance is to low. status = .balanceToLow @@ -40,7 +40,7 @@ struct HomeEthereumAccountsAggregator: DataAggregator { // Compare using crypto amount. if account.balance > 0 { // Balance is not zero - status = .readyToClaim + status = .ready } else { // Balance is to low. status = .balanceToLow @@ -49,7 +49,7 @@ struct HomeEthereumAccountsAggregator: DataAggregator { } else { // Claiming is running. - status = .isClaiming + status = .isProcessing } return RenderableEthereumAccount( diff --git a/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Aggregator/HomeSolanaAccountsAggregator.swift b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Aggregator/HomeSolanaAccountsAggregator.swift index 0813acb42e..d9411c5157 100644 --- a/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Aggregator/HomeSolanaAccountsAggregator.swift +++ b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Aggregator/HomeSolanaAccountsAggregator.swift @@ -18,7 +18,7 @@ struct HomeSolanaAccountsAggregator: DataAggregator { let (accounts, favourites, ignores, hideZeroBalance) = input return accounts - .filter { !$0.isNFTToken } + .filter { !$0.isNFTToken && $0.token.keyAppExtensions.isPositionOnWS == true } .sorted(by: Self.defaultSorter) .map { account in var tags: AccountTags = [] diff --git a/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/HomeAccountsView.swift b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/HomeAccountsView.swift index 7d2e706856..ff826feadd 100644 --- a/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/HomeAccountsView.swift +++ b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/HomeAccountsView.swift @@ -20,6 +20,7 @@ struct HomeAccountsView: View { .padding(.bottom, 16) .id(0) actionsView + content } } .customRefreshable { @@ -60,4 +61,47 @@ struct HomeAccountsView: View { } ) } + + private var accounts: some View { + VStack(alignment: .leading, spacing: 16) { + ForEach(viewModel.accounts, id: \.id) { acc in + bankTransferCell(rendableAccount: acc, isVisible: true) + .cornerRadius(16) + .padding(.horizontal, 16) + .frame(height: 72) + } + } + } + + private var content: some View { + VStack(alignment: .leading, spacing: 0) { + if let smallBanner = viewModel.smallBanner { + HomeSmallBannerView(params: smallBanner) + .animation(.linear, value: smallBanner) + .padding(.horizontal, 16) + .padding(.top, 16) + .onChange(of: viewModel.shouldCloseBanner) { [weak viewModel] output in + guard output else { return } + withAnimation { viewModel?.closeBanner(id: smallBanner.id) } + } + .onTapGesture(perform: viewModel.bannerTapped.send) + } + if !viewModel.accounts.isEmpty { + accounts + .padding(.top, 48) + } + Spacer() + } + } + + private func bankTransferCell( + rendableAccount: any RenderableAccount, + isVisible _: Bool + ) -> some View { + HomeBankTransferAccountView( + renderable: rendableAccount, + onTap: { viewModel.invoke(for: rendableAccount, event: .tap) }, + onButtonTap: { viewModel.invoke(for: rendableAccount, event: .extraButtonTap) } + ) + } } diff --git a/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/HomeAccountsViewModel.swift b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/HomeAccountsViewModel.swift index 49947cc1ca..de62a77b98 100644 --- a/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/HomeAccountsViewModel.swift +++ b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/HomeAccountsViewModel.swift @@ -1,15 +1,16 @@ import AnalyticsManager +import BankTransfer import BigDecimal import Combine import Foundation import KeyAppBusiness import KeyAppKitCore +import Onboarding import Resolver import Sell import SolanaSwift import SwiftyUserDefaults import Web3 -import Wormhole final class HomeAccountsViewModel: BaseViewModel, ObservableObject { private var defaultsDisposables: [DefaultsDisposable] = [] @@ -21,16 +22,27 @@ final class HomeAccountsViewModel: BaseViewModel, ObservableObject { private let favouriteAccountsStore: FavouriteAccountsDataSource @Injected private var analyticsManager: AnalyticsManager + @Injected private var notificationService: NotificationService + @Injected private var bankTransferService: AnyBankTransferService + @Injected private var metadataService: WalletMetadataService // MARK: - Properties let navigation: PassthroughSubject + let bannerTapped = PassthroughSubject() + private let shouldOpenBankTransfer = PassthroughSubject() + private let shouldShowErrorSubject = CurrentValueSubject(false) @Published private(set) var balance: String = "" @Published private(set) var usdcAmount: String = "" @Published private(set) var actions: [HomeAction] = [] @Published private(set) var scrollOnTheTop = true @Published private(set) var hideZeroBalance: Bool = Defaults.hideZeroBalances + @Published private(set) var smallBanner: HomeBannerParameters? + @Published private(set) var shouldCloseBanner = false + + @SwiftyUserDefault(keyPath: \.homeBannerVisibility, options: .cached) + private var smallBannerVisibility: HomeBannerVisibility? /// Primary list accounts. @Published var accounts: [any RenderableAccount] = [] @@ -42,7 +54,6 @@ final class HomeAccountsViewModel: BaseViewModel, ObservableObject { init( solanaAccountsService: SolanaAccountsService = Resolver.resolve(), - ethereumAccountsService: EthereumAccountsService = Resolver.resolve(), userActionService: UserActionService = Resolver.resolve(), favouriteAccountsStore: FavouriteAccountsDataSource = Resolver.resolve(), sellDataService _: any SellDataService = Resolver.resolve(), @@ -55,25 +66,14 @@ final class HomeAccountsViewModel: BaseViewModel, ObservableObject { actions = [.addMoney] super.init() + bindTransferData() + addWithdrawIfNeeded() // TODO: Replace with combine defaultsDisposables.append(Defaults.observe(\.hideZeroBalances) { [weak self] change in self?.hideZeroBalance = change.newValue ?? false }) - // Ethereum accounts - let ethereumAggregator = HomeEthereumAccountsAggregator() - let ethereumAccountsPublisher = Publishers - .CombineLatest( - ethereumAccountsService.statePublisher, - userActionService.actions.map { userActions in - userActions.compactMap { $0 as? WormholeClaimUserAction } - } - ) - .map { state, actions in - ethereumAggregator.transform(input: (state.value, actions)) - } - // Solana accounts let solanaAggregator = HomeSolanaAccountsAggregator() let solanaAccountsPublisher = Publishers @@ -87,16 +87,33 @@ final class HomeAccountsViewModel: BaseViewModel, ObservableObject { solanaAggregator.transform(input: (state.value, favourites, ignores, hideZeroBalance)) } + // bankTransferPublisher + let bankTransferServicePublisher = Publishers.CombineLatest( + bankTransferService.value.state + .compactMap { $0.value.wallet?.accounts }, + userActionService.actions + ) + .compactMap { account, actions -> [any RenderableAccount] in + BankTransferRenderableAccountFactory.renderableAccount( + accounts: account, + actions: actions + ) + } + let homeAccountsAggregator = HomeAccountsAggregator() Publishers - .CombineLatest(solanaAccountsPublisher, ethereumAccountsPublisher) - .map { solanaAccounts, ethereumAccounts in - homeAccountsAggregator.transform(input: (solanaAccounts, ethereumAccounts)) + .CombineLatest( + solanaAccountsPublisher, + bankTransferServicePublisher.prepend([]) + ) + .map { solanaAccounts, bankTransferAccounts in + homeAccountsAggregator + .transform(input: (solanaAccounts, [], bankTransferAccounts)) } .receive(on: RunLoop.main) - .sink { primary, secondary in - self.accounts = primary - self.hiddenAccounts = secondary + .sink { [weak self] primary, secondary in + self?.accounts = primary + self?.hiddenAccounts = secondary } .store(in: &subscriptions) @@ -121,6 +138,57 @@ final class HomeAccountsViewModel: BaseViewModel, ObservableObject { .assignWeak(to: \.balance, on: self) .store(in: &subscriptions) + userActionService.actions + .compactMap { $0.compactMap { $0 as? BankTransferClaimUserAction } } + .flatMap(\.publisher) + .filter { $0.status != .pending && $0.status != .processing } + .removeDuplicates() + .receive(on: RunLoop.main) + .handleEvents(receiveOutput: { [weak self] val in + switch val.status { + case let .error(concreteType): + self?.handle(error: concreteType) + default: + break + } + }) + .sinkAsync(receiveValue: { [weak self] action in + let priceService = Resolver.resolve(PriceService.self) + let price = try? await priceService.getPrice( + token: SolanaToken.usdc, + fiat: Defaults.fiat.rawValue + ) + guard let result = action.result else { return } + self?.handleClaim(result: result, in: action, tokenPrice: price) + }).store(in: &subscriptions) + + userActionService.actions + .compactMap { $0.compactMap { $0 as? OutgoingBankTransferUserAction } } + .flatMap(\.publisher) + .filter { $0.status != .pending && $0.status != .processing } + .removeDuplicates() + .receive(on: RunLoop.main) + .sinkAsync(receiveValue: { [weak self] action in + let priceService = Resolver.resolve(PriceService.self) + let price = try? await priceService.getPrice( + token: SolanaToken.usdc, + fiat: Defaults.fiat.rawValue + ) + switch action.status { + case .ready: + guard let result = action.result else { return } + self?.handleOutgoingConfirm( + result: result, + in: action, + price: price + ) + case let .error(concreteType): + self?.handle(error: concreteType) + default: + break + } + }).store(in: &subscriptions) + analyticsManager.log(event: .claimAvailable(claim: available(.ethAddressEnabled))) // USDC amount @@ -165,14 +233,11 @@ final class HomeAccountsViewModel: BaseViewModel, ObservableObject { break } - case let renderableAccount as RenderableEthereumAccount: - switch event { - case .extraButtonTap: - navigation - .send(.claim(renderableAccount.account, renderableAccount.userAction)) - default: - break - } + case let renderableAccount as BankTransferRenderableAccount: + handleBankTransfer(account: renderableAccount) + + case let renderableAccount as OutgoingBankTransferRenderableAccount: + handleBankTransfer(account: renderableAccount) default: break @@ -186,6 +251,8 @@ final class HomeAccountsViewModel: BaseViewModel, ObservableObject { navigation.send(.addMoney) case .withdraw: analyticsManager.log(event: .mainScreenWithdrawClick) + navigation.send(.withdrawActions) +// navigation.send(.withdrawCalculator) } } @@ -204,9 +271,103 @@ final class HomeAccountsViewModel: BaseViewModel, ObservableObject { analyticsManager.log(event: .mainScreenAmountClick) } + func closeBanner(id: String) { + smallBannerVisibility = HomeBannerVisibility(id: id, closed: true) + smallBanner = nil + shouldCloseBanner = false + } + func hiddenTokensTapped() { analyticsManager.log(event: .mainScreenHiddenTokens) } + + // Bank transfer + private func handle(error: UserActionError) { + switch error { + case .networkFailure: + notificationService.showConnectionErrorNotification() + default: + notificationService.showDefaultErrorNotification() + } + } + + private func handleBankTransfer(account: OutgoingBankTransferRenderableAccount) { + let userActionService: UserActionService = Resolver.resolve() + guard account.status != .isProcessing else { return } + let userAction = OutgoingBankTransferUserAction( + id: account.id, + accountId: account.accountId, + amount: String(account.rawAmount), + status: .processing + ) + // Execute and emit action. + userActionService.execute(action: userAction) + } + + private func handleBankTransfer(account: BankTransferRenderableAccount) { + let userActionService: UserActionService = Resolver.resolve() + let userWalletManager: UserWalletManager = Resolver.resolve() + guard + account.status != .isProcessing, + let walletPubKey = userWalletManager.wallet?.account.publicKey + else { return } + let userAction = BankTransferClaimUserAction( + id: account.id, + accountId: account.accountId, + token: account.token, + amount: String(account.rawAmount), + receivingAddress: try! PublicKey.associatedTokenAddress( + walletAddress: walletPubKey, + tokenMintAddress: try! PublicKey(string: Token.usdc.address) + ).base58EncodedString, + status: .processing + ) + + // Execute and emit action. + userActionService.execute(action: userAction) + } + + private func handleOutgoingConfirm( + result: OutgoingBankTransferUserActionResult, + in action: OutgoingBankTransferUserAction, + price: TokenPrice? + ) { + switch result { + case let .initiated(challengeId, IBAN, BIC): + navigation.send(.bankTransferConfirm( + StrigaWithdrawTransaction( + challengeId: challengeId, + IBAN: IBAN, + BIC: BIC, + amount: Double(action.amount) ?? 0 / 100, + token: .usdc, + tokenPrice: price, + feeAmount: .zero + ) + )) + case let .requestWithdrawInfo(receiver): + navigation.send(.withdrawInfo( + StrigaWithdrawalInfo(receiver: receiver), + WithdrawConfirmationParameters(accountId: action.accountId, amount: action.amount) + )) + } + } + + private func handleClaim( + result _: BankTransferClaimUserActionResult, + in action: BankTransferClaimUserAction, + tokenPrice: TokenPrice? + ) { + navigation.send(.bankTransferClaim(StrigaClaimTransaction( + challengeId: action.result?.challengeId ?? "", + token: action.result?.token ?? .usdc, + tokenPrice: tokenPrice, + amount: Double(action.amount ?? "") ?? 0, + feeAmount: .zero, + fromAddress: action.result?.fromAddress ?? "", + receivingAddress: action.receivingAddress + ))) + } } extension HomeAccountsViewModel { @@ -216,3 +377,92 @@ extension HomeAccountsViewModel { case extraButtonTap } } + +// MARK: - Private + +private extension HomeAccountsViewModel { + func addWithdrawIfNeeded() { + // If striga is enabled and user is web3 authed + metadataService.metadataPublisher + .filter { [weak self] _ in self?.actions.contains(.withdraw) == false } + .filter { $0.value != nil && available(.bankTransfer) } + .sink { [weak self] _ in + self?.actions.append(.withdraw) + } + .store(in: &subscriptions) + } + + func bindTransferData() { + bankTransferService.value.state + .filter { !$0.isFetching } + .filter { $0.value.userId != nil && $0.value.mobileVerified } + .filter { [weak self] in + // If banner with the same KYC status was already tapped, then we do not show it again + guard let bannerVisibility = self?.smallBannerVisibility else { return true } + return bannerVisibility.id != $0.value.kycStatus.rawValue || !bannerVisibility.closed + } + .map { value in + HomeBannerParameters( + status: value.value.kycStatus, + action: { [weak self] in self?.bannerTapped.send() }, + isLoading: false, + isSmallBanner: true + ) + } + .receive(on: RunLoop.main) + .assignWeak(to: \.smallBanner, on: self) + .store(in: &subscriptions) + + shouldOpenBankTransfer + .withLatestFrom(bankTransferService.value.state) + .receive(on: RunLoop.main) + .sink { [weak self] state in + if state.value.isIBANNotReady { + self?.shouldShowErrorSubject.send(true) + } else { + self?.navigation.send(.bankTransfer) + } + } + .store(in: &subscriptions) + + shouldShowErrorSubject + .filter { $0 } + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.notificationService.showToast(title: "❌", text: L10n.somethingWentWrong) + self?.shouldShowErrorSubject.send(false) + } + .store(in: &subscriptions) + + bannerTapped + .withLatestFrom(bankTransferService.value.state) + .filter { !$0.isFetching } + .receive(on: RunLoop.main) + .sink { [weak self] state in + guard let self else { return } + self.requestCloseBanner(for: state.value) + + if state.value.isIBANNotReady { + self.smallBanner?.button?.isLoading = true + Task { + await self.bankTransferService.value.reload() + self.shouldOpenBankTransfer.send() + } + } else { + self.shouldOpenBankTransfer.send() + } + } + .store(in: &subscriptions) + } + + func requestCloseBanner(for data: UserData) { + switch data.kycStatus { + case .onHold, .pendingReview: + shouldCloseBanner = false + case .approved: + shouldCloseBanner = data.isIBANNotReady == false + default: + shouldCloseBanner = true + } + } +} diff --git a/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Model/BankTransferRenderableAccount.swift b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Model/BankTransferRenderableAccount.swift new file mode 100644 index 0000000000..6e113b6b63 --- /dev/null +++ b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Model/BankTransferRenderableAccount.swift @@ -0,0 +1,199 @@ +import BankTransfer +import BigDecimal +import BigInt +import Foundation +import KeyAppBusiness +import KeyAppKitCore +import Web3 +import Wormhole + +struct BankTransferRenderableAccount: RenderableAccount { + let accountId: String + let token: EthereumToken + let visibleAmount: Int + let rawAmount: Int + var status: RenderableEthereumAccount.Status + private var amount: CryptoAmount { + .init(amount: BigUInt(visibleAmount.toCent()), token: token) + } + + var id: String { + accountId + } + + var icon: AccountIcon { + if let url = token.logo { + return .url(url) + } else { + return .image(.imageOutlineIcon) + } + } + + var wrapped: Bool { + false + } + + var title: String { + token.name + } + + var subtitle: String { + CryptoFormatterFactory.formatter( + with: amount.token, + style: .short + ) + .string(amount: amount) + } + + var detail: AccountDetail { + switch status { + case .ready: + return .button(label: L10n.claim, enabled: true) + case .isProcessing: + return .button(label: L10n.claim, enabled: true) + case .balanceToLow: + return .text("") + } + } + + var extraAction: AccountExtraAction? { + nil + } + + var tags: AccountTags { + var tags: AccountTags = [] + + if status == .balanceToLow { + if amount.amount == 0 { + tags.insert(.hidden) + } else { + tags.insert(.ignore) + } + } + return tags + } + + var isLoading: Bool { + switch status { + case .isProcessing: + return true + default: + return false + } + } +} + +private extension Int { + func toCent() -> Double { + Double(self * 10000) + } +} + +struct OutgoingBankTransferRenderableAccount: RenderableAccount { + let accountId: String + let fiat: Fiat + let rawAmount: Int + var status: RenderableEthereumAccount.Status + private var amount: CurrencyAmount { + CurrencyAmount(value: BigDecimal(floatLiteral: visibleAmount), currencyCode: fiat.code) + } + + var visibleAmount: Double { Double(rawAmount) / 100 } + + var id: String { accountId } + + var icon: AccountIcon { .image(.iconUpload) } + + var wrapped: Bool { false } + + var title: String { L10n.outcomingTransfer } + + var subtitle: String { + CurrencyFormatter(defaultValue: "", hideSymbol: true).string(amount: amount).appending(" \(fiat.code)") + } + + var detail: AccountDetail { + switch status { + case .ready, .isProcessing: + return .button(label: L10n.confirm, enabled: true) + case .balanceToLow: + return .text("") + } + } + + var extraAction: AccountExtraAction? { nil } + + var tags: AccountTags { + var tags: AccountTags = [] + + if status == .balanceToLow { + if visibleAmount == 0 { + tags.insert(.hidden) + } else { + tags.insert(.ignore) + } + } + return tags + } + + var isLoading: Bool { + switch status { + case .isProcessing: + return true + default: + return false + } + } +} + +enum BankTransferRenderableAccountFactory { + static func renderableAccount(accounts: UserAccounts, actions: [any UserAction]) -> [any RenderableAccount] { + var transactions = [any RenderableAccount]() + if + let usdc = accounts.usdc, + usdc.availableBalance > 0, + let address = try? EthereumAddress( + hex: EthereumAddresses.ERC20.usdc.rawValue, + eip55: false + ) + { + let token = EthereumToken( + name: SolanaToken.usdc.name, + symbol: SolanaToken.usdc.symbol, + decimals: 6, + logo: URL(string: SolanaToken.usdc.logoURI ?? ""), + contractType: .erc20(contract: address) + ) + let action = actions + .compactMap { $0 as? BankTransferClaimUserAction } + .first(where: { action in + action.id == usdc.accountID + }) + transactions.append( + BankTransferRenderableAccount( + accountId: usdc.accountID, + token: token, + visibleAmount: usdc.availableBalance, + rawAmount: usdc.totalBalance, + status: action?.status == .processing ? .isProcessing : .ready + ) + ) + } + + if let eur = accounts.eur, let balance = eur.availableBalance, balance > 0 { + let action = actions + .compactMap { $0 as? OutgoingBankTransferUserAction } + .first(where: { $0.accountId == eur.accountID }) + + transactions.append( + OutgoingBankTransferRenderableAccount( + accountId: eur.accountID, + fiat: .eur, + rawAmount: balance, + status: action?.status == .processing ? .isProcessing : .ready + ) + ) + } + return transactions + } +} diff --git a/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Model/RendableAccount+EthereumAccount.swift b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Model/RendableAccount+EthereumAccount.swift index 91a7140f1e..284d72713f 100644 --- a/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Model/RendableAccount+EthereumAccount.swift +++ b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Model/RendableAccount+EthereumAccount.swift @@ -34,7 +34,7 @@ struct RenderableEthereumAccount: RenderableAccount, Equatable { var title: String { switch status { - case .readyToClaim, .isClaiming: + case .ready, .isProcessing: return L10n.incomingTransfer default: return account.token.name @@ -53,9 +53,9 @@ struct RenderableEthereumAccount: RenderableAccount, Equatable { var detail: AccountDetail { switch status { - case .readyToClaim: + case .ready: return .button(label: L10n.claim, enabled: true) - case .isClaiming: + case .isProcessing: return .button(label: L10n.claiming, enabled: false) case .balanceToLow: if let balanceInFiat = account.balanceInFiat { @@ -84,6 +84,8 @@ struct RenderableEthereumAccount: RenderableAccount, Equatable { return tags } + var isLoading: Bool { false } + var sortingKey: BigDecimal? { account.balanceInFiat?.value } @@ -91,8 +93,8 @@ struct RenderableEthereumAccount: RenderableAccount, Equatable { extension RenderableEthereumAccount { enum Status: Equatable { - case readyToClaim - case isClaiming + case ready + case isProcessing case balanceToLow } } diff --git a/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Model/RendableAccount+Mock.swift b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Model/RendableAccount+Mock.swift index c8d6a487d5..b55a3859b9 100644 --- a/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Model/RendableAccount+Mock.swift +++ b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Model/RendableAccount+Mock.swift @@ -18,4 +18,6 @@ struct RendableMockAccount: RenderableAccount { var extraAction: AccountExtraAction? var tags: AccountTags + + var isLoading: Bool } diff --git a/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Model/RendableAccount+SolanaAccount.swift b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Model/RendableAccount+SolanaAccount.swift index a85e49c70a..b8b50dc0ea 100644 --- a/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Model/RendableAccount+SolanaAccount.swift +++ b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Model/RendableAccount+SolanaAccount.swift @@ -50,6 +50,8 @@ struct RenderableSolanaAccount: RenderableAccount { let tags: AccountTags + var isLoading: Bool { false } + var sortingKey: BigDecimal? { account.amountInFiat?.value } diff --git a/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Model/RenderableAccount.swift b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Model/RenderableAccount.swift index 893ad30988..b369fdae94 100644 --- a/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Model/RenderableAccount.swift +++ b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Model/RenderableAccount.swift @@ -17,6 +17,8 @@ protocol RenderableAccount: SortableAccount, Identifiable, Equatable where ID == var extraAction: AccountExtraAction? { get } var tags: AccountTags { get } + + var isLoading: Bool { get } } extension RenderableAccount { diff --git a/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Subview/HomeAccountView.swift b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Subview/HomeAccountView.swift index 44a7556cfd..ee487a9f22 100644 --- a/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Subview/HomeAccountView.swift +++ b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Subview/HomeAccountView.swift @@ -47,23 +47,13 @@ struct HomeAccountView: View { .font(uiFont: .font(of: .text3, weight: .semibold)) .foregroundColor(Color(Asset.Colors.night.color)) case let .button(text, enabled): - Button( - action: { onButtonTap?() }, - label: { - Text(text) - .padding(.horizontal, 12) - .font(uiFont: TextButton.Style.second.font(size: .small)) - .foregroundColor(Color( - enabled ? TextButton.Style.primaryWhite.foreground - : TextButton.Style.primaryWhite.disabledForegroundColor! - )) - .frame(height: TextButton.Size.small.height) - .background(Color( - enabled ? TextButton.Style.primaryWhite.backgroundColor - : TextButton.Style.primaryWhite.disabledBackgroundColor! - )) - .cornerRadius(12) - } + NewTextButton( + title: text, + size: .small, + style: .primaryWhite, + isEnabled: enabled, + isLoading: rendable.isLoading, + action: { onButtonTap?() } ) } } @@ -87,7 +77,8 @@ struct HomeAccountView_Previews: PreviewProvider { subtitle: "0.1747 SOL", detail: .text("$ 3.67"), extraAction: .showHide, - tags: [] + tags: [], + isLoading: false ) ) {} onButtonTap: {} } diff --git a/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Subview/HomeBankTransferAccountView.swift b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Subview/HomeBankTransferAccountView.swift new file mode 100644 index 0000000000..492fc99e7a --- /dev/null +++ b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/Subview/HomeBankTransferAccountView.swift @@ -0,0 +1,32 @@ +import Foundation +import KeyAppUI +import SwiftUI + +struct HomeBankTransferAccountView: View { + let renderable: any RenderableAccount + + let onTap: (() -> Void)? + let onButtonTap: (() -> Void)? + + var body: some View { + FinanceBlockView( + leadingItem: FinanceBlockLeadingItem( + image: .image(.iconUpload), + iconSize: CGSize(width: 50, height: 50), + isWrapped: false + ), + centerItem: FinancialBlockCenterItem( + title: renderable.title, + subtitle: renderable.subtitle + ), + trailingItem: FinancialBlockTrailingItem( + isLoading: renderable.isLoading, + detail: renderable.detail, + onButtonTap: onButtonTap + ) + ) + .onTapGesture { + onTap?() + } + } +} diff --git a/p2p_wallet/Scenes/Main/NewHome/Subview/EarnBannerView.swift b/p2p_wallet/Scenes/Main/NewHome/Subview/Banners/EarnBannerView.swift similarity index 100% rename from p2p_wallet/Scenes/Main/NewHome/Subview/EarnBannerView.swift rename to p2p_wallet/Scenes/Main/NewHome/Subview/Banners/EarnBannerView.swift diff --git a/p2p_wallet/Scenes/Main/NewHome/Subview/Banners/HomeBannerView.swift b/p2p_wallet/Scenes/Main/NewHome/Subview/Banners/HomeBannerView.swift new file mode 100644 index 0000000000..7ff9915cbc --- /dev/null +++ b/p2p_wallet/Scenes/Main/NewHome/Subview/Banners/HomeBannerView.swift @@ -0,0 +1,82 @@ +import BankTransfer +import KeyAppUI +import SwiftUI + +struct HomeBannerView: View { + let params: HomeBannerParameters + + var body: some View { + ZStack(alignment: .top) { + VStack(spacing: 0) { + Color(Asset.Colors.smoke.color) + .frame(height: 87) + + VStack(spacing: 16) { + VStack(spacing: 8) { + Text(params.title) + .foregroundColor(Color(Asset.Colors.night.color)) + .fontWeight(.semibold) + .apply(style: .text1) + .multilineTextAlignment(.center) + if let subtitle = params.subtitle { + Text(subtitle) + .apply(style: .text3) + .minimumScaleFactor(0.5) + .multilineTextAlignment(.center) + .lineLimit(2) + .foregroundColor(Color(Asset.Colors.night.color)) + } + } + + if let button = params.button { + NewTextButton( + title: button.title, + size: .medium, + style: .inverted, + expandable: true, + isLoading: button.isLoading, + action: button.handler + ) + } + } + .padding(.horizontal, 24) + .frame(height: params.button == nil ? 164 : 200) + .frame(maxWidth: .infinity) + .background(Color(params.backgroundColor).cornerRadius(16)) + } + + Image(uiImage: params.image) + .resizable() + .frame(width: params.imageSize.width, height: params.imageSize.height) + .aspectRatio(contentMode: .fit) + } + .frame(maxWidth: .infinity) + } +} + +struct HomeBannerView_Previews: PreviewProvider { + static var previews: some View { + List { + HomeBannerView( + params: HomeBannerParameters( + id: UUID().uuidString, + backgroundColor: .fern, + image: .homeBannerPerson, + imageSize: CGSize(width: 198, height: 142), + title: L10n.topUpYourAccountToGetStarted, + subtitle: L10n.makeYourFirstDepositOrBuyCryptoWithYourCreditCardOrApplePay, + button: HomeBannerParameters.Button(title: L10n.addMoney, isLoading: false, handler: {}) + ) + ) + + ForEach( + [StrigaKYCStatus.notStarted, .initiated, .pendingReview, .onHold, .approved, .rejected, .rejectedFinal], + id: \.rawValue + ) { element in + HomeBannerView(params: HomeBannerParameters(status: element, action: {}, isLoading: false, + isSmallBanner: false)) + } + } + .listStyle(.plain) + } +} diff --git a/p2p_wallet/Scenes/Main/NewHome/Subview/Banners/HomeSmallBannerView.swift b/p2p_wallet/Scenes/Main/NewHome/Subview/Banners/HomeSmallBannerView.swift new file mode 100644 index 0000000000..2cc723d66d --- /dev/null +++ b/p2p_wallet/Scenes/Main/NewHome/Subview/Banners/HomeSmallBannerView.swift @@ -0,0 +1,72 @@ +import BankTransfer +import KeyAppUI +import SwiftUI + +struct HomeSmallBannerView: View { + let params: HomeBannerParameters + + var body: some View { + ZStack(alignment: .topTrailing) { + HStack { + VStack(alignment: .leading, spacing: 0) { + Text(params.title) + .fontWeight(.semibold) + .apply(style: .text2) + .foregroundColor(Color(asset: Asset.Colors.night)) + + if let subtitle = params.subtitle { + Text(subtitle) + .apply(style: .text4) + .padding(.top, 4) + } + + if let button = params.button { + NewTextButton( + title: button.title, + size: .small, + style: .primaryWhite, + isLoading: button.isLoading, + trailing: .arrowForward.withRenderingMode(.alwaysTemplate), + action: { button.handler() } + ) + .padding(.top, 16) + } + } + .layoutPriority(1) + + Spacer() + + Image(uiImage: params.image) + .resizable() + .scaledToFit() + .frame(idealWidth: params.imageSize.width, idealHeight: params.imageSize.height) + .padding(.trailing, 16) + } + .padding(16) + .background(Color(params.backgroundColor)) + .cornerRadius(radius: 24, corners: .allCorners) + } + .frame(height: 141) + } +} + +struct HomeSmallBannerView_Previews: PreviewProvider { + static var previews: some View { + List { + ForEach( + [StrigaKYCStatus.notStarted, .initiated, .pendingReview, .onHold, .approved, .rejected, .rejectedFinal], + id: \.rawValue + ) { element in + HomeSmallBannerView( + params: HomeBannerParameters( + status: element, + action: {}, + isLoading: true, + isSmallBanner: true + ) + ) + } + } + .listStyle(.plain) + } +} diff --git a/p2p_wallet/Scenes/Main/NewHome/Subview/Empty/HomeEmptyView.swift b/p2p_wallet/Scenes/Main/NewHome/Subview/Empty/HomeEmptyView.swift index 1ea5b7942c..a94306a3d7 100644 --- a/p2p_wallet/Scenes/Main/NewHome/Subview/Empty/HomeEmptyView.swift +++ b/p2p_wallet/Scenes/Main/NewHome/Subview/Empty/HomeEmptyView.swift @@ -8,7 +8,11 @@ struct HomeEmptyView: View { var body: some View { ScrollView { VStack(spacing: 36) { - banner + HomeBannerView(params: viewModel.banner) + .animation(.easeInOut, value: viewModel.banner) + .onTapGesture { + viewModel.bannerTapped.send() + } scrollingContent } .padding(.horizontal, 16) @@ -21,53 +25,6 @@ struct HomeEmptyView: View { } } - private var banner: some View { - ZStack(alignment: .bottom) { - ZStack(alignment: .top) { - VStack(spacing: 0) { - Color(Asset.Colors.smoke.color) - .frame(height: 87) - Color(.fern) - .frame(height: 200) - .cornerRadius(16) - } - Image(uiImage: .homeBannerPerson) - } - VStack(spacing: 19) { - VStack(spacing: 13) { - Text(L10n.topUpYourAccountToGetStarted) - .foregroundColor(Color(Asset.Colors.night.color)) - .fontWeight(.bold) - .apply(style: .text1) - Text(L10n.makeYourFirstDepositOrBuyCryptoWithYourCreditCardOrApplePay) - .apply(style: .text3) - .minimumScaleFactor(0.5) - .multilineTextAlignment(.center) - .lineLimit(2) - .foregroundColor(Color(Asset.Colors.night.color)) - .padding(.horizontal, 24) - } - Button( - action: { - viewModel.receiveClicked() - }, - label: { - Text(L10n.receive) - .foregroundColor(Color(Asset.Colors.night.color)) - .font(uiFont: .font(of: .text4, weight: .semibold)) - .frame(height: 48) - .frame(maxWidth: .infinity) - .background(Color(Asset.Colors.snow.color)) - .cornerRadius(8) - .padding(.horizontal, 24) - } - ) - } - .padding(.bottom, 24) - } - .frame(maxWidth: .infinity) - } - private var scrollingContent: some View { VStack(alignment: .leading, spacing: 12) { Text(L10n.currenciesAvailable) diff --git a/p2p_wallet/Scenes/Main/NewHome/Subview/Empty/HomeEmptyViewModel.swift b/p2p_wallet/Scenes/Main/NewHome/Subview/Empty/HomeEmptyViewModel.swift index b9d8f5afb7..11125e9904 100644 --- a/p2p_wallet/Scenes/Main/NewHome/Subview/Empty/HomeEmptyViewModel.swift +++ b/p2p_wallet/Scenes/Main/NewHome/Subview/Empty/HomeEmptyViewModel.swift @@ -1,8 +1,10 @@ import AnalyticsManager +import BankTransfer import Combine import Foundation import KeyAppBusiness import KeyAppKitCore +import KeyAppUI import Resolver import SolanaSwift import UIKit @@ -11,24 +13,46 @@ final class HomeEmptyViewModel: BaseViewModel, ObservableObject { // MARK: - Dependencies @Injected private var analyticsManager: AnalyticsManager + @Injected private var bankTransferService: any BankTransferService + @Injected private var notificationService: NotificationService // MARK: - Properties private let navigation: PassthroughSubject - private var popularCoinsTokens: [TokenMetadata] = [.usdc, .nativeSolana, /* .renBTC, */ .eth, .usdt] + @Published var popularCoins = [PopularCoin]() + @Published var banner: HomeBannerParameters + let bannerTapped = PassthroughSubject() + private let shouldOpenBankTransfer = PassthroughSubject() + private let shouldShowErrorSubject = CurrentValueSubject(false) // MARK: - Initializer init(navigation: PassthroughSubject) { self.navigation = navigation + banner = HomeBannerParameters( + id: UUID().uuidString, + backgroundColor: Asset.Colors.lightGrass.color, + image: .homeBannerPerson, + imageSize: CGSize(width: 198, height: 142), + title: L10n.topUpYourAccountToGetStarted, + subtitle: L10n.makeYourFirstDepositOrBuyCryptoWithYourCreditCardOrApplePay, + button: HomeBannerParameters.Button( + title: L10n.addMoney, + isLoading: false, + handler: { navigation.send(.topUp) } + ) + ) super.init() updateData() + bindBankTransfer() } // MARK: - Actions + // MARK: - Actions + func reloadData() async { // refetch await HomeAccountsSynchronisationService().refresh() @@ -36,13 +60,6 @@ final class HomeEmptyViewModel: BaseViewModel, ObservableObject { updateData() } - func receiveClicked() { - let userWalletManager = Resolver.resolve(UserWalletManager.self) - guard let pubkey = userWalletManager.wallet?.account.publicKey - else { return } - navigation.send(.receive(publicKey: pubkey)) - } - func buyTapped(index: Int) { let coin = popularCoinsTokens[index] analyticsManager.log(event: .mainScreenBuyToken(tokenName: coin.symbol)) @@ -62,6 +79,63 @@ private extension HomeEmptyViewModel { ) } } + + func bindBankTransfer() { + bankTransferService.state + .filter { !$0.isFetching } + .filter { $0.value.userId != nil && $0.value.mobileVerified } + .receive(on: RunLoop.main) + .map { value in + HomeBannerParameters( + status: value.value.kycStatus, + action: { [weak self] in self?.bannerTapped.send() }, + isLoading: false, + isSmallBanner: false + ) + } + .assignWeak(to: \.banner, on: self) + .store(in: &subscriptions) + + shouldOpenBankTransfer + .withLatestFrom(bankTransferService.state) + .receive(on: RunLoop.main) + .sink { [weak self] state in + if state.value.isIBANNotReady { + self?.shouldShowErrorSubject.send(true) + } else { + self?.navigation.send(.bankTransfer) + } + } + .store(in: &subscriptions) + + shouldShowErrorSubject + .filter { $0 } + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.notificationService.showToast(title: "❌", text: L10n.somethingWentWrong) + self?.shouldShowErrorSubject.send(false) + } + .store(in: &subscriptions) + + bannerTapped + .withLatestFrom(bankTransferService.state) + .filter { !$0.isFetching } + .receive(on: RunLoop.main) + .sink { [weak self] state in + guard let self else { return } + + if state.value.isIBANNotReady { + self.banner.button?.isLoading = true + Task { + await self.bankTransferService.reload() + self.shouldOpenBankTransfer.send() + } + } else { + self.shouldOpenBankTransfer.send() + } + } + .store(in: &subscriptions) + } } // MARK: - Model diff --git a/p2p_wallet/Scenes/Main/NewSettings/Settings/View/SettingsView.swift b/p2p_wallet/Scenes/Main/NewSettings/Settings/View/SettingsView.swift index 30fa925f10..ce3beca994 100644 --- a/p2p_wallet/Scenes/Main/NewSettings/Settings/View/SettingsView.swift +++ b/p2p_wallet/Scenes/Main/NewSettings/Settings/View/SettingsView.swift @@ -50,6 +50,26 @@ struct SettingsView: View { } ) } + Button( + action: { viewModel.showView(.country) }, + label: { + cellView(image: .settingsCountry, title: L10n.country.uppercaseFirst) { + HStack(spacing: 14) { + if let country = viewModel.region, let name = country?.name { + Text(name) + .foregroundColor(Color(Asset.Colors.mountain.color)) + .apply(style: .label1) + } else { + Text(L10n.select) + .foregroundColor(Color(Asset.Colors.mountain.color)) + .apply(style: .label1) + } + Image(uiImage: .cellArrow) + .foregroundColor(Color(Asset.Colors.mountain.color)) + } + } + } + ) Button( action: { viewModel.showView(.support) }, label: { cellView(image: .settingsSupport, title: L10n.support.uppercaseFirst) } diff --git a/p2p_wallet/Scenes/Main/NewSettings/Settings/ViewModel/SettingsViewModel.swift b/p2p_wallet/Scenes/Main/NewSettings/Settings/ViewModel/SettingsViewModel.swift index 554b48cd89..4d13fcff40 100644 --- a/p2p_wallet/Scenes/Main/NewSettings/Settings/ViewModel/SettingsViewModel.swift +++ b/p2p_wallet/Scenes/Main/NewSettings/Settings/ViewModel/SettingsViewModel.swift @@ -1,10 +1,12 @@ import AnalyticsManager import Combine +import CountriesAPI import Foundation import LocalAuthentication import Onboarding import Resolver import SolanaSwift +import SwiftyUserDefaults import UIKit final class SettingsViewModel: BaseViewModel, ObservableObject { @@ -15,6 +17,7 @@ final class SettingsViewModel: BaseViewModel, ObservableObject { @Injected private var authenticationHandler: AuthenticationHandlerType @Injected private var metadataService: WalletMetadataService @Injected private var createNameService: CreateNameService + @Injected private var logoutService: LogoutService @Injected private var deviceShareMigrationService: DeviceShareMigrationService @Published var zeroBalancesIsHidden = Defaults.hideZeroBalances { @@ -30,6 +33,16 @@ final class SettingsViewModel: BaseViewModel, ObservableObject { } } + @Published var region = Optional(Defaults.region) { + didSet { + if let region { + Defaults.region = region + } else { + Defaults.region = nil + } + } + } + private var isBiometryCheckGoing: Bool = false @Published var biometryType: BiometryType = .none @@ -56,6 +69,7 @@ final class SettingsViewModel: BaseViewModel, ObservableObject { override init() { super.init() + setUpAuthType() updateNameIfNeeded() bind() @@ -132,6 +146,8 @@ final class SettingsViewModel: BaseViewModel, ObservableObject { guard let userAddress = solanaStorage.account?.publicKey.base58EncodedString else { return } openActionSubject.send(.reserveUsername(userAddress: userAddress)) } + case .country: + openActionSubject.send(.country) default: openActionSubject.send(type) } @@ -143,7 +159,7 @@ final class SettingsViewModel: BaseViewModel, ObservableObject { func signOut() { analyticsManager.log(event: .signedOut) - Task { try await userWalletManager.remove() } + Task { await logoutService.logout() } } private func toggleZeroBalancesVisibility() { @@ -158,6 +174,9 @@ final class SettingsViewModel: BaseViewModel, ObservableObject { } else { isNameEnabled = true } + if region != Defaults.region { + region = Defaults.region + } } private func bind() { @@ -195,6 +214,7 @@ final class SettingsViewModel: BaseViewModel, ObservableObject { extension SettingsViewModel { enum OpenAction { case username + case country case support case reserveUsername(userAddress: String) case recoveryKit diff --git a/p2p_wallet/Scenes/Main/NewSettings/SettingsCoordinator.swift b/p2p_wallet/Scenes/Main/NewSettings/SettingsCoordinator.swift index 34e51f2004..22dc1fabef 100644 --- a/p2p_wallet/Scenes/Main/NewSettings/SettingsCoordinator.swift +++ b/p2p_wallet/Scenes/Main/NewSettings/SettingsCoordinator.swift @@ -1,4 +1,5 @@ import Combine +import CountriesAPI import Resolver import UIKit @@ -49,14 +50,31 @@ final class SettingsCoordinator: Coordinator { coordinate(to: coordinator) .sink(receiveValue: {}) .store(in: &subscriptions) + case .country: + coordinate(to: ChooseItemCoordinator( + title: L10n.selectYourCountry, + controller: settingsVC, + service: SelectRegionService(), + chosen: Defaults.region, + showDoneButton: true + )) + .sink { result in + switch result { + case let .item(item): + if let region = item as? Region { + viewModel.region = region + } else { + assert(true) + } + case .cancel: break + } + }.store(in: &subscriptions) } }) .store(in: &subscriptions) - let closeSubject = PassthroughSubject() - settingsVC.onClose = { - closeSubject.send() - } - return closeSubject.prefix(1).eraseToAnyPublisher() + return settingsVC.deallocatedPublisher() + .prefix(1) + .eraseToAnyPublisher() } } diff --git a/p2p_wallet/Scenes/Main/NewSettings/Subscenes/PincodeChange/ForgetPinView.swift b/p2p_wallet/Scenes/Main/NewSettings/Subscenes/PincodeChange/ForgetPinView.swift index 24059a94b4..499ce5ff19 100644 --- a/p2p_wallet/Scenes/Main/NewSettings/Subscenes/PincodeChange/ForgetPinView.swift +++ b/p2p_wallet/Scenes/Main/NewSettings/Subscenes/PincodeChange/ForgetPinView.swift @@ -4,7 +4,7 @@ import SwiftUI struct ForgetPinView: View { @State var isLoading: Bool = false - @Injected var userWalletManager: UserWalletManager + @Injected var logoutService: LogoutService private let text: String var close: (() -> Void)? @@ -56,7 +56,7 @@ struct ForgetPinView: View { Task { isLoading = true defer { isLoading = false } - do { try await userWalletManager.remove() } + do { await logoutService.logout() } } } } diff --git a/p2p_wallet/Scenes/Main/Receive/Model/ReceiveRendableItem.swift b/p2p_wallet/Scenes/Main/Receive/Model/ReceiveRendableItem.swift index c4046efc92..de73675c10 100644 --- a/p2p_wallet/Scenes/Main/Receive/Model/ReceiveRendableItem.swift +++ b/p2p_wallet/Scenes/Main/Receive/Model/ReceiveRendableItem.swift @@ -1,3 +1,3 @@ import Foundation -protocol ReceiveRendableItem: Rendable {} +protocol ReceiveRendableItem: Renderable {} diff --git a/p2p_wallet/Scenes/Main/Receive/Model/Rendable.swift b/p2p_wallet/Scenes/Main/Receive/Model/Renderable.swift similarity index 68% rename from p2p_wallet/Scenes/Main/Receive/Model/Rendable.swift rename to p2p_wallet/Scenes/Main/Receive/Model/Renderable.swift index 89ba094141..178ad729eb 100644 --- a/p2p_wallet/Scenes/Main/Receive/Model/Rendable.swift +++ b/p2p_wallet/Scenes/Main/Receive/Model/Renderable.swift @@ -2,7 +2,7 @@ import KeyAppUI import SwiftUI /// In case of successful experiment make a base Renderable protocol -protocol Rendable: Identifiable where ID == String { +protocol Renderable: Identifiable where ID == String { associatedtype ViewType: View var id: String { get } @@ -11,12 +11,12 @@ protocol Rendable: Identifiable where ID == String { } /// Opaque type for cell views -struct AnyRendable: View, Identifiable { - var item: any Rendable +struct AnyRenderable: View, Identifiable { + var item: any Renderable var id: String { item.id } - init(item: any Rendable) { + init(item: any Renderable) { self.item = item } diff --git a/p2p_wallet/Scenes/Main/Receive/Model/SpacerReceiveItem.swift b/p2p_wallet/Scenes/Main/Receive/Model/SpacerReceiveItem.swift index 9d87621b95..c3343b0ef6 100644 --- a/p2p_wallet/Scenes/Main/Receive/Model/SpacerReceiveItem.swift +++ b/p2p_wallet/Scenes/Main/Receive/Model/SpacerReceiveItem.swift @@ -4,11 +4,12 @@ import SwiftUI struct SpacerReceiveItem { var id: String = UUID().uuidString var height: CGFloat = 8 + var width: CGFloat = 8 } extension SpacerReceiveItem: ReceiveRendableItem { func render() -> some View { Color(UIColor.clear) - .frame(height: height) + .frame(width: width, height: height) } } diff --git a/p2p_wallet/Scenes/Main/Receive/ReceiveView.swift b/p2p_wallet/Scenes/Main/Receive/ReceiveView.swift index 6b82a608dc..0a25dff0d6 100644 --- a/p2p_wallet/Scenes/Main/Receive/ReceiveView.swift +++ b/p2p_wallet/Scenes/Main/Receive/ReceiveView.swift @@ -77,7 +77,7 @@ struct ReceiveView: View { var list: some View { VStack(spacing: 0) { ForEach(viewModel.items, id: \.id) { item in - AnyRendable(item: item) + AnyRenderable(item: item) .onTapGesture { viewModel.itemTapped(item) } diff --git a/p2p_wallet/Scenes/Main/Receive/ReceiveViewModel.swift b/p2p_wallet/Scenes/Main/Receive/ReceiveViewModel.swift index 9352a730f3..f188627aab 100644 --- a/p2p_wallet/Scenes/Main/Receive/ReceiveViewModel.swift +++ b/p2p_wallet/Scenes/Main/Receive/ReceiveViewModel.swift @@ -159,7 +159,7 @@ class ReceiveViewModel: BaseViewModel, ObservableObject { // MARK: - - func itemTapped(_ item: any Rendable) { + func itemTapped(_ item: any Renderable) { if let row = item as? ListReceiveItem { clipboardManager.copyToClipboard(row.description) var message = "" diff --git a/p2p_wallet/Scenes/Main/Receive/View/RefundBannerReceiveView.swift b/p2p_wallet/Scenes/Main/Receive/View/RefundBannerReceiveView.swift index 93dfb3e4eb..38235e54c6 100644 --- a/p2p_wallet/Scenes/Main/Receive/View/RefundBannerReceiveView.swift +++ b/p2p_wallet/Scenes/Main/Receive/View/RefundBannerReceiveView.swift @@ -16,7 +16,7 @@ struct RefundBannerReceiveView: View { } .padding(.horizontal, 20) .padding(.vertical, 4) - .background(Color(UIColor.cdf6cd)) + .background(Color(Asset.Colors.lightGrass.color)) .cornerRadius(radius: 16, corners: .allCorners) } } diff --git a/p2p_wallet/Scenes/Main/ReceiveFundsViaLink/ClaimSentViaLinkTransaction.swift b/p2p_wallet/Scenes/Main/ReceiveFundsViaLink/ClaimSentViaLinkTransaction.swift index 48f6247623..f8891a7754 100644 --- a/p2p_wallet/Scenes/Main/ReceiveFundsViaLink/ClaimSentViaLinkTransaction.swift +++ b/p2p_wallet/Scenes/Main/ReceiveFundsViaLink/ClaimSentViaLinkTransaction.swift @@ -129,7 +129,7 @@ struct ClaimSentViaLinkTransaction: RawTransactionType { appVersion: data.appVersion, timestamp: data.timestamp, simulationError: nil, - feeRelayerError: data.feeRelayerError, + feeRelayerError: data.feeRelayerError ?? data.otherError, blockchainError: data.blockchainError ) ) diff --git a/p2p_wallet/Scenes/Main/ReceiveFundsViaLink/ViewModel/ReceiveFundsViaLinkViewModel.swift b/p2p_wallet/Scenes/Main/ReceiveFundsViaLink/ViewModel/ReceiveFundsViaLinkViewModel.swift index 3c53b7cc26..3aa00aaac2 100644 --- a/p2p_wallet/Scenes/Main/ReceiveFundsViaLink/ViewModel/ReceiveFundsViaLinkViewModel.swift +++ b/p2p_wallet/Scenes/Main/ReceiveFundsViaLink/ViewModel/ReceiveFundsViaLinkViewModel.swift @@ -103,7 +103,7 @@ final class ReceiveFundsViaLinkViewModel: BaseViewModel, ObservableObject { // Send it to transactionHandler let transactionHandler = Resolver.resolve(TransactionHandlerType.self) - let transactionIndex = transactionHandler.sendTransaction(transaction) + let transactionIndex = transactionHandler.sendTransaction(transaction, status: .sending) // Observe transaction and update status transactionHandler.observeTransaction(transactionIndex: transactionIndex) diff --git a/p2p_wallet/Scenes/Main/SelectRegion/SelectRegionCoordinator.swift b/p2p_wallet/Scenes/Main/SelectRegion/SelectRegionCoordinator.swift new file mode 100644 index 0000000000..6df2ea4506 --- /dev/null +++ b/p2p_wallet/Scenes/Main/SelectRegion/SelectRegionCoordinator.swift @@ -0,0 +1,60 @@ +import Combine +import CountriesAPI +import Foundation +import Resolver +import SwiftUI + +final class SelectRegionCoordinator: Coordinator { + // MARK: - + + private var navigationController: UINavigationController + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + + override func start() -> AnyPublisher { + let viewModel = SelectRegionViewModel() + let controller = UIHostingController( + rootView: SelectRegionView(viewModel: viewModel) + ) + + viewModel.showCountries.flatMap { [unowned self, unowned controller] val in + coordinate(to: ChooseItemCoordinator( + title: L10n.selectYourCountry, + controller: controller, + service: SelectRegionService(), + chosen: val, + showDoneButton: true + )) + }.sink { [weak viewModel] result in + switch result { + case let .item(item): + viewModel?.setRegion(item as! Region) + case .cancel: break + } + }.store(in: &subscriptions) + + controller.hidesBottomBarWhenPushed = true + navigationController.pushViewController(controller, animated: true) + + return Publishers.Merge( + controller.deallocatedPublisher().map { SelectRegionCoordinator.Result.cancelled }, + viewModel.countrySubmitted.map { country in + if let country { + return SelectRegionCoordinator.Result.selected(country) + } + return SelectRegionCoordinator.Result.cancelled + } + ) + .prefix(1) + .eraseToAnyPublisher() + } +} + +extension SelectRegionCoordinator { + enum Result { + case cancelled + case selected(Region) + } +} diff --git a/p2p_wallet/Scenes/Main/SelectRegion/SelectRegionService.swift b/p2p_wallet/Scenes/Main/SelectRegion/SelectRegionService.swift new file mode 100644 index 0000000000..3222701ad8 --- /dev/null +++ b/p2p_wallet/Scenes/Main/SelectRegion/SelectRegionService.swift @@ -0,0 +1,47 @@ +import Combine +import CountriesAPI +import Foundation +import KeyAppKitCore +import Resolver + +final class SelectRegionService: ChooseItemService { + let chosenTitle = L10n.chosenCountry + let otherTitle = L10n.allCountries + let emptyTitle = L10n.nothingWasFound + + var state: AnyPublisher, Never> { + statePublisher.eraseToAnyPublisher() + } + + @Injected private var countriesService: CountriesAPI + private let statePublisher: CurrentValueSubject, Never> + + init() { + statePublisher = CurrentValueSubject, Never>( + AsyncValueState(status: .fetching, value: []) + ) + + Task { + do { + let countries = try await self.countriesService.fetchRegions() + self.statePublisher.send( + AsyncValueState(status: .ready, value: [ChooseItemListSection(items: countries)]) + ) + } catch { + DefaultLogManager.shared.log(event: "Error", logLevel: .error, data: error.localizedDescription) + self.statePublisher.send( + AsyncValueState(status: .ready, value: [ChooseItemListSection(items: [])], error: error) + ) + } + } + } + + func sort(items: [ChooseItemListSection]) -> [ChooseItemListSection] { + let isEmpty = items.flatMap(\.items).isEmpty + return isEmpty ? [] : items + } + + func sortFiltered(by _: String, items: [ChooseItemListSection]) -> [ChooseItemListSection] { + sort(items: items) + } +} diff --git a/p2p_wallet/Scenes/Main/SelectRegion/SelectRegionView.swift b/p2p_wallet/Scenes/Main/SelectRegion/SelectRegionView.swift new file mode 100644 index 0000000000..f6f7f4ffb4 --- /dev/null +++ b/p2p_wallet/Scenes/Main/SelectRegion/SelectRegionView.swift @@ -0,0 +1,39 @@ +import KeyAppUI +import SwiftUI + +struct SelectRegionView: View { + @ObservedObject var viewModel: SelectRegionViewModel + + var body: some View { + VStack(spacing: 8) { + RoundedRectangle(cornerRadius: 2, style: .circular) + .fill(Color(Asset.Colors.rain.color)) + .frame(width: 31, height: 4) + .padding(.top, 6) + Spacer() + list + .padding(.horizontal, 20) + .padding(.top, 20) + } + .background(Color(Asset.Colors.smoke.color)) + .cornerRadius(20) + .edgesIgnoringSafeArea(.all) + } + + var list: some View { + VStack(spacing: 0) { + ForEach(viewModel.items, id: \.id) { item in + AnyRenderable(item: item) + .onTapGesture { + viewModel.itemTapped(item: item) + } + } + } + } +} + +struct SelectRegionView_Previews: PreviewProvider { + static var previews: some View { + SelectRegionView(viewModel: .init()) + } +} diff --git a/p2p_wallet/Scenes/Main/SelectRegion/SelectRegionViewModel.swift b/p2p_wallet/Scenes/Main/SelectRegion/SelectRegionViewModel.swift new file mode 100644 index 0000000000..1f2822b65c --- /dev/null +++ b/p2p_wallet/Scenes/Main/SelectRegion/SelectRegionViewModel.swift @@ -0,0 +1,123 @@ +import BankTransfer +import Combine +import CountriesAPI +import KeyAppUI +import Resolver +import SwiftyUserDefaults + +final class SelectRegionViewModel: BaseViewModel, ObservableObject { + // MARK: - Navigation + + var showCountries: AnyPublisher { + showCountriesSubject.eraseToAnyPublisher() + } + + var countrySubmitted: AnyPublisher { + submitCountrySubject.eraseToAnyPublisher() + } + + // MARK: - Dependencies + + @Injected private var countriesService: CountriesAPI + + // MARK: - + + @Published var items: [any Renderable] = [] + @Published var isLoading = false { + didSet { + items = makeItems() + } + } + + // MARK: - + + private let showCountriesSubject = PassthroughSubject() + private let submitCountrySubject = PassthroughSubject() + + private var currentRegion: Region? { + didSet { + items = makeItems() + } + } + + override init() { + super.init() + + bind() + } + + func setRegion(_ country: Region) { + currentRegion = country + Defaults.region = country + } + + func bind() { + if Defaults.region != nil { + currentRegion = Defaults.region + } else { + Task { + defer { + self.isLoading = false + } + + self.isLoading = true + do { + self.currentRegion = try await countriesService.currentCountryName() + } catch { + DefaultLogManager.shared.log(event: "Error", logLevel: .error, data: error.localizedDescription) + } + } + } + } + + private func makeItems() -> [any Renderable] { + let countryCell = BankTransferCountryCellViewItem( + name: currentRegion?.name ?? "", + flag: currentRegion?.flagEmoji ?? "🏴", + isLoading: isLoading + ) + return [ + BankTransferInfoImageCellViewItem(image: .bankTransferInfoUnavailableIcon), + ListSpacerCellViewItem(height: 27, backgroundColor: .clear), + BankTransferTitleCellViewItem(title: L10n.selectYourCountryOfResidence), + ListSpacerCellViewItem(height: 27, backgroundColor: .clear), + CenterTextCellViewItem( + text: L10n.weSuggestPaymentOptionsBasedOnYourChoice, + style: .text3, + color: Asset.Colors.night.color + ), + ListSpacerCellViewItem(height: 26, backgroundColor: .clear), + countryCell, + ListSpacerCellViewItem(height: 92 + 21, backgroundColor: .clear), + ButtonListCellItem( + leadingImage: nil, + title: L10n.next, + action: { [weak self] in + self?.submitCountry() + }, + style: .primaryWhite, + trailingImage: Asset.MaterialIcon.arrowForward.image + ), + ListSpacerCellViewItem(height: 53, backgroundColor: .clear), + ] + } + + // MARK: - + + func itemTapped(item: any Identifiable) { + if item as? BankTransferCountryCellViewItem != nil { + openCountries() + } + } + + // MARK: - actions + + private func openCountries() { + showCountriesSubject.send(currentRegion) + } + + private func submitCountry() { + Defaults.region = currentRegion + submitCountrySubject.send(currentRegion) + } +} diff --git a/p2p_wallet/Scenes/Main/Sell/TransactionDetails/SellSuccessTransactionDetailsView.swift b/p2p_wallet/Scenes/Main/Sell/TransactionDetails/SellSuccessTransactionDetailsView.swift index 4be5eb185f..7669854b86 100644 --- a/p2p_wallet/Scenes/Main/Sell/TransactionDetails/SellSuccessTransactionDetailsView.swift +++ b/p2p_wallet/Scenes/Main/Sell/TransactionDetails/SellSuccessTransactionDetailsView.swift @@ -62,7 +62,7 @@ struct SellSuccessTransactionDetailsView: View { private var infoBlockView: some View { ZStack { - Color(.cdf6cd.withAlphaComponent(0.3)) + Color(Asset.Colors.lightGrass.color.withAlphaComponent(0.3)) .cornerRadius(12) HStack(spacing: 12) { Image(uiImage: .successSellTransaction) diff --git a/p2p_wallet/Scenes/Main/Sell/info/MoonpayInfoView.swift b/p2p_wallet/Scenes/Main/Sell/info/MoonpayInfoView.swift index 15f7f54404..88ae302bc3 100644 --- a/p2p_wallet/Scenes/Main/Sell/info/MoonpayInfoView.swift +++ b/p2p_wallet/Scenes/Main/Sell/info/MoonpayInfoView.swift @@ -103,21 +103,6 @@ struct MoonpayInfoView: View { } } -struct CheckboxView: View { - @Binding var isChecked: Bool - var body: some View { - Button { - isChecked.toggle() - } label: { - if isChecked { - Image(uiImage: .checkboxFill) - } else { - Image(uiImage: .checkboxEmpty) - } - } - } -} - struct MoonpayInfoView_Previews: PreviewProvider { static var previews: some View { MoonpayInfoView() diff --git a/p2p_wallet/Scenes/Main/Send/ChooseSendItem/ChooseSendItemCoordinator.swift b/p2p_wallet/Scenes/Main/Send/ChooseSendItem/ChooseSendItemCoordinator.swift index fa6ac00f1a..7350a2b5c2 100644 --- a/p2p_wallet/Scenes/Main/Send/ChooseSendItem/ChooseSendItemCoordinator.swift +++ b/p2p_wallet/Scenes/Main/Send/ChooseSendItem/ChooseSendItemCoordinator.swift @@ -26,7 +26,8 @@ final class ChooseSendItemCoordinator: Coordinator { override func start() -> AnyPublisher { let viewModel = ChooseItemViewModel( service: buildService(strategy: strategy), - chosenToken: chosenWallet + chosenItem: chosenWallet, + isSearchEnabled: true ) let view = ChooseItemView(viewModel: viewModel) { model in TokenCellView(item: .init(wallet: model.item as! SolanaAccount), appearance: .other) diff --git a/p2p_wallet/Scenes/Main/Send/ChooseSendItem/Services/ChooseSendFeeTokenService.swift b/p2p_wallet/Scenes/Main/Send/ChooseSendItem/Services/ChooseSendFeeTokenService.swift index f166d03fb4..a1ac7ae7d3 100644 --- a/p2p_wallet/Scenes/Main/Send/ChooseSendItem/Services/ChooseSendFeeTokenService.swift +++ b/p2p_wallet/Scenes/Main/Send/ChooseSendItem/Services/ChooseSendFeeTokenService.swift @@ -3,7 +3,9 @@ import KeyAppKitCore import SolanaSwift final class ChooseSendFeeTokenService: ChooseItemService { - let otherTokensTitle = L10n.otherTokens + let chosenTitle = L10n.chosenToken + let emptyTitle = L10n.TokenNotFound.tryAnotherOne + let otherTitle = L10n.otherTokens var state: AnyPublisher, Never> { statePublisher.eraseToAnyPublisher() diff --git a/p2p_wallet/Scenes/Main/Send/ChooseSendItem/Services/ChooseSendTokenService.swift b/p2p_wallet/Scenes/Main/Send/ChooseSendItem/Services/ChooseSendTokenService.swift index 74ca30d979..5136cc9cd4 100644 --- a/p2p_wallet/Scenes/Main/Send/ChooseSendItem/Services/ChooseSendTokenService.swift +++ b/p2p_wallet/Scenes/Main/Send/ChooseSendItem/Services/ChooseSendTokenService.swift @@ -5,7 +5,9 @@ import Resolver import SolanaSwift final class ChooseSendTokenService: ChooseItemService { - let otherTokensTitle = L10n.otherTokens + let chosenTitle = L10n.chosenToken + let otherTitle = L10n.otherTokens + let emptyTitle = L10n.TokenNotFound.tryAnotherOne var state: AnyPublisher, Never> { statePublisher.eraseToAnyPublisher() diff --git a/p2p_wallet/Scenes/Main/Send/Models/SendTransaction.swift b/p2p_wallet/Scenes/Main/Send/Models/SendTransaction.swift index 639c04d028..bb7f3102a5 100644 --- a/p2p_wallet/Scenes/Main/Send/Models/SendTransaction.swift +++ b/p2p_wallet/Scenes/Main/Send/Models/SendTransaction.swift @@ -111,7 +111,7 @@ struct SendTransaction: RawTransactionType { userPubkey: data.userPubkey, platform: data.platform, blockchainError: data.blockchainError, - feeRelayerError: data.feeRelayerError, + feeRelayerError: data.feeRelayerError ?? data.otherError, appVersion: data.appVersion, timestamp: data.timestamp ) @@ -122,7 +122,7 @@ struct SendTransaction: RawTransactionType { userPubkey: data.userPubkey, platform: data.platform, blockchainError: data.blockchainError, - feeRelayerError: data.feeRelayerError, + feeRelayerError: data.feeRelayerError ?? data.feeRelayerError, appVersion: data.appVersion, timestamp: data.timestamp ) diff --git a/p2p_wallet/Scenes/Main/Send/SendViaLink/SendCreateLinkCoordinator.swift b/p2p_wallet/Scenes/Main/Send/SendViaLink/SendCreateLinkCoordinator.swift index 052d9ba89e..0888e3bbbe 100644 --- a/p2p_wallet/Scenes/Main/Send/SendViaLink/SendCreateLinkCoordinator.swift +++ b/p2p_wallet/Scenes/Main/Send/SendViaLink/SendCreateLinkCoordinator.swift @@ -42,8 +42,8 @@ final class SendCreateLinkCoordinator: Coordinator { override func start() -> AnyPublisher { let viewModel = ChooseItemViewModel( service: ChooseSwapTokenService(swapTokens: tokens, fromToken: fromToken), - chosenToken: chosenWallet + chosenItem: chosenWallet, + isSearchEnabled: true ) let fromToken = fromToken let view = ChooseItemView(viewModel: viewModel) { model in diff --git a/p2p_wallet/Scenes/Main/Swap/ChooseSwapItem/Service/ChooseSwapTokenService.swift b/p2p_wallet/Scenes/Main/Swap/ChooseSwapItem/Service/ChooseSwapTokenService.swift index 7cd2519b16..fb8618a6a3 100644 --- a/p2p_wallet/Scenes/Main/Swap/ChooseSwapItem/Service/ChooseSwapTokenService.swift +++ b/p2p_wallet/Scenes/Main/Swap/ChooseSwapItem/Service/ChooseSwapTokenService.swift @@ -4,7 +4,10 @@ import KeyAppKitCore import Resolver final class ChooseSwapTokenService: ChooseItemService { - let otherTokensTitle = L10n.allTokens + let chosenTitle = L10n.chosenToken + let otherTitle = L10n.allTokens + let emptyTitle = L10n.TokenNotFound.tryAnotherOne + var state: AnyPublisher, Never> { statePublisher.eraseToAnyPublisher() } diff --git a/p2p_wallet/Scenes/Main/Swap/Common/Models/JupiterSwapTransaction.swift b/p2p_wallet/Scenes/Main/Swap/Common/Models/JupiterSwapTransaction.swift index 40c2d9e090..2887ca6121 100644 --- a/p2p_wallet/Scenes/Main/Swap/Common/Models/JupiterSwapTransaction.swift +++ b/p2p_wallet/Scenes/Main/Swap/Common/Models/JupiterSwapTransaction.swift @@ -87,7 +87,7 @@ struct JupiterSwapTransaction: SwapRawTransactionType { platform: data.platform, appVersion: data.appVersion, timestamp: data.timestamp, - blockchainError: data.blockchainError ?? data.feeRelayerError ?? "", + blockchainError: data.blockchainError ?? data.feeRelayerError ?? data.otherError ?? "", diffRoutesTime: diffRoutesTime, diffTxTime: diffTxTime ) diff --git a/p2p_wallet/Scenes/Main/Swap/Swap/Subviews/Input/SwapInputView.swift b/p2p_wallet/Scenes/Main/Swap/Swap/Subviews/Input/SwapInputView.swift index fbc027bfa9..7e2c6557e7 100644 --- a/p2p_wallet/Scenes/Main/Swap/Swap/Subviews/Input/SwapInputView.swift +++ b/p2p_wallet/Scenes/Main/Swap/Swap/Subviews/Input/SwapInputView.swift @@ -7,126 +7,24 @@ struct SwapInputView: View { @ObservedObject var viewModel: SwapInputViewModel var body: some View { - VStack(spacing: 16) { - HStack { - Text(viewModel.title) - .subtitleStyle() - .accessibilityIdentifier("SwapInputView.\(viewModel.accessibilityIdentifierTokenPrefix)TitleLabel") - - Spacer() - - if viewModel.isEditable && viewModel.balance != nil && !viewModel.isLoading { - allButton - } - } - HStack { - changeTokenButton - .layoutPriority(1) - amountField - } - HStack { - balanceLabel - - Spacer() - - if let fiatAmount = viewModel.fiatAmount, !viewModel.isLoading, fiatAmount > 0 { - Text("≈\(fiatAmount.toString(maximumFractionDigits: 2, roundingMode: .down)) \(Defaults.fiat.code)") - .subtitleStyle(color: Color(viewModel.fiatAmountTextColor)) - .lineLimit(1) - .accessibilityIdentifier( - "SwapInputView.\(viewModel.accessibilityIdentifierTokenPrefix)FiatLabel" - ) - } - } - .frame(minHeight: 16) - } - .padding(EdgeInsets(top: 12, leading: 16, bottom: 16, trailing: 12)) - .background( - RoundedRectangle(cornerRadius: 16) - .foregroundColor(Color(Asset.Colors.snow.color).opacity(viewModel.isEditable ? 1 : 0.6)) - ) - } -} - -// MARK: - Subviews - -private extension SwapInputView { - var allButton: some View { - Button(action: viewModel.allButtonPressed.send, label: { - HStack(spacing: 4) { - Text(L10n.all.uppercaseFirst) - .subtitleStyle() - Text("\(viewModel.balanceText) \(viewModel.tokenSymbol)") - .apply(style: .label1) - .foregroundColor(Color(Asset.Colors.sky.color)) - } - }) - .accessibilityIdentifier("SwapInputView.\(viewModel.accessibilityIdentifierTokenPrefix)AllButton") - } - - var changeTokenButton: some View { - Button { - viewModel.changeTokenPressed.send() - } label: { - HStack { - Text(viewModel.tokenSymbol) - .apply(style: .title1) - .foregroundColor(Color(Asset.Colors.night.color)) - .if(viewModel.isLoading) { view in - view.skeleton(with: true, size: CGSize(width: 84, height: 20)) - } - Image(uiImage: .expandIcon) - .renderingMode(.template) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 12) - .foregroundColor(Color(Asset.Colors.night.color)) - } - } - .allowsHitTesting(!viewModel.isLoading) - .accessibilityIdentifier("SwapInputView.\(viewModel.accessibilityIdentifierTokenPrefix)TokenButton") - } - - var amountField: some View { - AmountTextField( - value: $viewModel.amount, + BigInputView( + allButtonPressed: { viewModel.allButtonPressed.send() }, + amountFieldTap: { viewModel.amountFieldTap.send() }, + changeTokenPressed: { viewModel.changeTokenPressed.send() }, + accessibilityIdPrefix: viewModel.accessibilityIdentifierTokenPrefix, + title: viewModel.title, + amount: $viewModel.amount, + amountTextColor: $viewModel.amountTextColor, isFirstResponder: $viewModel.isFirstResponder, - textColor: $viewModel.amountTextColor, - maxFractionDigits: $viewModel.decimalLength, - moveCursorToTrailingWhenDidBeginEditing: true - ) { textField in - textField.font = .font(of: .title1) - textField.isEnabled = viewModel.isEditable - textField.placeholder = "0" - textField.adjustsFontSizeToFitWidth = true - textField.textAlignment = .right - } - .frame(maxWidth: .infinity) - .accessibilityIdentifier("SwapInputView.\(viewModel.accessibilityIdentifierTokenPrefix)Input") - .if(viewModel.isLoading || viewModel.isAmountLoading) { view in - HStack { - Spacer() - view.skeleton(with: true, size: CGSize(width: 84, height: 20)) - } - } - .if(!viewModel.isEditable) { view in - view.onTapGesture(perform: viewModel.amountFieldTap.send) - } - .frame(height: 32) - } - - var balanceLabel: some View { - Text("\(L10n.balance) \(viewModel.balanceText)") - .subtitleStyle() - .if(viewModel.isLoading) { view in - view.skeleton(with: true, size: CGSize(width: 84, height: 8)) - } - .accessibilityIdentifier("SwapInputView.\(viewModel.accessibilityIdentifierTokenPrefix)BalanceLabel") - } -} - -private extension Text { - func subtitleStyle(color: Color = Color(Asset.Colors.silver.color)) -> some View { - apply(style: .label1).foregroundColor(color) + decimalLength: $viewModel.decimalLength, + isEditable: $viewModel.isEditable, + balance: $viewModel.balance, + balanceText: $viewModel.balanceText, + tokenSymbol: $viewModel.tokenSymbol, + isLoading: $viewModel.isLoading, + isAmountLoading: $viewModel.isAmountLoading, + fiatAmount: $viewModel.fiatAmount, + fiatAmountTextColor: $viewModel.fiatAmountTextColor + ) } } diff --git a/p2p_wallet/Scenes/Main/Swap/Swap/Subviews/Input/SwapInputViewModel.swift b/p2p_wallet/Scenes/Main/Swap/Swap/Subviews/Input/SwapInputViewModel.swift index 091dadd615..5437ed79ed 100644 --- a/p2p_wallet/Scenes/Main/Swap/Swap/Subviews/Input/SwapInputViewModel.swift +++ b/p2p_wallet/Scenes/Main/Swap/Swap/Subviews/Input/SwapInputViewModel.swift @@ -51,7 +51,7 @@ final class SwapInputViewModel: BaseViewModel, ObservableObject { token = stateMachine.currentState.fromToken decimalLength = Int(stateMachine.currentState.fromToken.token.decimals) - accessibilityIdentifierTokenPrefix = isFromToken ? "from" : "to" + accessibilityIdentifierTokenPrefix = isFromToken ? "SwapInputView.from" : "SwapInputView.to" super.init() allButtonPressed diff --git a/p2p_wallet/Scenes/Main/Topup/Actions/TopupActionsView.swift b/p2p_wallet/Scenes/Main/Topup/Actions/TopupActionsView.swift new file mode 100644 index 0000000000..7e3362fe8d --- /dev/null +++ b/p2p_wallet/Scenes/Main/Topup/Actions/TopupActionsView.swift @@ -0,0 +1,77 @@ +import Combine +import KeyAppUI +import SwiftUI + +struct TopupActionsView: View { + @ObservedObject var viewModel: TopupActionsViewModel + + var body: some View { + VStack(spacing: 0) { + RoundedRectangle(cornerRadius: 2, style: .circular) + .fill(Color(Asset.Colors.rain.color)) + .frame(width: 31, height: 4) + .padding(.top, 6) + Text(L10n.addMoney) + .apply(style: .title3, weight: .semibold) + .padding(.top, 24) + .padding(.bottom, 15) + VStack(spacing: 8) { + ForEach(viewModel.actions) { item in + Button { + viewModel.didTapItem(item: item) + } label: { + cell(item: item) + } + .disabled(item.isDisabled) + .frame(minHeight: 69) + } + } + .padding(.top, 21) + Spacer() + } + .padding(.horizontal, 16) + .padding(.bottom, 38) + .background(Color(Asset.Colors.smoke.color)) + .cornerRadius(20) + } + + private func foregroundColor(item: TopupActionsViewModel.ActionItem) -> Color { + item.isDisabled ? Color(Asset.Colors.mountain.color) : Color(Asset.Colors.night.color) + } + + private func cell(item: TopupActionsViewModel.ActionItem) -> some View { + HStack(spacing: 12) { + Image(uiImage: item.icon) + .frame(width: 50, height: 50) + VStack(alignment: .leading, spacing: 4) { + Text(item.title) + .apply(style: .text3, weight: .semibold) + .foregroundColor(foregroundColor(item: item)) + Text(item.subtitle) + .apply(style: .label1, weight: .regular) + .foregroundColor(foregroundColor(item: item)) + } + Spacer() + if item.isLoading { + Spinner( + color: Color(Asset.Colors.night.color).opacity(0.6), + activePartColor: Color(Asset.Colors.night.color) + ) + .frame(width: 24, height: 24) + } else { + Image(uiImage: Asset.MaterialIcon.chevronRight.image) + .foregroundColor(Color(Asset.Colors.mountain.color)) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color(Asset.Colors.snow.color)) + .cornerRadius(16) + } +} + +struct TopupActions_Previews: PreviewProvider { + static var previews: some View { + TopupActionsView(viewModel: .init()) + } +} diff --git a/p2p_wallet/Scenes/Main/Topup/Actions/TopupActionsViewModel.swift b/p2p_wallet/Scenes/Main/Topup/Actions/TopupActionsViewModel.swift new file mode 100644 index 0000000000..e04a383a07 --- /dev/null +++ b/p2p_wallet/Scenes/Main/Topup/Actions/TopupActionsViewModel.swift @@ -0,0 +1,159 @@ +import BankTransfer +import Combine +import CountriesAPI +import Foundation +import Onboarding +import Resolver +import UIKit + +final class TopupActionsViewModel: BaseViewModel, ObservableObject { + @Injected private var bankTransferService: any BankTransferService + @Injected private var notificationService: NotificationService + @Injected private var metadataService: WalletMetadataService + + // MARK: - + + @Published var actions: [ActionItem] = [ + ActionItem( + id: .crypto, + icon: .addMoneyCrypto, + title: L10n.crypto, + subtitle: L10n.upTo1Hour·Fees("%0"), + isLoading: false, + isDisabled: false + ), + ] + + var tappedItem: AnyPublisher { + if !shouldShowBankTransfer { + return tappedItemSubject.eraseToAnyPublisher() + } + return tappedItemSubject.flatMap { [unowned self] action in + switch action { + // If it's transfer we need to check if the service is ready + case .transfer: + return self.bankTransferService.state.filter { state in + !state.hasError && !state.isFetching && !state.value.isIBANNotReady + }.map { _ in Action.transfer }.eraseToAnyPublisher() + default: + // Otherwise just pass action + return Just(action).eraseToAnyPublisher() + } + }.eraseToAnyPublisher() + } + + private let tappedItemSubject = PassthroughSubject() + private let shouldCheckBankTransfer = PassthroughSubject() + private let shouldShowErrorSubject = CurrentValueSubject(false) + private var shouldShowBankTransfer: Bool { + // always enabled for mocking + GlobalAppState.shared.strigaMockingEnabled ? true : + // for non-mocking need to check + available(.bankTransfer) && metadataService.metadata.value != nil + } + + func didTapItem(item: ActionItem) { + tappedItemSubject.send(item.id) + } + + override init() { + super.init() + + if let region = Defaults.region, region.isMoonpayAllowed { + actions.insert( + ActionItem( + id: .card, + icon: region.isMoonpayAllowed ? .addMoneyBankCard : .addMoneyBankCardDisabled, + title: L10n.bankCard, + subtitle: L10n.instant·Fees("4.5%"), + isLoading: false, + isDisabled: !region.isMoonpayAllowed + ), + at: 0 + ) + } + + let isStrigaAllowed = Defaults.region?.isStrigaAllowed ?? false + if shouldShowBankTransfer, isStrigaAllowed { + actions.insert( + ActionItem( + id: .transfer, + icon: isStrigaAllowed ? .addMoneyBankTransfer : .addMoneyBankTransferDisabled, + title: L10n.bankTransfer, + subtitle: L10n.upTo3Days·Fees("0%"), + isLoading: false, + isDisabled: !isStrigaAllowed + ), + at: 0 + ) + bindBankTransfer() + } + } + + private func bindBankTransfer() { + shouldCheckBankTransfer + .withLatestFrom(bankTransferService.state) + .filter { !$0.isFetching } + .receive(on: RunLoop.main) + .sink { [weak self] state in + self?.setTransferLoadingState(isLoading: false) + // Toggling error + if self?.shouldShowErrorSubject.value == false { + self?.shouldShowErrorSubject.send(state.hasError || state.value.isIBANNotReady) + } + } + .store(in: &subscriptions) + + tappedItemSubject + .filter { $0 == .transfer } + .withLatestFrom(bankTransferService.state).filter { state in + state.hasError || state.value.isIBANNotReady + } + .sinkAsync { [weak self] _ in + guard let self else { return } + await MainActor.run { + self.setTransferLoadingState(isLoading: true) + self.shouldShowErrorSubject.send(false) + } + await self.bankTransferService.reload() + self.shouldCheckBankTransfer.send(()) + } + .store(in: &subscriptions) + + shouldShowErrorSubject.filter { $0 }.sinkAsync { [weak self] _ in + await MainActor.run { + self?.notificationService.showToast(title: "❌", text: L10n.somethingWentWrong) + } + }.store(in: &subscriptions) + + bankTransferService.state.filter { state in + state.status == .initializing + }.sinkAsync { [weak self] _ in + await self?.bankTransferService.reload() + }.store(in: &subscriptions) + } + + private func setTransferLoadingState(isLoading: Bool) { + guard let idx = actions.firstIndex(where: { act in + act.id == .transfer + }) else { return } + actions[idx].isLoading = isLoading + } +} + +extension TopupActionsViewModel { + enum Action: String { + case transfer + case card + case crypto + } + + struct ActionItem: Identifiable { + var id: TopupActionsViewModel.Action + var icon: UIImage + var title: String + var subtitle: String + var isLoading: Bool + var isDisabled: Bool + } +} diff --git a/p2p_wallet/Scenes/Main/Topup/Actions/TopupCoordinator.swift b/p2p_wallet/Scenes/Main/Topup/Actions/TopupCoordinator.swift new file mode 100644 index 0000000000..0ff96a3b5a --- /dev/null +++ b/p2p_wallet/Scenes/Main/Topup/Actions/TopupCoordinator.swift @@ -0,0 +1,45 @@ +import AnalyticsManager +import Combine +import Foundation +import Resolver +import SwiftUI + +enum TopupCoordinatorResult { + case action(action: TopupActionsViewModel.Action) + case cancel +} + +final class TopupCoordinator: Coordinator { + private var viewController: UIViewController! + + @Injected private var analyticsManager: AnalyticsManager + + init( + viewController: UIViewController? = nil + ) { + self.viewController = viewController + } + + override func start() -> AnyPublisher { + let viewModel = TopupActionsViewModel() + let controller = BottomSheetController( + rootView: TopupActionsView(viewModel: viewModel) + ) + viewController?.present(controller, animated: true) + + return Publishers.Merge( + // Cancel event + controller.deallocatedPublisher() + .map { TopupCoordinatorResult.cancel }.eraseToAnyPublisher(), + // Tapped item + viewModel.tappedItem + .map { TopupCoordinatorResult.action(action: $0) } + .receive(on: RunLoop.main) + .handleEvents(receiveOutput: { [weak controller] _ in + controller?.dismiss(animated: true) + }) + .eraseToAnyPublisher() + ) + .prefix(1).eraseToAnyPublisher() + } +} diff --git a/p2p_wallet/Scenes/Main/TransactionDetailView/Model/RendableDetailTransaction+PendingTransaction.swift b/p2p_wallet/Scenes/Main/TransactionDetailView/Model/RendableDetailTransaction+PendingTransaction.swift index bf20bb27a8..1a75559db7 100644 --- a/p2p_wallet/Scenes/Main/TransactionDetailView/Model/RendableDetailTransaction+PendingTransaction.swift +++ b/p2p_wallet/Scenes/Main/TransactionDetailView/Model/RendableDetailTransaction+PendingTransaction.swift @@ -4,11 +4,17 @@ import KeyAppBusiness import KeyAppKitCore import Wormhole -struct RendableDetailPendingTransaction: RenderableTransactionDetail { +struct RenderableDetailPendingTransaction: RenderableTransactionDetail { let trx: PendingTransaction + private var hasTransactionId: Bool { + trx + .transactionId != nil && !(trx.rawTransaction is ClaimSentViaLinkTransaction) && + !(trx.rawTransaction is StrigaWithdrawSendTransaction) + } + var status: TransactionDetailStatus { - if trx.transactionId != nil, !(trx.rawTransaction is ClaimSentViaLinkTransaction) { + if hasTransactionId { return .succeed(message: L10n.theTransactionHasBeenSuccessfullyCompleted) } @@ -22,13 +28,15 @@ struct RendableDetailPendingTransaction: RenderableTransactionDetail { ), error: errorModel) case .finalized: return .succeed(message: L10n.theTransactionHasBeenSuccessfullyCompleted) + case .confirmationNeeded: + return .paused(message: L10n.weWillAskYouToConfirmThisOperationWithinAFewMinutes) default: return .loading(message: L10n.theTransactionWillBeCompletedInAFewSeconds) } } var title: String { - if trx.transactionId != nil, !(trx.rawTransaction is ClaimSentViaLinkTransaction) { + if hasTransactionId { return L10n.transactionSucceeded } @@ -37,6 +45,8 @@ struct RendableDetailPendingTransaction: RenderableTransactionDetail { return L10n.transactionFailed case .finalized: return L10n.transactionSucceeded + case .confirmationNeeded: + return L10n.transactionPending default: return L10n.transactionSubmitted } @@ -96,6 +106,26 @@ struct RendableDetailPendingTransaction: RenderableTransactionDetail { return .icon(.transactionReceive) } + case let transaction as any StrigaClaimTransactionType: + if + let urlStr = transaction.token?.logoURI, + let url = URL(string: urlStr) + { + return .single(url) + } else { + return .icon(.transactionReceive) + } + + case let transaction as any StrigaWithdrawTransactionType: + if + let urlStr = transaction.token?.logoURI, + let url = URL(string: urlStr) + { + return .single(url) + } else { + return .icon(.transactionSend) + } + // case let transaction as WormholeClaimTransaction: // guard let url = transaction.token.logo else { // return .icon(.planet) @@ -140,6 +170,17 @@ struct RendableDetailPendingTransaction: RenderableTransactionDetail { return .positive("+\(amountInFiat)") } return .unchanged("") + case let transaction as any StrigaClaimTransactionType: + if let amountInFiat = transaction.amountInFiat?.fiatAmountFormattedString() { + return .positive("+\(amountInFiat)") + } + return .unchanged("") + + case let transaction as any StrigaWithdrawTransactionType: + if let amountInFiat = transaction.amountInFiat?.fiatAmountFormattedString() { + return .negative("-\(amountInFiat)") + } + return .unchanged("") default: return .unchanged("") @@ -174,6 +215,10 @@ struct RendableDetailPendingTransaction: RenderableTransactionDetail { case let transaction as ClaimSentViaLinkTransaction: return "\(transaction.tokenAmount.tokenAmountFormattedString(symbol: transaction.token.symbol))" + case let transaction as any StrigaClaimTransactionType: + guard let amount = transaction.amount else { return "" } + return "\(amount.tokenAmountFormattedString(symbol: transaction.token?.symbol ?? ""))" + default: return "" } @@ -237,34 +282,35 @@ struct RendableDetailPendingTransaction: RenderableTransactionDetail { ) } case let transaction as SwapRawTransactionType: - let fees = transaction.feeAmount - - if fees.total == 0 { - result.append( - .init( - title: L10n.transactionFee, - values: [.init(text: L10n.freePaidByKeyApp)] - ) - ) - } - - // network fee - else if let payingFeeWallet = transaction.payingFeeWallet { - let feeAmount: Double = fees.total.convertToBalance(decimals: payingFeeWallet.token.decimals) - let formatedFeeAmount: String = feeAmount - .tokenAmountFormattedString(symbol: payingFeeWallet.token.symbol) - - let feeAmountInFiat: Double = feeAmount * payingFeeWallet.price?.doubleValue - let formattedFeeAmountInFiat: String = feeAmountInFiat.fiatAmountFormattedString() - - result - .append( - .init( - title: L10n.transactionFee, - values: [.init(text: "\(formatedFeeAmount) (\(formattedFeeAmountInFiat))")] - ) - ) - } + break +// let fees = transaction.feeAmount +// +// if fees.total == 0 { +// result.append( +// .init( +// title: L10n.transactionFee, +// values: [.init(text: L10n.freePaidByKeyApp)] +// ) +// ) +// } +// +// // network fee +// else if let payingFeeWallet = transaction.payingFeeWallet { +// let feeAmount: Double = fees.total.convertToBalance(decimals: payingFeeWallet.token.decimals) +// let formatedFeeAmount: String = feeAmount +// .tokenAmountFormattedString(symbol: payingFeeWallet.token.symbol) +// +// let feeAmountInFiat: Double = feeAmount * payingFeeWallet.price?.doubleValue +// let formattedFeeAmountInFiat: String = feeAmountInFiat.fiatAmountFormattedString() +// +// result +// .append( +// .init( +// title: L10n.transactionFee, +// values: [.init(text: "\(formatedFeeAmount) (\(formattedFeeAmountInFiat))")] +// ) +// ) +// } case let transaction as ClaimSentViaLinkTransaction: let title: String @@ -286,6 +332,52 @@ struct RendableDetailPendingTransaction: RenderableTransactionDetail { ) ) result.append(.init(title: L10n.transactionFee, values: [.init(text: L10n.freePaidByKeyApp)])) + + case let transaction as any StrigaClaimTransactionType: + let title = L10n.receivedFrom + result.append( + .init( + title: title, + values: [ + .init(text: RecipientFormatter + .format(destination: transaction.fromAddress)), + ], + copyableValue: transaction.fromAddress + ) + ) + result.append( + .init( + title: L10n.transactionFee, + values: [.init(text: L10n.freePaidByKeyApp)] + ) + ) + + case let transaction as any StrigaWithdrawTransactionType: + result.append( + .init( + title: L10n.iban, + values: [ + .init(text: transaction.IBAN.formatIBAN()), + ], + copyableValue: nil + ) + ) + result.append( + .init( + title: L10n.bic, + values: [ + .init(text: transaction.BIC), + ], + copyableValue: nil + ) + ) + result.append( + .init( + title: L10n.transactionFee, + values: [.init(text: L10n.freePaidByKeyApp)] + ) + ) + default: break } @@ -296,6 +388,11 @@ struct RendableDetailPendingTransaction: RenderableTransactionDetail { var actions: [TransactionDetailAction] { switch trx.status { case .finalized: + if trx.rawTransaction as? any StrigaWithdrawTransactionType != nil || + trx.rawTransaction as? any StrigaClaimTransactionType != nil + { + return [] + } return [.share, .explorer] default: return [] diff --git a/p2p_wallet/Scenes/Main/TransactionDetailView/Model/RenderableTransactionDetail.swift b/p2p_wallet/Scenes/Main/TransactionDetailView/Model/RenderableTransactionDetail.swift index 48a1bdcc97..a37bbe1915 100644 --- a/p2p_wallet/Scenes/Main/TransactionDetailView/Model/RenderableTransactionDetail.swift +++ b/p2p_wallet/Scenes/Main/TransactionDetailView/Model/RenderableTransactionDetail.swift @@ -91,6 +91,7 @@ enum TransactionDetailChange { } enum TransactionDetailStatus { + case paused(message: String) case loading(message: String) case succeed(message: String) case error(message: NSAttributedString, error: Error?) diff --git a/p2p_wallet/Scenes/Main/TransactionDetailView/Subview/TransactionDetailStatusView.swift b/p2p_wallet/Scenes/Main/TransactionDetailView/Subview/TransactionDetailStatusView.swift index e30effa5e8..bfda38961c 100644 --- a/p2p_wallet/Scenes/Main/TransactionDetailView/Subview/TransactionDetailStatusView.swift +++ b/p2p_wallet/Scenes/Main/TransactionDetailView/Subview/TransactionDetailStatusView.swift @@ -10,7 +10,7 @@ struct TransactionDetailStatusAppearance { init(status: TransactionDetailStatus) { switch status { - case .loading: + case .loading, .paused(message: _): image = .lightningFilled imageSize = CGSize(width: 24, height: 24) backgroundColor = Color(Asset.Colors.cloud.color) @@ -19,8 +19,8 @@ struct TransactionDetailStatusAppearance { case .succeed: image = .lightningFilled imageSize = CGSize(width: 24, height: 24) - backgroundColor = Color(.cdf6cd).opacity(0.3) - circleColor = Color(.cdf6cd) + backgroundColor = Color(Asset.Colors.lightGrass.color).opacity(0.3) + circleColor = Color(Asset.Colors.lightGrass.color) imageColor = Color(.h04D004) case .error: image = .solendSubtract @@ -103,6 +103,8 @@ struct TransactionDetailStatusView: View { Text(L10n.LowSlippage.weRecommendToIncreaseSlippageManually(context ?? "")) .messageStyled() } + case let .paused(message: message): + Text(message).messageStyled() } } .padding(.leading, 2) diff --git a/p2p_wallet/Scenes/Main/TransactionDetailView/TransactionDetailView.swift b/p2p_wallet/Scenes/Main/TransactionDetailView/TransactionDetailView.swift index 2d77fc003a..ea717ab735 100644 --- a/p2p_wallet/Scenes/Main/TransactionDetailView/TransactionDetailView.swift +++ b/p2p_wallet/Scenes/Main/TransactionDetailView/TransactionDetailView.swift @@ -24,6 +24,9 @@ struct DetailTransactionView: View { .padding(.horizontal, 16) .padding(.bottom, 4) } + .background(Color(Asset.Colors.smoke.color)) + .cornerRadius(radius: 20, corners: .topLeft) + .cornerRadius(radius: 20, corners: .topRight) .navigationBarHidden(true) } @@ -98,7 +101,6 @@ struct DetailTransactionView: View { } } .frame(maxWidth: .infinity) - .background(Color(Asset.Colors.smoke.color)) } var info: some View { diff --git a/p2p_wallet/Scenes/Main/TransactionDetailView/TransactionDetailViewModel.swift b/p2p_wallet/Scenes/Main/TransactionDetailView/TransactionDetailViewModel.swift index 5945c01aeb..2173672a31 100644 --- a/p2p_wallet/Scenes/Main/TransactionDetailView/TransactionDetailViewModel.swift +++ b/p2p_wallet/Scenes/Main/TransactionDetailView/TransactionDetailViewModel.swift @@ -56,7 +56,7 @@ class TransactionDetailViewModel: BaseViewModel, ObservableObject { style = .active self.statusContext = statusContext - rendableTransaction = RendableDetailPendingTransaction(trx: pendingTransaction) + rendableTransaction = RenderableDetailPendingTransaction(trx: pendingTransaction) super.init() @@ -64,7 +64,7 @@ class TransactionDetailViewModel: BaseViewModel, ObservableObject { .observeTransaction(transactionIndex: pendingTransaction.trxIndex) .sink { trx in guard let trx = trx else { return } - self.rendableTransaction = RendableDetailPendingTransaction(trx: trx) + self.rendableTransaction = RenderableDetailPendingTransaction(trx: trx) } .store(in: &subscriptions) } @@ -89,7 +89,7 @@ class TransactionDetailViewModel: BaseViewModel, ObservableObject { userActionService .observer(id: userAction.id) .receive(on: RunLoop.main) - .sink { userAction in + .sink { [unowned self] userAction in self.rendableTransaction = RendableGeneralUserActionTransaction.resolve(userAction: userAction) } .store(in: &subscriptions) diff --git a/p2p_wallet/Scenes/Main/WithdrawActions/WithdrawActionsCoordinator.swift b/p2p_wallet/Scenes/Main/WithdrawActions/WithdrawActionsCoordinator.swift new file mode 100644 index 0000000000..90e386d805 --- /dev/null +++ b/p2p_wallet/Scenes/Main/WithdrawActions/WithdrawActionsCoordinator.swift @@ -0,0 +1,45 @@ +import AnalyticsManager +import Combine +import Foundation +import Resolver +import SwiftUI + +final class WithdrawActionsCoordinator: Coordinator { + private var viewController: UIViewController + + @Injected private var analyticsManager: AnalyticsManager + + init(viewController: UIViewController) { + self.viewController = viewController + } + + override func start() -> AnyPublisher { + let viewModel = WithdrawActionsViewModel() + let controller = BottomSheetController( + rootView: WithdrawActionsView(viewModel: viewModel) + ) + viewController.present(controller, animated: true) + + return Publishers.Merge( + // Cancel event + controller.deallocatedPublisher() + .map { Result.cancel }.eraseToAnyPublisher(), + // Tapped item + viewModel.tappedItem + .map { Result.action(action: $0) } + .receive(on: RunLoop.main) + .handleEvents(receiveOutput: { [weak controller] _ in + controller?.dismiss(animated: true) + }) + .eraseToAnyPublisher() + ) + .prefix(1).eraseToAnyPublisher() + } +} + +extension WithdrawActionsCoordinator { + enum Result { + case action(action: WithdrawActionsViewModel.Action) + case cancel + } +} diff --git a/p2p_wallet/Scenes/Main/WithdrawActions/WithdrawActionsView.swift b/p2p_wallet/Scenes/Main/WithdrawActions/WithdrawActionsView.swift new file mode 100644 index 0000000000..3b782c2f3f --- /dev/null +++ b/p2p_wallet/Scenes/Main/WithdrawActions/WithdrawActionsView.swift @@ -0,0 +1,77 @@ +import Combine +import KeyAppUI +import SwiftUI + +struct WithdrawActionsView: View { + @ObservedObject var viewModel: WithdrawActionsViewModel + + var body: some View { + VStack(spacing: 0) { + RoundedRectangle(cornerRadius: 2, style: .circular) + .fill(Color(Asset.Colors.rain.color)) + .frame(width: 31, height: 4) + .padding(.top, 6) + Text(L10n.withdrawTo) + .apply(style: .title3, weight: .semibold) + .padding(.top, 24) + VStack(spacing: 8) { + ForEach(viewModel.actions) { item in + Button { + viewModel.didTapItem(item: item) + } label: { + cell(item: item) + } + .disabled(item.isDisabled) + .frame(minHeight: 69) + } + } + .padding(.top, 21) + Spacer() + } + .padding(.horizontal, 16) + .padding(.bottom, 38) + .background(Color(Asset.Colors.smoke.color)) + .cornerRadius(20) + } + + private func foregroundColor(item: WithdrawActionsViewModel.ActionItem) -> Color { + item.isDisabled ? Color(Asset.Colors.mountain.color) : Color(Asset.Colors.night.color) + } + + private func cell(item: WithdrawActionsViewModel.ActionItem) -> some View { + HStack(spacing: 12) { + Image(uiImage: item.icon) + .frame(width: 50, height: 50) + VStack(alignment: .leading, spacing: 4) { + Text(item.title) + .apply(style: .text3, weight: .semibold) + .foregroundColor(foregroundColor(item: item)) + + Text(item.subtitle) + .apply(style: .label1, weight: .regular) + .foregroundColor(foregroundColor(item: item)) + } + Spacer() + if item.isLoading { + Spinner( + color: Color(Asset.Colors.night.color).opacity(0.6), + activePartColor: Color(Asset.Colors.night.color) + ) + .frame(width: 24, height: 24) + } else { + Image(uiImage: Asset.MaterialIcon.chevronRight.image) + .foregroundColor(Color(Asset.Colors.mountain.color)) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color(Asset.Colors.snow.color)) + .cornerRadius(16) + } +} + +struct WithdrawActionsView_Previews: PreviewProvider { + static var previews: some View { + WithdrawActionsView(viewModel: .init()) + } +} diff --git a/p2p_wallet/Scenes/Main/WithdrawActions/WithdrawActionsViewModel.swift b/p2p_wallet/Scenes/Main/WithdrawActions/WithdrawActionsViewModel.swift new file mode 100644 index 0000000000..f5adbface2 --- /dev/null +++ b/p2p_wallet/Scenes/Main/WithdrawActions/WithdrawActionsViewModel.swift @@ -0,0 +1,89 @@ +import Combine +import CountriesAPI +import Foundation +import Onboarding +import Resolver +import UIKit + +final class WithdrawActionsViewModel: BaseViewModel, ObservableObject { + @Injected private var metadataService: WalletMetadataService + + // MARK: - + + @Published var actions: [ActionItem] = [] + + var tappedItem: AnyPublisher { + tappedItemSubject.eraseToAnyPublisher() + } + + private let tappedItemSubject = PassthroughSubject() + private var shouldShowBankTransfer: Bool { + // always enabled for mocking + GlobalAppState.shared.strigaMockingEnabled ? true : + // for non-mocking need to check + available(.bankTransfer) && metadataService.metadata.value != nil + } + + func didTapItem(item: ActionItem) { + tappedItemSubject.send(item.id) + } + + override init() { + super.init() + + actions.insert( + ActionItem( + id: .wallet, + icon: .withdrawActionsCrypto, + title: L10n.cryptoExchangeOrWallet, + subtitle: L10n.fee("0%"), + isLoading: false, + isDisabled: false + ), + at: 0 + ) + + actions.insert( + ActionItem( + id: .user, + icon: .withdrawActionsUser, + title: L10n.keyAppUser, + subtitle: L10n.fee("0%"), + isLoading: false, + isDisabled: false + ), + at: 0 + ) + + if let region = Defaults.region, region.isStrigaAllowed { + actions.insert( + ActionItem( + id: .transfer, + icon: region.isStrigaAllowed ? .withdrawActionsTransfer : .addMoneyBankTransferDisabled, + title: L10n.myBankAccount, + subtitle: L10n.fee("1%"), + isLoading: false, + isDisabled: !region.isStrigaAllowed + ), + at: 0 + ) + } + } +} + +extension WithdrawActionsViewModel { + enum Action: String { + case transfer + case user + case wallet + } + + struct ActionItem: Identifiable { + var id: WithdrawActionsViewModel.Action + var icon: UIImage + var title: String + var subtitle: String + var isLoading: Bool + var isDisabled: Bool + } +} diff --git a/p2p_wallet/Scenes/Main/Wormhole/Send/ChooseWormholeSend/ChooseWormholeTokenCoordinator.swift b/p2p_wallet/Scenes/Main/Wormhole/Send/ChooseWormholeSend/ChooseWormholeTokenCoordinator.swift index 339d95c282..5d68b046da 100644 --- a/p2p_wallet/Scenes/Main/Wormhole/Send/ChooseWormholeSend/ChooseWormholeTokenCoordinator.swift +++ b/p2p_wallet/Scenes/Main/Wormhole/Send/ChooseWormholeSend/ChooseWormholeTokenCoordinator.swift @@ -22,7 +22,8 @@ final class ChooseWormholeTokenCoordinator: Coordinator { override func start() -> AnyPublisher { let viewModel = ChooseItemViewModel( service: ChooseWormholeTokenService(), - chosenToken: chosenWallet + chosenItem: chosenWallet, + isSearchEnabled: true ) let view = ChooseItemView(viewModel: viewModel) { model in TokenCellView(item: .init(wallet: model.item as! SolanaAccount), appearance: .other) diff --git a/p2p_wallet/Scenes/Main/Wormhole/Send/ChooseWormholeSend/ChooseWormholeTokenService.swift b/p2p_wallet/Scenes/Main/Wormhole/Send/ChooseWormholeSend/ChooseWormholeTokenService.swift index 753f40b49a..68a453de0e 100644 --- a/p2p_wallet/Scenes/Main/Wormhole/Send/ChooseWormholeSend/ChooseWormholeTokenService.swift +++ b/p2p_wallet/Scenes/Main/Wormhole/Send/ChooseWormholeSend/ChooseWormholeTokenService.swift @@ -6,7 +6,9 @@ import SolanaSwift import Wormhole final class ChooseWormholeTokenService: ChooseItemService { - let otherTokensTitle = L10n.otherTokens + let otherTitle = L10n.chosenToken + let chosenTitle = L10n.otherTokens + let emptyTitle = L10n.TokenNotFound.tryAnotherOne var state: AnyPublisher, Never> { statePublisher.eraseToAnyPublisher() diff --git a/p2p_wallet/Scenes/SelectCountry/View/ChangeCountryErrorView.swift b/p2p_wallet/Scenes/SelectCountry/View/ChangeCountryErrorView.swift deleted file mode 100644 index a1b3b23a39..0000000000 --- a/p2p_wallet/Scenes/SelectCountry/View/ChangeCountryErrorView.swift +++ /dev/null @@ -1,71 +0,0 @@ -import KeyAppUI -import SwiftUI - -struct ChangeCountryErrorView: View { - let model: ChangeCountryModel - let buttonAction: () -> Void - let subButtonAction: () -> Void - - var body: some View { - VStack(spacing: 20) { - Spacer() - Image(uiImage: model.image) - VStack(spacing: 8) { - Text(model.title) - .foregroundColor(Color(Asset.Colors.night.color)) - .font(uiFont: .font(of: .title1, weight: .bold)) - Text(model.subtitle) - .multilineTextAlignment(.center) - .foregroundColor(Color(Asset.Colors.night.color)) - .font(uiFont: .font(of: .text1)) - } - Spacer() - actions - } - .padding(.horizontal, 16) - .padding(.bottom, 32) - } - - private var actions: some View { - VStack(spacing: 12) { - Button( - action: { - buttonAction() - }, - label: { - Text(model.buttonTitle) - .foregroundColor(Color(Asset.Colors.snow.color)) - .font(uiFont: .font(of: .text2, weight: .semibold)) - .frame(height: 56) - .frame(maxWidth: .infinity) - .background(Color(Asset.Colors.night.color)) - .cornerRadius(12) - } - ) - Button( - action: { - subButtonAction() - }, - label: { - Text(model.subButtonTitle) - .foregroundColor(Color(Asset.Colors.night.color)) - .font(uiFont: .font(of: .text2, weight: .semibold)) - .frame(height: 56) - .frame(maxWidth: .infinity) - } - ) - } - } -} - -// MARK: - Model - -extension ChangeCountryErrorView { - struct ChangeCountryModel { - let image: UIImage - let title: String - let subtitle: String - let buttonTitle: String - let subButtonTitle: String - } -} diff --git a/p2p_wallet/Scenes/SelectCountry/View/SelectCountryView.swift b/p2p_wallet/Scenes/SelectCountry/View/SelectCountryView.swift deleted file mode 100644 index ec8c78d522..0000000000 --- a/p2p_wallet/Scenes/SelectCountry/View/SelectCountryView.swift +++ /dev/null @@ -1,128 +0,0 @@ -import KeyAppUI -import SkeletonUI -import SwiftUI - -struct SelectCountryView: View { - @ObservedObject var viewModel: SelectCountryViewModel - - var body: some View { - WrapperForSearchingView(searching: $viewModel.isSearching) { - searchableContent - } - .searchable(text: $viewModel.searchText) - } - - private var searchableContent: some View { - Group { - switch viewModel.state { - case .loaded, .skeleton: - content - case .notFound: - Spacer() - notFoundView - Spacer() - } - } - .toolbar { - ToolbarItem(placement: .principal) { - Text(L10n.selectYourCountry) - .fontWeight(.semibold) - } - } - .onAppear { - viewModel.onAppear() - } - } - - @ViewBuilder - private var content: some View { - List { - if viewModel.searchText.isEmpty { - Section(header: Text(L10n.chosenCountry)) { - switch viewModel.state { - case .skeleton: - countrySkeleton - case .loaded: - Button( - action: { - viewModel.currentCountrySelected() - }, - label: { - countryView( - flag: viewModel.selectedCountry.flag, - title: viewModel.selectedCountry.title - ) - } - ) - case .notFound: - SwiftUI.EmptyView() - } - } - } - Section(header: Text(L10n.allCountries)) { - switch viewModel.state { - case .skeleton: - ForEach(0 ..< 10) { _ in - countrySkeleton - } - case let .loaded(countries): - ForEach(0 ..< countries.count, id: \.self) { index in - Button( - action: { - viewModel.countrySelected(model: countries[index]) - }, - label: { - countryView(flag: countries[index].flag, title: countries[index].title) - } - ) - } - case .notFound: - SwiftUI.EmptyView() - } - } - } - .listStyle(.insetGrouped) - } - - private func countryView(flag: String, title: String) -> some View { - HStack(spacing: 10) { - Text(flag) - .font(uiFont: .font(of: .title1, weight: .bold)) - Text(title) - .foregroundColor(Color(Asset.Colors.night.color)) - .font(uiFont: .font(of: .text3)) - } - .padding(.vertical, 6) - } - - private var countrySkeleton: some View { - HStack(spacing: 12) { - Text("") - .skeleton( - with: true, - size: CGSize(width: 32, height: 28), - animated: .default - ) - Text("") - .skeleton( - with: true, - size: CGSize(width: 120, height: 16), - animated: .default - ) - } - .padding(.vertical, 6) - } -} - -// MARK: - Not Found - -private extension SelectCountryView { - var notFoundView: some View { - VStack(spacing: 20) { - Image(uiImage: .womanNotFound) - Text(L10n.sorryWeDonTKnowThatCountry) - .foregroundColor(Color(Asset.Colors.night.color)) - .font(uiFont: .font(of: .text3)) - } - } -} diff --git a/p2p_wallet/Scenes/SelectCountry/ViewModel/SelectCountryViewModel.swift b/p2p_wallet/Scenes/SelectCountry/ViewModel/SelectCountryViewModel.swift deleted file mode 100644 index 941de06b38..0000000000 --- a/p2p_wallet/Scenes/SelectCountry/ViewModel/SelectCountryViewModel.swift +++ /dev/null @@ -1,135 +0,0 @@ -import AnalyticsManager -import Combine -import Foundation -import Moonpay -import Resolver - -private extension String { - static let neutralFlag = "🏳️‍🌈" -} - -final class SelectCountryViewModel: ObservableObject { - // Dependencies - @Injected private var analyticsManager: AnalyticsManager - @Injected private var moonpayProvider: Moonpay.Provider - - // Private variables - private var models = [(Model, buyAllowed: Bool)]() - - // Subjects - private let selectCountrySubject = PassthroughSubject<(Model, buyAllowed: Bool), Never>() - private let currentSelectedSubject = PassthroughSubject() - - // MARK: - To View - - let selectedCountry: Model - @Published var state = State.skeleton - - // MARK: - From View - - @Published var searchText = "" { - didSet { - searchItem() - } - } - - @Published var isSearching = false { - didSet { - isSearchingChanged() - } - } - - // MARK: - Init - - init(selectedCountry: Model) { - self.selectedCountry = selectedCountry - getCountries() - } - - // MARK: - From View - - func onAppear() { - analyticsManager.log(event: .regionBuyScreenOpen) - } - - func countrySelected(model: Model) { - guard let model = (models.first { $0.0 == model }) else { return } - analyticsManager.log(event: .regionBuyResultClick(country: model.0.title)) - selectCountrySubject.send(model) - } - - func currentCountrySelected() { - analyticsManager.log(event: .regionBuyResultClick(country: selectedCountry.title)) - currentSelectedSubject.send() - } - - // MARK: - Private - - private func getCountries() { - Task { - let countries = try await moonpayProvider.getCountries() - - await MainActor.run { - var models = [(Model, buyAllowed: Bool)]() - for country in countries { - let flag = country.code.asFlag ?? .neutralFlag - - if selectedCountry.title != country.name { - models.append((Model(flag: flag, title: country.name), buyAllowed: country.isBuyAllowed)) - } - guard country.code == "US" else { continue } - - for state in country.states ?? [] { - guard !state.isBuyAllowed else { continue } - let title = "\(country.name) (\(state.name))" - guard selectedCountry.title != title else { continue } - - models.append(( - Model(flag: flag, title: "\(country.name) (\(state.name))"), - buyAllowed: state.isBuyAllowed - )) - } - } - state = .loaded(models: models.map(\.0)) - self.models = models - } - } - } - - private func searchItem() { - guard !searchText.isEmpty else { - state = .loaded(models: models.map(\.0)) - return - } - - let filteredItems = models.filter { $0.0.title.contains(searchText) } - state = !filteredItems.isEmpty ? .loaded(models: filteredItems.map(\.0)) : .notFound - } - - private func isSearchingChanged() { - guard isSearching else { return } - analyticsManager.log(event: .regionBuySearchClick) - } -} - -// MARK: - State - -extension SelectCountryViewModel { - enum State { - case skeleton - case loaded(models: [Model]) - case notFound - } - - struct Model: Equatable { - let flag: String - let title: String - } -} - -// MARK: - To Coordinator - -extension SelectCountryViewModel { - var selectCountry: AnyPublisher<(Model, buyAllowed: Bool), Never> { selectCountrySubject.eraseToAnyPublisher() } - var currentSelected: AnyPublisher { currentSelectedSubject.eraseToAnyPublisher() } -} diff --git a/p2p_wallet/Scenes/TabBar/TabBarCoordinator.swift b/p2p_wallet/Scenes/TabBar/TabBarCoordinator.swift index eff079f2a1..7d55c0dead 100644 --- a/p2p_wallet/Scenes/TabBar/TabBarCoordinator.swift +++ b/p2p_wallet/Scenes/TabBar/TabBarCoordinator.swift @@ -1,5 +1,7 @@ import AnalyticsManager +import BankTransfer import Combine +import CountriesAPI import Foundation import KeyAppBusiness import Resolver diff --git a/p2p_wallet/Scenes/TabBar/TabBarViewModel.swift b/p2p_wallet/Scenes/TabBar/TabBarViewModel.swift index 7330664c2a..140c9b78a1 100644 --- a/p2p_wallet/Scenes/TabBar/TabBarViewModel.swift +++ b/p2p_wallet/Scenes/TabBar/TabBarViewModel.swift @@ -167,7 +167,7 @@ extension TabBarViewModel { let ethAccounts = self.ethereumAggregator.transform(input: (state.value, actions)) let transferAccounts = ethAccounts.filter { ethAccount in switch ethAccount.status { - case .readyToClaim, .isClaiming: + case .ready, .isProcessing: return true default: return false diff --git a/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/BindingPhoneNumberDelegatedCoordinator.swift b/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/BindingPhoneNumberDelegatedCoordinator.swift index f58cc8139a..7f418e9026 100644 --- a/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/BindingPhoneNumberDelegatedCoordinator.swift +++ b/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/BindingPhoneNumberDelegatedCoordinator.swift @@ -23,11 +23,8 @@ class BindingPhoneNumberDelegatedCoordinator: DelegatedCoordinator Country? { + func selectCountry(chosen: Country?) async throws -> Country? { guard let rootViewController = rootViewController else { return nil } - let coordinator = ChoosePhoneCodeCoordinator( - selectedDialCode: selectedDialCode, - selectedCountryCode: selectedCountryCode, - presentingViewController: rootViewController + let coordinator = ChooseItemCoordinator( + title: L10n.selectYourCountry, + controller: rootViewController, + service: ChoosePhoneCodeService(), + chosen: PhoneCodeItem(country: chosen) ) - return try await coordinator.start().async() + let result = try await coordinator.start().async() + switch result { + case let .item(item): + guard let item = item as? PhoneCodeItem? else { return nil } + return item?.country + case .cancel: + return nil + } } private func openHelp() { diff --git a/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/ChoosePhoneCode/ChoosePhoneCodeCoordinator.swift b/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/ChoosePhoneCode/ChoosePhoneCodeCoordinator.swift deleted file mode 100644 index 33518123a4..0000000000 --- a/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/ChoosePhoneCode/ChoosePhoneCodeCoordinator.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Combine -import CountriesAPI -import Foundation -import UIKit - -final class ChoosePhoneCodeCoordinator: Coordinator { - // MARK: - Properties - - let presentingViewController: UIViewController - let selectedDialCode: String? - let selectedCountryCode: String? - - // MARK: - Initializer - - init( - selectedDialCode: String? = nil, - selectedCountryCode: String? = nil, - presentingViewController: UIViewController - ) { - self.presentingViewController = presentingViewController - self.selectedDialCode = selectedDialCode - self.selectedCountryCode = selectedCountryCode - } - - override func start() -> AnyPublisher { - let vm = ChoosePhoneCodeViewModel( - selectedDialCode: selectedDialCode, - selectedCountryCode: selectedCountryCode - ) - let view = ChoosePhoneCodeView(viewModel: vm) - let vc = view.asViewController(withoutUIKitNavBar: false) - vc.isModalInPresentation = true - let nc = UINavigationController(rootViewController: vc) - nc.navigationBar.isTranslucent = false - nc.view.backgroundColor = vc.view.backgroundColor - presentingViewController.present(nc, animated: true) - - vm.didClose - .sink { [weak nc] _ in - nc?.dismiss(animated: true) - } - .store(in: &subscriptions) - - return vm.didClose.withLatestFrom(vm.$data) - .map { $0.first(where: { $0.isSelected })?.value } - .eraseToAnyPublisher() - } -} diff --git a/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/ChoosePhoneCode/ChoosePhoneCodeItemView.swift b/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/ChoosePhoneCode/ChoosePhoneCodeItemView.swift deleted file mode 100644 index ee707d5345..0000000000 --- a/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/ChoosePhoneCode/ChoosePhoneCodeItemView.swift +++ /dev/null @@ -1,43 +0,0 @@ -import KeyAppUI -import SwiftUI - -struct ChoosePhoneCodeItemView: View { - let emoji: String - let countryName: String - let phoneCode: String - let isSelected: Bool - - init(country: SelectableCountry) { - emoji = country.value.emoji ?? "" - countryName = country.value.name - phoneCode = country.value.dialCode - isSelected = country.isSelected - } - - var body: some View { - HStack(spacing: 12) { - Text(emoji) - .frame(width: 28, height: 32) - VStack(alignment: .leading, spacing: 4) { - Text(countryName) - .apply(style: .text3) - .foregroundColor(Color(Asset.Colors.night.color)) - .lineLimit(2) - - if !phoneCode.isEmpty { - Text(phoneCode) - .apply(style: .label1) - .foregroundColor(Color(Asset.Colors.mountain.color)) - .lineLimit(1) - } - } - Spacer() - if isSelected { - Image(uiImage: Asset.MaterialIcon.checkmark.image.withRenderingMode(.alwaysOriginal)) - .frame(width: 14.3, height: 14.19) - } - } - .padding(14) - .background(Color(Asset.Colors.snow.color)) - } -} diff --git a/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/ChoosePhoneCode/ChoosePhoneCodeView.swift b/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/ChoosePhoneCode/ChoosePhoneCodeView.swift deleted file mode 100644 index c8d7d876f2..0000000000 --- a/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/ChoosePhoneCode/ChoosePhoneCodeView.swift +++ /dev/null @@ -1,65 +0,0 @@ -import KeyAppUI -import SwiftUI - -struct ChoosePhoneCodeView: View { - @ObservedObject private var viewModel: ChoosePhoneCodeViewModel - - init(viewModel: ChoosePhoneCodeViewModel) { - self.viewModel = viewModel - } - - var body: some View { - ZStack(alignment: .bottom) { - VStack(spacing: 20) { - SearchField( - searchText: $viewModel.keyword, - isSearchFieldFocused: $viewModel.isSearchFieldFocused - ) - .padding(.horizontal, 16) - .padding(.top, 16) - - WrappedList { - ForEach(viewModel.data) { item in - ZStack(alignment: .bottom) { - ChoosePhoneCodeItemView(country: item) - - // Separator added manually to support iOS 14 version - if viewModel.data.count > 1 && item.id != viewModel.data.last?.id { - Rectangle() - .fill(Color(Asset.Colors.rain.color)) - .frame(height: 1) - .padding(.leading, 20) - } - } - .frame(height: 68) - .onTapGesture { - viewModel.select(country: item) - } - } - } - .modifier(ListBackgroundModifier(separatorColor: Asset.Colors.snow.color)) - .environment(\.defaultMinListRowHeight, 12) - .scrollDismissesKeyboard() - - TextButtonView(title: L10n.ok.uppercased(), style: .primary, size: .large, onPressed: { - viewModel.didClose.send() - }) - .frame(height: TextButton.Size.large.height) - .padding(.horizontal, 20) - } - } - .ignoresSafeArea(.keyboard) - .navigationTitle(L10n.countryCode) - .navigationBarItems( - trailing: - Button(L10n.done, action: viewModel.didClose.send) - .foregroundColor(Color(Asset.Colors.night.color)) - ) - .onAppear { - viewModel.isSearchFieldFocused = true - } - .onDisappear { - viewModel.isSearchFieldFocused = false - } - } -} diff --git a/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/ChoosePhoneCode/ChoosePhoneCodeViewModel.swift b/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/ChoosePhoneCode/ChoosePhoneCodeViewModel.swift deleted file mode 100644 index 0caa4abb72..0000000000 --- a/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/ChoosePhoneCode/ChoosePhoneCodeViewModel.swift +++ /dev/null @@ -1,120 +0,0 @@ -import Combine -import CountriesAPI -import PhoneNumberKit - -final class ChoosePhoneCodeViewModel: BaseViewModel, ObservableObject { - // MARK: - Properties - - private var cachedResult = [SelectableCountry]() - private var initialDialCode: String? - private var initialCountryCode: String? - - @Published var selectedDialCode: String? - @Published var selectedCountryCode: String? - @Published var keyword = "" - @Published var isSearchFieldFocused = false - @Published var data = [SelectableCountry]() - let didClose = PassthroughSubject() - - // MARK: - Initializers - - init(selectedDialCode: String?, selectedCountryCode: String?) { - initialDialCode = selectedDialCode - initialCountryCode = selectedCountryCode - super.init() - Task { - self.data = try await createRequest() - } - bind() - } - - // MARK: - Methods - - func select(country: SelectableCountry) { - guard !country.isSelected, !country.isEmpty else { return } - selectedDialCode = country.value.dialCode - selectedCountryCode = country.value.code - var countries = data - for i in 0 ..< countries.count { - if countries[i].value.dialCode == country.value.dialCode, - countries[i].value.code == country.value.code - { - countries[i].isSelected = true - } else { - countries[i].isSelected = false - } - } - data = countries - } - - private func bind() { - $keyword - .sink { [weak self] keyword in - guard let self = self else { return } - if keyword.isEmpty { - let cachedResult = self.cachedResult - self.data = self.placeInitialIfNeeded(countries: cachedResult) - return - } - var newData = self.cachedResult.filteredAndSorted(byKeyword: keyword) - if newData.isEmpty { - newData.append(self.emptyCountryModel()) - } else { - newData = self.placeInitialIfNeeded(countries: newData) - } - self.data = newData - } - .store(in: &subscriptions) - - var selectedIndex = 0 - $selectedDialCode.sink { [weak self] value in - guard let value = value else { return } - - if let index = (self?.cachedResult - .firstIndex { $0.value.dialCode == self?.initialDialCode && $0.value.code == self?.initialCountryCode - }) - { - self?.cachedResult[index].isSelected = false - } - self?.initialDialCode = nil - - if let index = (self?.cachedResult.firstIndex { $0.value.dialCode == value }) { - self?.cachedResult[selectedIndex].isSelected = false - self?.cachedResult[index].isSelected = true - selectedIndex = index - } - } - .store(in: &subscriptions) - } - - private func createRequest() async throws -> [SelectableCountry] { - let selectedDialCode = selectedDialCode ?? initialDialCode - let selectedCountryCode = selectedCountryCode ?? initialCountryCode - cachedResult = try await CountriesAPIImpl().fetchCountries() - .map { .init(value: $0, isSelected: $0.dialCode == selectedDialCode && $0.code == selectedCountryCode) } - var countries = cachedResult.filteredAndSorted(byKeyword: keyword) - countries = placeInitialIfNeeded(countries: countries) - return countries - } - - private func emptyCountryModel() -> SelectableCountry { - SelectableCountry( - value: Country(name: L10n.sorryWeDonTKnowASuchCountry, code: "", dialCode: "", emoji: "🏴"), - isSelected: false, - isEmpty: true - ) - } - - private func placeInitialIfNeeded(countries: [SelectableCountry]) -> [SelectableCountry] { - var countries = countries.filteredAndSorted() - guard initialDialCode != nil, initialCountryCode != nil else { return countries } - // Put initial selected country in the first place - if let selectedIndex = countries - .firstIndex(where: { $0.value.dialCode == initialDialCode && $0.value.code == initialCountryCode }) - { - let selectedCountry = countries.remove(at: selectedIndex) - countries.insert(selectedCountry, at: .zero) - } - return countries - } -} diff --git a/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/ChoosePhoneCode/SelectableCountry.swift b/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/ChoosePhoneCode/SelectableCountry.swift deleted file mode 100644 index fa324a47f6..0000000000 --- a/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/ChoosePhoneCode/SelectableCountry.swift +++ /dev/null @@ -1,35 +0,0 @@ -import CountriesAPI -import Foundation - -struct SelectableCountry: Hashable, Identifiable { - let value: Country - var isSelected = false - var isEmpty = false - - var id: String { - "\(value.dialCode) \(value.code)" - } -} - -extension Array where Element == SelectableCountry { - func filteredAndSorted(byKeyword keyword: String = "") -> Self { - var countries = self - if !keyword.isEmpty { - let keyword = keyword.lowercased() - countries = countries - .filter { country in - // Filter only countries which name or dialCode starts with keyword - var dialCode = country.value.dialCode - if keyword.first != "+" { - dialCode.removeFirst() - } - return country.value.name.lowercased().starts(with: keyword) || - country.value.dialCode.starts(with: keyword) || - dialCode.starts(with: keyword) || - country.value.name.lowercased().split(separator: " ") - .map { $0.starts(with: keyword) }.contains(true) - } - } - return countries.sorted(by: { $0.value.name < $1.value.name }) - } -} diff --git a/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/EnterPhoneNumberViewModel.swift b/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/EnterPhoneNumberViewModel.swift index e61ab18c47..f2521c5740 100644 --- a/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/EnterPhoneNumberViewModel.swift +++ b/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/EnterPhoneNumberViewModel.swift @@ -13,7 +13,8 @@ final class EnterPhoneNumberViewModel: BaseOTPViewModel { name: L10n.sorryWeDonTKnowASuchCountry, code: "", dialCode: "", - emoji: "🏴" + emoji: "🏴", + alpha3Code: "" ) private var cancellable = Set() @@ -38,7 +39,7 @@ final class EnterPhoneNumberViewModel: BaseOTPViewModel { @Published var isButtonEnabled: Bool = false @Published var isLoading: Bool = false @Published var inputError: String? - @Published var selectedCountry: Country = EnterPhoneNumberViewModel.defaultCountry + @Published var selectedCountry = EnterPhoneNumberViewModel.defaultCountry @Published var subtitle: String = L10n.addAPhoneNumberToProtectYourAccount let isBackAvailable: Bool @@ -60,7 +61,7 @@ final class EnterPhoneNumberViewModel: BaseOTPViewModel { } func selectCountryTap() { - coordinatorIO.selectCode.send((selectedCountry.dialCode, selectedCountry.code)) + coordinatorIO.selectCode.send(selectedCountry) } func onPaste() { @@ -95,7 +96,7 @@ final class EnterPhoneNumberViewModel: BaseOTPViewModel { var error: PassthroughSubject = .init() var countrySelected: PassthroughSubject = .init() // Output - var selectCode: PassthroughSubject<(String?, String?), Never> = .init() + var selectCode: PassthroughSubject = .init() var phoneEntered: PassthroughSubject = .init() let helpClicked = PassthroughSubject() let back: PassthroughSubject = .init() @@ -112,7 +113,7 @@ final class EnterPhoneNumberViewModel: BaseOTPViewModel { Task { let countries = try await countriesAPI.fetchCountries() - let defaultRegionCode = self.defaultRegionCode() + let defaultRegionCode = countriesAPI.defaultRegionCode() if let phone = phone { // In case we have an initial phone number let parsedRegion = try? self.phoneNumberKit.parse(phone).regionID?.lowercased() diff --git a/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/EnterSMSCode/EnterSMSCodeViewController.swift b/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/EnterSMSCode/EnterSMSCodeViewController.swift index fb7eaa450a..2eb86332d6 100644 --- a/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/EnterSMSCode/EnterSMSCodeViewController.swift +++ b/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/EnterSMSCode/EnterSMSCodeViewController.swift @@ -54,7 +54,7 @@ final class EnterSMSCodeViewController: BaseOTPViewController { BEContainer { UILabel(numberOfLines: 0).withAttributedText( .attributedString( - with: L10n.theCodeFromSMS, + with: viewModel.title, of: .title1, weight: .bold, alignment: .center @@ -63,17 +63,23 @@ final class EnterSMSCodeViewController: BaseOTPViewController { UIView(height: 10) UILabel().withAttributedText( .attributedString( - with: "Check the number \(self.viewModel.phone)", + with: "\(viewModel.phoneText) \(self.viewModel.phone)", of: .text1 ).withForegroundColor(Asset.Colors.night.color) - ) + ).setup { label in + label.numberOfLines = 0 + label.textAlignment = .center + } - BaseTextFieldView(leftView: BEView(width: 7), rightView: nil, isBig: true).bind(smsInputRef) + BaseTextFieldView(leftView: BEView(width: 7), rightView: nil, isBig: true) + .bind(smsInputRef) .setup { input in input.textField?.keyboardType = .numberPad - input.constantPlaceholder = "••• •••" + input.constantPlaceholder = "000 000" input.textField?.textContentType = .oneTimeCode - }.frame(width: 180).padding(.init(only: .top, inset: 28)) + } + .frame(width: 180) + .padding(.init(only: .top, inset: 28)) BEHStack { UIButton().bind(resendButtonRef).setup { _ in } @@ -123,14 +129,14 @@ final class EnterSMSCodeViewController: BaseOTPViewController { .assignWeak(to: \.text, on: textField) .store(in: &store) - textField.textPublisher.map { $0 ?? "" } + textField.textPublisher + .compactMap { $0 ?? "" } .assignWeak(to: \.code, on: viewModel) .store(in: &store) } viewModel.$isLoading.sink { [weak self] isLoading in self?.continueButtonRef.view?.isLoading = isLoading - self?.smsInputRef.view?.textField?.isEnabled = !isLoading }.store(in: &store) viewModel.$codeError.sink { [weak self] error in diff --git a/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/EnterSMSCode/EnterSMSCodeViewModel.swift b/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/EnterSMSCode/EnterSMSCodeViewModel.swift index 08bc07bb7a..8c345fb170 100644 --- a/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/EnterSMSCode/EnterSMSCodeViewModel.swift +++ b/p2p_wallet/Scenes/Web3Auth/Onboarding/CreateWallet/Steps/EnterPhoneNumber/EnterSMSCode/EnterSMSCodeViewModel.swift @@ -12,9 +12,8 @@ import SwiftyUserDefaults final class EnterSMSCodeViewModel: BaseOTPViewModel { // MARK: - - private var cancellable = Set() + var attemptCounter: Wrapper - private let attemptCounter: Wrapper private var countdown: Int private static let codeLength = 6 private let strategy: Strategy @@ -40,6 +39,13 @@ final class EnterSMSCodeViewModel: BaseOTPViewModel { @Published var resendEnabled: Bool = false @Published var resendText: String = "" @Published var isButtonEnabled: Bool = false + var phoneText: String { + strategy == .striga ? L10n.weHaveSentACodeTo : L10n.checkTheNumber + } + + var title: String { + strategy == .striga ? L10n.enterConfirmationCode : L10n.theCodeFromSMS + } func buttonTaped() { guard !isLoading, reachability.check() else { return } @@ -88,7 +94,11 @@ final class EnterSMSCodeViewModel: BaseOTPViewModel { var coordinatorIO: CoordinatorIO = .init() - private var timer: Timer? + private weak var timer: Timer? + + deinit { + timer?.invalidate() + } init(phone: String, attemptCounter: Wrapper, strategy: Strategy) { self.phone = phone @@ -114,6 +124,8 @@ final class EnterSMSCodeViewModel: BaseOTPViewModel { analyticsManager.log(event: .createSmsScreen) case .restore: analyticsManager.log(event: .restoreSmsScreen) + case .striga: + break } } @@ -121,8 +133,8 @@ final class EnterSMSCodeViewModel: BaseOTPViewModel { coordinatorIO .error .receive(on: RunLoop.main) - .sinkAsync { error in - if let serviceError = error as? APIGatewayError { + .sinkAsync { [weak self] error in + if let self, let serviceError = error as? APIGatewayError { switch serviceError { case .invalidOTP: self.showCodeError(error: EnterSMSCodeViewModelError.incorrectCode) @@ -131,23 +143,25 @@ final class EnterSMSCodeViewModel: BaseOTPViewModel { default: self.showError(error: error) } + } else if (error as? NSError)?.isNetworkConnectionError == true { + self?.notificationService.showConnectionErrorNotification() } else if error is UndefinedAPIGatewayError { - self.notificationService.showDefaultErrorNotification() + self?.notificationService.showDefaultErrorNotification() } else { - self.showError(error: error) + self?.showError(error: error) } } .store(in: &subscriptions) $code.removeDuplicates() .debounce(for: 0.0, scheduler: DispatchQueue.main) + .map { Self.format(code: $0) } .handleEvents(receiveOutput: { [weak self] aCode in self?.showCodeError(error: nil) self?.rawCode = Self.prepareRawCode(code: aCode) }) - .map { Self.format(code: $0) } .assignWeak(to: \.code, on: self) - .store(in: &cancellable) + .store(in: &subscriptions) } @MainActor @@ -165,15 +179,16 @@ final class EnterSMSCodeViewModel: BaseOTPViewModel { // MARK: - private func startTimer() { - timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in - DispatchQueue.main.async { [weak self] in - self?.countdown -= 1 - self?.setResendCountdown() - - if self?.countdown == 0 { - self?.timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in + Task { + await MainActor.run { [weak self] in + self?.countdown -= 1 self?.setResendCountdown() - return + if self?.countdown == 0 { + self?.timer?.invalidate() + self?.setResendCountdown() + return + } } } } @@ -182,7 +197,7 @@ final class EnterSMSCodeViewModel: BaseOTPViewModel { private func setResendCountdown() { let secs = countdown <= 0 ? "" : " \(countdown) sec" - resendText = " Tap to resend\(secs)" + resendText = secs.isEmpty ? L10n.tapToResend : L10n.resendSMS(secs) updateResendEnabled() } @@ -204,12 +219,6 @@ final class EnterSMSCodeViewModel: BaseOTPViewModel { } } -extension Substring { - func asString() -> String { - String(self) - } -} - extension EnterSMSCodeViewModel { struct CoordinatorIO { let error: PassthroughSubject = .init() @@ -238,5 +247,12 @@ extension EnterSMSCodeViewModel { enum Strategy { case create case restore + case striga + } +} + +private extension Substring { + func asString() -> String { + String(self) } } diff --git a/p2p_wallet/Scenes/Web3Auth/Onboarding/RestoreWallet/Steps/RestoreCustom/RestoreCustomDelegateCoordinator.swift b/p2p_wallet/Scenes/Web3Auth/Onboarding/RestoreWallet/Steps/RestoreCustom/RestoreCustomDelegateCoordinator.swift index eb4cb22d6d..105fb9ea2a 100644 --- a/p2p_wallet/Scenes/Web3Auth/Onboarding/RestoreWallet/Steps/RestoreCustom/RestoreCustomDelegateCoordinator.swift +++ b/p2p_wallet/Scenes/Web3Auth/Onboarding/RestoreWallet/Steps/RestoreCustom/RestoreCustomDelegateCoordinator.swift @@ -67,14 +67,22 @@ final class RestoreCustomDelegatedCoordinator: DelegatedCoordinator Country? { + private func selectCountry(chosen: Country?) async throws -> Country? { guard let rootViewController = rootViewController else { return nil } - let coordinator = ChoosePhoneCodeCoordinator( - selectedDialCode: selectedDialCode, - selectedCountryCode: selectedCountryCode, - presentingViewController: rootViewController + let coordinator = ChooseItemCoordinator( + title: L10n.selectYourCountry, + controller: rootViewController, + service: ChoosePhoneCodeService(), + chosen: PhoneCodeItem(country: chosen) ) - return try await coordinator.start().async() + let result = try await coordinator.start().async() + switch result { + case let .item(item): + guard let item = item as? PhoneCodeItem? else { return nil } + return item?.country + case .cancel: + return nil + } } private func weCanTSMSYouContent(phone: String, code: Int) -> OnboardingContentData { @@ -100,11 +108,8 @@ private extension RestoreCustomDelegatedCoordinator { viewModel.subtitle = L10n.addAPhoneNumberToRestoreYourAccount let viewController = EnterPhoneNumberViewController(viewModel: viewModel) - viewModel.coordinatorIO.selectCode.sinkAsync { [weak self] dialCode, countryCode in - guard let result = try await self?.selectCountry( - selectedDialCode: dialCode, - selectedCountryCode: countryCode - ) else { return } + viewModel.coordinatorIO.selectCode.sinkAsync { [weak self] country in + guard let result = try await self?.selectCountry(chosen: country) else { return } viewModel.coordinatorIO.countrySelected.send(result) }.store(in: &subscriptions) diff --git a/p2p_wallet/UI/SwiftUI/CheckboxView.swift b/p2p_wallet/UI/SwiftUI/CheckboxView.swift new file mode 100644 index 0000000000..c6c3d9c210 --- /dev/null +++ b/p2p_wallet/UI/SwiftUI/CheckboxView.swift @@ -0,0 +1,16 @@ +import SwiftUI + +struct CheckboxView: View { + @Binding var isChecked: Bool + var body: some View { + Button { + isChecked.toggle() + } label: { + if isChecked { + Image(uiImage: .checkboxFill) + } else { + Image(uiImage: .checkboxEmpty) + } + } + } +} diff --git a/p2p_wallet/UI/SwiftUI/FocusedTexts/BigInputView.swift b/p2p_wallet/UI/SwiftUI/FocusedTexts/BigInputView.swift new file mode 100644 index 0000000000..8303e8cc40 --- /dev/null +++ b/p2p_wallet/UI/SwiftUI/FocusedTexts/BigInputView.swift @@ -0,0 +1,189 @@ +import KeyAppUI +import SwiftUI + +struct BigInputView: View { + let allButtonPressed: (() -> Void)? + let amountFieldTap: (() -> Void)? + let changeTokenPressed: (() -> Void)? + let accessibilityIdPrefix: String + let title: String + let isBalanceVisible: Bool + + @Binding var amount: Double? + @Binding var amountTextColor: UIColor + @Binding var isFirstResponder: Bool + @Binding var decimalLength: Int + @Binding var isEditable: Bool + @Binding var balance: Double? + @Binding var balanceText: String + @Binding var tokenSymbol: String + @Binding var isLoading: Bool + @Binding var isAmountLoading: Bool + @Binding var fiatAmount: Double? + @Binding var fiatAmountTextColor: UIColor + + init( + allButtonPressed: (() -> Void)?, + amountFieldTap: (() -> Void)?, + changeTokenPressed: (() -> Void)?, + accessibilityIdPrefix: String, + title: String, + isBalanceVisible: Bool = true, + amount: Binding, + amountTextColor: Binding = .constant(Asset.Colors.night.color), + isFirstResponder: Binding, + decimalLength: Binding, + isEditable: Binding = .constant(true), + balance: Binding = .constant(nil), + balanceText: Binding = .constant(""), + tokenSymbol: Binding, + isLoading: Binding = .constant(false), + isAmountLoading: Binding = .constant(false), + fiatAmount: Binding = .constant(nil), + fiatAmountTextColor: Binding = .constant(Asset.Colors.silver.color) + ) { + self.allButtonPressed = allButtonPressed + self.amountFieldTap = amountFieldTap + self.changeTokenPressed = changeTokenPressed + self.accessibilityIdPrefix = accessibilityIdPrefix + self.title = title + self.isBalanceVisible = isBalanceVisible + _amount = amount + _amountTextColor = amountTextColor + _isFirstResponder = isFirstResponder + _decimalLength = decimalLength + _isEditable = isEditable + _balance = balance + _balanceText = balanceText + _tokenSymbol = tokenSymbol + _isLoading = isLoading + _isAmountLoading = isAmountLoading + _fiatAmount = fiatAmount + _fiatAmountTextColor = fiatAmountTextColor + } + + var body: some View { + VStack(spacing: 16) { + HStack { + Text(title) + .subtitleStyle() + .accessibilityIdentifier("\(accessibilityIdPrefix)TitleLabel") + + Spacer() + + if isEditable && balance != nil && !isLoading { + allButton + } + } + HStack { + changeTokenButton + .layoutPriority(1) + amountField + } + HStack { + if isBalanceVisible { + balanceLabel + } + + Spacer() + + if let fiatAmount = fiatAmount, !isLoading, fiatAmount > 0 { + Text("≈\(fiatAmount.toString(maximumFractionDigits: 2, roundingMode: .down)) \(Defaults.fiat.code)") + .subtitleStyle(color: Color(fiatAmountTextColor)) + .lineLimit(1) + .accessibilityIdentifier("\(accessibilityIdPrefix)FiatLabel") + } + } + .frame(minHeight: 16) + } + .padding(EdgeInsets(top: 12, leading: 16, bottom: 16, trailing: 12)) + .background( + RoundedRectangle(cornerRadius: 16) + .foregroundColor(Color(Asset.Colors.snow.color).opacity(isEditable ? 1 : 0.6)) + ) + } +} + +private extension BigInputView { + var allButton: some View { + Button(action: { allButtonPressed?() }, label: { + HStack(spacing: 4) { + Text(L10n.all.uppercaseFirst) + .subtitleStyle() + Text("\(balanceText) \(tokenSymbol)") + .apply(style: .label1) + .foregroundColor(Color(Asset.Colors.sky.color)) + } + }) + .accessibilityIdentifier("\(accessibilityIdPrefix)AllButton") + } + + var changeTokenButton: some View { + Button { + changeTokenPressed?() + } label: { + HStack { + Text(tokenSymbol) + .apply(style: .title1) + .foregroundColor(Color(Asset.Colors.night.color)) + .if(isLoading) { view in + view.skeleton(with: true, size: CGSize(width: 84, height: 20)) + } + + if changeTokenPressed != nil { + Image(uiImage: .expandIcon) + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12) + .foregroundColor(Color(Asset.Colors.night.color)) + } + } + } + .allowsHitTesting(!isLoading) + .accessibilityIdentifier("\(accessibilityIdPrefix)TokenButton") + } + + var amountField: some View { + AmountTextField( + value: $amount, + isFirstResponder: $isFirstResponder, + textColor: $amountTextColor, + maxFractionDigits: $decimalLength, + moveCursorToTrailingWhenDidBeginEditing: true + ) { textField in + textField.font = .font(of: .title1) + textField.isEnabled = isEditable + textField.placeholder = "0" + textField.adjustsFontSizeToFitWidth = true + textField.textAlignment = .right + } + .frame(maxWidth: .infinity) + .accessibilityIdentifier("\(accessibilityIdPrefix)Input") + .if(isLoading || isAmountLoading) { view in + HStack { + Spacer() + view.skeleton(with: true, size: CGSize(width: 84, height: 20)) + } + } + .if(!isEditable) { view in + view.onTapGesture { amountFieldTap?() } + } + .frame(height: 32) + } + + var balanceLabel: some View { + Text("\(L10n.balance) \(balanceText)") + .subtitleStyle() + .if(isLoading) { view in + view.skeleton(with: true, size: CGSize(width: 84, height: 8)) + } + .accessibilityIdentifier("\(accessibilityIdPrefix)BalanceLabel") + } +} + +private extension Text { + func subtitleStyle(color: Color = Color(Asset.Colors.silver.color)) -> some View { + apply(style: .label1).foregroundColor(color) + } +} diff --git a/p2p_wallet/UI/SwiftUI/FocusedTexts/SearchField.swift b/p2p_wallet/UI/SwiftUI/FocusedTexts/SearchField.swift index 0dc80b9a19..4cec3fb550 100644 --- a/p2p_wallet/UI/SwiftUI/FocusedTexts/SearchField.swift +++ b/p2p_wallet/UI/SwiftUI/FocusedTexts/SearchField.swift @@ -3,22 +3,37 @@ import SwiftUI struct SearchField: View { @Binding var searchText: String - @Binding var isSearchFieldFocused: Bool + @FocusState var isFocused: Bool var body: some View { - HStack(spacing: .zero) { - FocusedTextField( - text: $searchText, - isFirstResponder: $isSearchFieldFocused - ) { searchField in - searchField.returnKeyType = .done - searchField.autocorrectionType = .no - searchField.spellCheckingType = .no - searchField.placeholder = L10n.search - searchField.textColor = Asset.Colors.night.color + HStack(spacing: 6) { + Image(uiImage: Asset.MaterialIcon.magnifyingGlass.image) + .resizable() + .renderingMode(.template) + .foregroundColor(.gray) + .frame(width: 15, height: 15) + + TextField(L10n.search, text: $searchText) + .foregroundColor(Color(Asset.Colors.night.color)) + .focused($isFocused) + .frame(height: 38) + .submitLabel(.done) + .onSubmit { + isFocused = false + } + + if !searchText.isEmpty { + Button(action: { searchText = "" }) { + Image(uiImage: .crossIcon) + .resizable() + .renderingMode(.template) + .foregroundColor(.gray) + .frame(width: 14, height: 14) + } } } + .padding(.horizontal, 8) + .background(Color(UIColor.h767680)) .cornerRadius(10) - .frame(height: 38) } } diff --git a/p2p_wallet/UI/SwiftUI/HardErrorView.swift b/p2p_wallet/UI/SwiftUI/HardErrorView.swift new file mode 100644 index 0000000000..5fc46c789c --- /dev/null +++ b/p2p_wallet/UI/SwiftUI/HardErrorView.swift @@ -0,0 +1,52 @@ +import KeyAppUI +import SwiftUI + +/// An error view with image :) +struct HardErrorView: View { + let title: String + let subtitle: String + var image: UIImage = .womanHardError + @ViewBuilder var content: () -> Content + + var body: some View { + VStack { + Spacer() + OnboardingContentView( + data: .init( + image: image, + title: title, + subtitle: subtitle + ) + ) + .padding(.bottom, 48) + + BottomActionContainer { + content() + } + } + .hardErrorScreen() + } +} + +private extension View { + func hardErrorScreen() -> some View { + background(Color(Asset.Colors.smoke.color)) + .edgesIgnoringSafeArea(.all) + .frame(maxHeight: .infinity) + } +} + +struct HardErrorView_Previews: PreviewProvider { + static var previews: some View { + HardErrorView( + title: "Title", + subtitle: "Subtitle" + ) { + VStack { + Text("1") + Text("2") + Text("3") + } + } + } +} diff --git a/p2p_wallet/UI/SwiftUI/List/ListBackgroundModifier.swift b/p2p_wallet/UI/SwiftUI/List/ListBackgroundModifier.swift index 4beca7179b..8362af1993 100644 --- a/p2p_wallet/UI/SwiftUI/List/ListBackgroundModifier.swift +++ b/p2p_wallet/UI/SwiftUI/List/ListBackgroundModifier.swift @@ -13,11 +13,10 @@ struct ListBackgroundModifier: ViewModifier { .listRowSeparatorTint(Color(separatorColor)) } else { content - .onAppear { - UITableView.appearance().backgroundColor = .clear - UITableViewCell.appearance().backgroundColor = .clear - UITableView.appearance().separatorColor = separatorColor - } + .introspectTableView(customize: { view in + view.backgroundColor = .clear + view.separatorColor = separatorColor + }) } } } diff --git a/p2p_wallet/UI/SwiftUI/Renderable/BaseInformerView.swift b/p2p_wallet/UI/SwiftUI/Renderable/BaseInformerView.swift new file mode 100644 index 0000000000..acb2628d07 --- /dev/null +++ b/p2p_wallet/UI/SwiftUI/Renderable/BaseInformerView.swift @@ -0,0 +1,80 @@ +import KeyAppUI +import SwiftUI + +struct BaseInformerViewItem: Identifiable { + let id = UUID().uuidString + + let icon: UIImage + let iconColor: ColorAsset + let title: String + let titleColor: ColorAsset + let backgroundColor: ColorAsset + let iconBackgroundColor: ColorAsset + + init( + icon: UIImage, + iconColor: ColorAsset, + title: String, + titleColor: ColorAsset = Asset.Colors.night, + backgroundColor: ColorAsset, + iconBackgroundColor: ColorAsset + ) { + self.icon = icon + self.iconColor = iconColor + self.title = title + self.titleColor = titleColor + self.backgroundColor = backgroundColor + self.iconBackgroundColor = iconBackgroundColor + } +} + +// MARK: - Renderable + +extension BaseInformerViewItem: Renderable { + func render() -> some View { + BaseInformerView(data: self) + } +} + +// MARK: - View + +struct BaseInformerView: View { + let data: BaseInformerViewItem + + var body: some View { + HStack(spacing: 16) { + Circle() + .fill(Color(asset: data.iconBackgroundColor)) + .frame(width: 48, height: 48) + .overlay( + Image(uiImage: data.icon) + .resizable() + .renderingMode(.template) + .aspectRatio(contentMode: .fill) + .foregroundColor(Color(asset: data.iconColor)) + .frame(width: 20, height: 20) + ) + + Text(data.title) + .apply(style: .text4) + .foregroundColor(Color(asset: data.titleColor)) + + Spacer() + } + .padding(.all, 17) + .background(Color(asset: data.backgroundColor)) + .cornerRadius(radius: 12, corners: .allCorners) + } +} + +struct BaseInfoView_Previews: PreviewProvider { + static var previews: some View { + BaseInformerView(data: BaseInformerViewItem( + icon: .infoFill, + iconColor: Asset.Colors.snow, + title: L10n.weUseSEPAInstantForBankTransfersAndTypicallyMoneyWillAppearInYourAccountInLessThanAMinute, + backgroundColor: Asset.Colors.lightSea, + iconBackgroundColor: Asset.Colors.sea + )) + } +} diff --git a/p2p_wallet/UI/SwiftUI/Renderable/ButtonListCellView.swift b/p2p_wallet/UI/SwiftUI/Renderable/ButtonListCellView.swift new file mode 100644 index 0000000000..969c76b2a5 --- /dev/null +++ b/p2p_wallet/UI/SwiftUI/Renderable/ButtonListCellView.swift @@ -0,0 +1,26 @@ +import KeyAppUI +import SwiftUI + +struct ButtonListCellItem: Identifiable { + var id = UUID().uuidString + let leadingImage: UIImage? + let title: String + let action: () -> Void + let style: TextButton.Style + let trailingImage: UIImage? + let horizontalPadding = CGFloat(4) +} + +extension ButtonListCellItem: Renderable { + func render() -> some View { + NewTextButton( + title: title, + style: style, + expandable: true, + leading: leadingImage, + trailing: trailingImage, + action: action + ) + .padding(.horizontal, horizontalPadding) + } +} diff --git a/p2p_wallet/UI/SwiftUI/Renderable/EmojiTitleCellView.swift b/p2p_wallet/UI/SwiftUI/Renderable/EmojiTitleCellView.swift new file mode 100644 index 0000000000..3228f0fd53 --- /dev/null +++ b/p2p_wallet/UI/SwiftUI/Renderable/EmojiTitleCellView.swift @@ -0,0 +1,46 @@ +import KeyAppUI +import SwiftUI + +struct EmojiTitleCellViewItem: Identifiable { + let id = UUID().uuidString + let emoji: String + let name: String + let subtitle: String? +} + +extension EmojiTitleCellViewItem: Renderable { + func render() -> some View { + EmojiTitleCellView(emoji: emoji, name: name, subtitle: subtitle) + } +} + +struct EmojiTitleCellView: View { + let emoji: String + let name: String + let subtitle: String? + + var body: some View { + HStack(spacing: 10) { + Text(emoji) + .font(uiFont: .font(of: .title1, weight: .bold)) + VStack(alignment: .leading, spacing: 4) { + Text(name) + .foregroundColor(Color(Asset.Colors.night.color)) + .apply(style: .text3) + if let subtitle { + Text(subtitle) + .foregroundColor(Color(Asset.Colors.mountain.color)) + .apply(style: .label1) + } + } + Spacer() + } + .padding(.vertical, 6) + } +} + +struct EmojiTitleCellView_Previews: PreviewProvider { + static var previews: some View { + EmojiTitleCellView(emoji: "🇫🇷", name: "France", subtitle: nil) + } +} diff --git a/p2p_wallet/UI/SwiftUI/Renderable/FinanceBlockView.swift b/p2p_wallet/UI/SwiftUI/Renderable/FinanceBlockView.swift new file mode 100644 index 0000000000..18ebc0eb5f --- /dev/null +++ b/p2p_wallet/UI/SwiftUI/Renderable/FinanceBlockView.swift @@ -0,0 +1,167 @@ +import KeyAppUI +import SwiftUI + +struct FinanceBlockView: View { + let leadingItem: any Renderable + let centerItem: any Renderable + let trailingItem: any Renderable + + var leadingView: some View { + AnyView(leadingItem.render()) + } + + var centerView: some View { + AnyView(centerItem.render()) + } + + var trailingView: some View { + AnyView(trailingItem.render()) + } + + var body: some View { + HStack(spacing: 0) { + HStack(spacing: 12) { + leadingView + centerView + } + .padding(.leading, 16) + Spacer() + trailingView + .padding(.trailing, 16) + } + .padding(.vertical, 12) + .background(.white) + } +} + +struct FinanceBlockView_Previews: PreviewProvider { + static var previews: some View { + FinanceBlockView( + leadingItem: FinanceBlockLeadingItem( + image: .image(.iconUpload), + iconSize: CGSize(width: 50, height: 50), + isWrapped: false + ), + centerItem: FinancialBlockCenterItem( + title: "renderable.title", + subtitle: "renderable.subtitle" + ), + trailingItem: ListSpacerCellViewItem(height: 0, backgroundColor: .clear) + ) + } +} + +// MARK: - Leading + +struct FinanceBlockLeadingItem: Renderable { + typealias ViewType = FinanceBlockLeadingView + var id: String = UUID().uuidString + + // TODO: Get rid of AccountIcon + var image: AccountIcon + var iconSize: CGSize + var isWrapped: Bool + + func render() -> FinanceBlockLeadingView { + FinanceBlockLeadingView(item: self) + } +} + +struct FinanceBlockLeadingView: View { + let item: FinanceBlockLeadingItem + + var body: some View { + var anURL: URL? + var aSeed: String? + var anImage: UIImage? + switch item.image { + case let .url(url): + anURL = url + case let .image(image): + anImage = image + case let .random(seed): + aSeed = seed + } + return CoinLogoImageViewRepresentable( + size: item.iconSize.width, + args: .manual( + preferredImage: anImage, + url: anURL, + key: aSeed ?? "", + wrapped: item.isWrapped + ) + ) + .frame(width: item.iconSize.width, height: item.iconSize.height) + } +} + +// MARK: - Center + +struct FinancialBlockCenterItem: Renderable { + typealias ViewType = FinancialBlockCenterView + var id: String = UUID().uuidString + + var title: String? + var subtitle: String? + + func render() -> FinancialBlockCenterView { + FinancialBlockCenterView(item: self) + } +} + +struct FinancialBlockCenterView: View { + let item: FinancialBlockCenterItem + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + if let title = item.title { + Text(title) + .apply(style: .text2) + .foregroundColor(Color(Asset.Colors.night.color)) + } + if let subtitle = item.subtitle { + Text(subtitle) + .apply(style: .label1) + .foregroundColor(Color(Asset.Colors.mountain.color)) + } + } + } +} + +// MARK: - Trailing + +struct FinancialBlockTrailingItem: Renderable { + typealias ViewType = FinancialBlockTrailingView + + var id: String = UUID().uuidString + var isLoading: Bool + var detail: AccountDetail + var onButtonTap: (() -> Void)? + + func render() -> FinancialBlockTrailingView { + FinancialBlockTrailingView(item: self) + } +} + +struct FinancialBlockTrailingView: View { + let item: FinancialBlockTrailingItem + + var body: some View { + switch item.detail { + case let .text(text): + Text(text) + .fontWeight(.semibold) + .apply(style: .text3) + .foregroundColor(Color(Asset.Colors.night.color)) + case let .button(text, enabled): + NewTextButton( + title: text, + size: .small, + style: .primaryWhite, + isEnabled: enabled, + isLoading: item.isLoading, + action: { item.onButtonTap?() } + ) + } + } +} diff --git a/p2p_wallet/UI/SwiftUI/Renderable/ListSpacerCellView.swift b/p2p_wallet/UI/SwiftUI/Renderable/ListSpacerCellView.swift new file mode 100644 index 0000000000..6723b55bde --- /dev/null +++ b/p2p_wallet/UI/SwiftUI/Renderable/ListSpacerCellView.swift @@ -0,0 +1,33 @@ +import KeyAppUI +import SwiftUI + +struct ListSpacerCellViewItem: Identifiable { + var id = UUID().uuidString + let height: CGFloat + let backgroundColor: Color + var leadingPadding: CGFloat = .zero +} + +extension ListSpacerCellViewItem: Renderable { + func render() -> some View { + ListSpacerCellView(height: height, backgroundColor: backgroundColor, leadingPadding: leadingPadding) + } +} + +struct ListSpacerCellView: View { + let height: CGFloat + let backgroundColor: Color + var leadingPadding: CGFloat = .zero + + var body: some View { + backgroundColor + .frame(height: height) + .padding(.leading, leadingPadding) + } +} + +struct ListSpacerCellView_Previews: PreviewProvider { + static var previews: some View { + ListSpacerCellView(height: 10, backgroundColor: .blue, leadingPadding: 20) + } +} diff --git a/p2p_wallet/UI/SwiftUI/Renderable/TitleCellView.swift b/p2p_wallet/UI/SwiftUI/Renderable/TitleCellView.swift new file mode 100644 index 0000000000..ebfc49aa55 --- /dev/null +++ b/p2p_wallet/UI/SwiftUI/Renderable/TitleCellView.swift @@ -0,0 +1,34 @@ +import KeyAppUI +import SwiftUI + +struct TitleCellViewItem: Identifiable { + var id = UUID().uuidString + let title: String +} + +extension TitleCellViewItem: Renderable { + func render() -> some View { + TitleCellView(title: title) + } +} + +struct TitleCellView: View { + let title: String + + var body: some View { + HStack { + Text(title) + .foregroundColor(Color(asset: Asset.Colors.night)) + .apply(style: .text3) + .padding(.vertical, 6) + + Spacer() + } + } +} + +struct TitleCellView_Previews: PreviewProvider { + static var previews: some View { + TitleCellView(title: "France") + } +} diff --git a/p2p_wallet/UI/SwiftUI/Spinner/Spinner.swift b/p2p_wallet/UI/SwiftUI/Spinner/Spinner.swift index 29e91aebb6..0743a7c178 100644 --- a/p2p_wallet/UI/SwiftUI/Spinner/Spinner.swift +++ b/p2p_wallet/UI/SwiftUI/Spinner/Spinner.swift @@ -1,14 +1,20 @@ +import KeyAppUI import SwiftUI struct Spinner: View { + let color: Color + let activePartColor: Color + let rotationTime: Double = 0.75 static let initialDegree: Angle = .degrees(270) - let color: Color = .init(.lightSea) - let activePartColor: Color = .init(.sea) - @State var rotationDegree = initialDegree + init(color: Color = Color(asset: Asset.Colors.lightSea), activePartColor: Color = Color(asset: Asset.Colors.sea)) { + self.color = color + self.activePartColor = activePartColor + } + var body: some View { ZStack { SpinnerCircle(start: 0, end: 1, rotation: Angle.degrees(0), color: color) diff --git a/p2p_wallet/UI/SwiftUI/TextField/AmountTextField.swift b/p2p_wallet/UI/SwiftUI/TextField/AmountTextField.swift index faf0431ad7..d9b02e9eec 100644 --- a/p2p_wallet/UI/SwiftUI/TextField/AmountTextField.swift +++ b/p2p_wallet/UI/SwiftUI/TextField/AmountTextField.swift @@ -174,10 +174,11 @@ final class AmountUITextField: UITextField, UITextFieldDelegate { let updatedText = text .replacingCharacters(in: textRange, with: string) .replacingOccurrences(of: decimalSeparator == "." ? "," : ".", with: decimalSeparator) - if (updatedText.components(separatedBy: decimalSeparator).count - 1) > 1 { + let components = updatedText.components(separatedBy: decimalSeparator) + if components.count - 1 > 1 { return false } - if updatedText.components(separatedBy: decimalSeparator).last?.count ?? 0 > maxFractionDigits.wrappedValue { + if components.count == 2 && components.last?.count ?? 0 > maxFractionDigits.wrappedValue { return false } return isNotMoreThanMax(text: updatedText.amountFormat( diff --git a/p2p_wallet/UI/SwiftUI/TransactionProcessView/TransactionProcessView.swift b/p2p_wallet/UI/SwiftUI/TransactionProcessView/TransactionProcessView.swift index 9c3bd037f8..2db174e961 100644 --- a/p2p_wallet/UI/SwiftUI/TransactionProcessView/TransactionProcessView.swift +++ b/p2p_wallet/UI/SwiftUI/TransactionProcessView/TransactionProcessView.swift @@ -116,8 +116,8 @@ extension TransactionProcessView { case .succeed: image = .lightningFilled imageSize = CGSize(width: 24, height: 24) - backgroundColor = Color(.cdf6cd).opacity(0.3) - circleColor = Color(.cdf6cd) + backgroundColor = Color(Asset.Colors.lightGrass.color).opacity(0.3) + circleColor = Color(Asset.Colors.lightGrass.color) imageColor = Color(.h04D004) case .error: image = .solendSubtract diff --git a/p2p_wallet/UI/UIKit/BottomSheetController/BottomSheetController.swift b/p2p_wallet/UI/UIKit/BottomSheetController/BottomSheetController.swift index e2ae8308ca..bf0c0c8d39 100644 --- a/p2p_wallet/UI/UIKit/BottomSheetController/BottomSheetController.swift +++ b/p2p_wallet/UI/UIKit/BottomSheetController/BottomSheetController.swift @@ -3,23 +3,41 @@ import SwiftUI import UIKit struct ModalView: View { + let showHandler: Bool let content: Content + let title: String? - init(@ViewBuilder content: () -> Content) { + init(title: String? = nil, showHandler: Bool = false, @ViewBuilder content: () -> Content) { + self.title = title + self.showHandler = showHandler self.content = content() } var body: some View { VStack(spacing: .zero) { + if showHandler { + RoundedRectangle(cornerRadius: 2, style: .circular) + .fill(Color(Asset.Colors.silver.color)) + .frame(width: 31, height: 4) + .padding(.top, 6) + } + if let title { + Text(title) + .fontWeight(.semibold) + .apply(style: .text1) + .padding(.top, 18) + .padding(.bottom, 28) + } content Spacer() } + .edgesIgnoringSafeArea(.all) } } class BottomSheetController: UIHostingController> { - @MainActor init(title _: String? = nil, showHandler _: Bool = true, rootView: Content) { - super.init(rootView: ModalView { rootView }) + @MainActor init(title: String? = nil, showHandler: Bool = false, rootView: Content) { + super.init(rootView: ModalView(title: title, showHandler: showHandler) { rootView }) } @available(*, unavailable) diff --git a/p2p_wallet/UI/UIKit/ViewControllers/KeyboardAvoidingViewController.swift b/p2p_wallet/UI/UIKit/ViewControllers/KeyboardAvoidingViewController.swift index 4b0db1f440..b33cded464 100644 --- a/p2p_wallet/UI/UIKit/ViewControllers/KeyboardAvoidingViewController.swift +++ b/p2p_wallet/UI/UIKit/ViewControllers/KeyboardAvoidingViewController.swift @@ -2,6 +2,9 @@ import Combine import SwiftUI import UIKit +#warning( + "Think carefully before using this VC as it leads to a lot of keyboard bugs. Use UIHostingController when it is possible" +) /// A view controller that embeds a SwiftUI view and controls Keyboard final class KeyboardAvoidingViewController: UIViewController { enum NavigationBarVisibility { diff --git a/p2p_wallet/p2p_wallet-Bridging-Header.h b/p2p_wallet/p2p_wallet-Bridging-Header.h new file mode 100644 index 0000000000..506b0e76f2 --- /dev/null +++ b/p2p_wallet/p2p_wallet-Bridging-Header.h @@ -0,0 +1,9 @@ +// + +#ifndef p2p_wallet_Bridging_Header_h +#define p2p_wallet_Bridging_Header_h + + +#endif /* p2p_wallet_Bridging_Header_h */ + +#include diff --git a/p2p_wallet/p2p_wallet.entitlements b/p2p_wallet/p2p_wallet.entitlements index 04870ebc53..cdadc46458 100644 --- a/p2p_wallet/p2p_wallet.entitlements +++ b/p2p_wallet/p2p_wallet.entitlements @@ -16,6 +16,10 @@ com.apple.developer.icloud-container-identifiers + com.apple.developer.nfc.readersession.formats + + TAG + com.apple.developer.ubiquity-kvstore-identifier $(TeamIdentifierPrefix)$(CFBundleIdentifier) com.apple.security.app-sandbox diff --git a/project.yml b/project.yml index 9f2ca6e068..13443562fd 100644 --- a/project.yml +++ b/project.yml @@ -59,6 +59,9 @@ packages: Firebase: url: https://github.com/firebase/firebase-ios-sdk.git from: 10.7.0 + IdensicMobileSDK: + url: https://github.com/SumSubstance/IdensicMobileSDK-iOS.git + from: 1.24.0 options: @@ -209,6 +212,7 @@ targets: - "\"CFNetwork\"" PRODUCT_NAME: "$(TARGET_NAME)" DEBUG_INFORMATION_FORMAT: "dwarf-with-dsym" + SWIFT_OBJC_BRIDGING_HEADER: "$(SRCROOT)/$(PROJECT_NAME)/$(SWIFT_MODULE_NAME)-Bridging-Header.h" configs: Debug: PRODUCT_BUNDLE_IDENTIFIER: org.p2p.cyber.test @@ -250,6 +254,10 @@ targets: product: Wormhole - package: KeyAppKit product: Jupiter + - package: KeyAppKit + product: KeyAppNetworking + - package: KeyAppKit + product: BankTransfer - package: KeyAppUI - package: BEPureLayout @@ -291,5 +299,10 @@ targets: - package: Firebase product: FirebaseRemoteConfig + - package: IdensicMobileSDK + product: IdensicMobileSDK + - package: IdensicMobileSDK + product: IdensicMobileSDK_MRTDReader + - framework: Frameworks/keyapp.xcframework embed: false