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("") || entity.hasPrefix("") {
+ 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