diff --git a/MobileWallet.xcodeproj/project.pbxproj b/MobileWallet.xcodeproj/project.pbxproj index b6ead086..411f8f5c 100644 --- a/MobileWallet.xcodeproj/project.pbxproj +++ b/MobileWallet.xcodeproj/project.pbxproj @@ -534,6 +534,8 @@ F8472F6B2E69BBCB0094163D /* SendingTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8472F6A2E69BBCB0094163D /* SendingTransaction.swift */; }; F8472F6D2E744AFF0094163D /* SendingTransaction+Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8472F6C2E744AFA0094163D /* SendingTransaction+Actions.swift */; }; F8472F722E745A3E0094163D /* ProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8472F712E745A3D0094163D /* ProgressIndicator.swift */; }; + F849ACB62ED5D024005B38AF /* SwapInProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = F849ACB52ED5D01C005B38AF /* SwapInProgress.swift */; }; + F849ACBA2ED5F823005B38AF /* CodableStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = F849ACB92ED5F822005B38AF /* CodableStorage.swift */; }; F88781AF2EB10B1E00207D0B /* TokenPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88781AE2EB10B1E00207D0B /* TokenPicker.swift */; }; F88781B12EB1EC5A00207D0B /* SwapDeposit.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88781B02EB1EC5A00207D0B /* SwapDeposit.swift */; }; F88781B32EB1EE4C00207D0B /* SwapProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88781B22EB1EE4C00207D0B /* SwapProgress.swift */; }; @@ -1130,6 +1132,8 @@ F8472F6A2E69BBCB0094163D /* SendingTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendingTransaction.swift; sourceTree = ""; }; F8472F6C2E744AFA0094163D /* SendingTransaction+Actions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SendingTransaction+Actions.swift"; sourceTree = ""; }; F8472F712E745A3D0094163D /* ProgressIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressIndicator.swift; sourceTree = ""; }; + F849ACB52ED5D01C005B38AF /* SwapInProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapInProgress.swift; sourceTree = ""; }; + F849ACB92ED5F822005B38AF /* CodableStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableStorage.swift; sourceTree = ""; }; F88781AE2EB10B1E00207D0B /* TokenPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenPicker.swift; sourceTree = ""; }; F88781B02EB1EC5A00207D0B /* SwapDeposit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapDeposit.swift; sourceTree = ""; }; F88781B22EB1EE4C00207D0B /* SwapProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapProgress.swift; sourceTree = ""; }; @@ -1907,6 +1911,7 @@ 545A9D22294F9E66008D24A6 /* User Settings */, 37B48A8224B3968F00F8A8D2 /* AppKeychainWrapper.swift */, 3A4CE32F26A18DFC00ECF460 /* UserDefaults.swift */, + F849ACB92ED5F822005B38AF /* CodableStorage.swift */, ); path = "Persistant Data"; sourceTree = ""; @@ -3036,6 +3041,7 @@ F82888B22EC39D730085A103 /* Models */ = { isa = PBXGroup; children = ( + F849ACB52ED5D01C005B38AF /* SwapInProgress.swift */, F82888B32EC39D7B0085A103 /* ExolixConfirmation.swift */, ); path = Models; @@ -3633,6 +3639,7 @@ 3AE5E5682874696E00D3AF85 /* ValuePickerView.swift in Sources */, 3A0391E6290BA40E00352D73 /* BugReportingView.swift in Sources */, 546B032A2983F33600DBED8E /* OnboardingPagerView.swift in Sources */, + F849ACB62ED5D024005B38AF /* SwapInProgress.swift in Sources */, 54E007B62B8DF55600AFCD7C /* SecurityManager.swift in Sources */, 711D7FA42D511FDE0080A987 /* LogViewController.swift in Sources */, 71F2B2232D8C20E700104073 /* LoginDeeplink.swift in Sources */, @@ -3744,6 +3751,7 @@ 5422CBFC2B88C79200428394 /* ScreenRecordingSettingsView.swift in Sources */, 711F28EB2DA3322D00751986 /* ReceiveViewController.swift in Sources */, 711D7FA82D5503540080A987 /* StylisedButton.swift in Sources */, + F849ACBA2ED5F823005B38AF /* CodableStorage.swift in Sources */, 37547D5624601BF600EB59CC /* UIView+GlobalFrame.swift in Sources */, 54D46A952CA6909800E554C0 /* WalletTag.swift in Sources */, BF8316FD23EF7EAA00235403 /* LAContext.swift in Sources */, @@ -4125,7 +4133,7 @@ CODE_SIGN_ENTITLEMENTS = MobileWallet/Tari.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = Dev; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 8XGMD9X2H2; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( @@ -4160,7 +4168,7 @@ CODE_SIGN_ENTITLEMENTS = MobileWallet/Tari.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = Dev; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 8XGMD9X2H2; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( diff --git a/MobileWallet/Common/APIService.swift b/MobileWallet/Common/APIService.swift index a2f8dd94..54befed7 100644 --- a/MobileWallet/Common/APIService.swift +++ b/MobileWallet/Common/APIService.swift @@ -104,9 +104,6 @@ private extension API { if httpResponse.statusCode == 401 { throw APIError.unauthorized } - if let responseString = String(data: data, encoding: .utf8) { - print("API Response for \(endpoint): \(responseString)") - } } func refreshToken() async throws { diff --git a/MobileWallet/Common/Persistant Data/CodableStorage.swift b/MobileWallet/Common/Persistant Data/CodableStorage.swift new file mode 100644 index 00000000..1fb18b3e --- /dev/null +++ b/MobileWallet/Common/Persistant Data/CodableStorage.swift @@ -0,0 +1,78 @@ +// CodableStorage.swift + +/* + Package MobileWallet + Created by Tomas Hakel on 25.11.2025 + Using Swift 6.0 + Running on macOS 26.0 + + Copyright 2019 The Tari Project + + Redistribution and use in source and binary forms, with or + without modification, are permitted provided that the + following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + 3. Neither the name of the copyright holder nor the names of + its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR + CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +import SwiftUI + +@propertyWrapper +public struct CodableStorage: DynamicProperty { + public init( + _ key: String, + store: UserDefaults = .standard, + defaultValue: Value + ) { + self.key = key + self.store = store + self.defaultValue = defaultValue + } + + private let defaultValue: Value + private let key: String + private let store: UserDefaults + + public var wrappedValue: Value { + get { + Self.initialValue(for: key, in: store) ?? defaultValue + } + nonmutating set { + let data = try? JSONEncoder().encode(newValue) + store.set(data, forKey: key) + } + } +} + +private extension CodableStorage { + static func initialValue( + for key: String, + in store: UserDefaults + ) -> Value? { + guard let data = store.object(forKey: key) as? Data else { return nil } + return try? JSONDecoder().decode(Value.self, from: data) + } +} diff --git a/MobileWallet/Common/Persistant Data/UserDefaults.swift b/MobileWallet/Common/Persistant Data/UserDefaults.swift index 647650aa..4e3d21ff 100644 --- a/MobileWallet/Common/Persistant Data/UserDefaults.swift +++ b/MobileWallet/Common/Persistant Data/UserDefaults.swift @@ -42,7 +42,7 @@ import Foundation // MARK: - Generic User Defaults -private enum UserDefaultName: String, CaseIterable { +enum UserDefaultName: String, CaseIterable { case selectedNetworkName case networksSettings case walletSettings @@ -53,13 +53,13 @@ private enum UserDefaultName: String, CaseIterable { } enum GroupUserDefaults { - @UserDefault(key: UserDefaultName.selectedNetworkName.rawValue, suiteName: TariSettings.groupIndentifier) static var selectedNetworkName: String? - @UserDefault(key: UserDefaultName.networksSettings.rawValue, suiteName: TariSettings.groupIndentifier) static var networksSettings: [NetworkSettings]? - @UserDefault(key: UserDefaultName.walletSettings.rawValue, suiteName: TariSettings.groupIndentifier) static var walletSettings: [WalletSettings]? - @UserDefault(key: UserDefaultName.userSettings.rawValue, suiteName: TariSettings.groupIndentifier) static var userSettings: UserSettings? - @UserDefault(key: UserDefaultName.isTrackingEnabled.rawValue, suiteName: TariSettings.groupIndentifier) static var isTrackingEnabled: Bool? - @UserDefault(key: UserDefaultName.areScreenshotsDisabled.rawValue, suiteName: TariSettings.groupIndentifier) static var areScreenshotsDisabled: Bool? - @UserDefault(key: UserDefaultName.trustedAddresses.rawValue, suiteName: TariSettings.groupIndentifier) static var trustedAddresses: Set? + @UserDefault(key: UserDefaultName.selectedNetworkName, suiteName: TariSettings.groupIndentifier) static var selectedNetworkName: String? + @UserDefault(key: UserDefaultName.networksSettings, suiteName: TariSettings.groupIndentifier) static var networksSettings: [NetworkSettings]? + @UserDefault(key: UserDefaultName.walletSettings, suiteName: TariSettings.groupIndentifier) static var walletSettings: [WalletSettings]? + @UserDefault(key: UserDefaultName.userSettings, suiteName: TariSettings.groupIndentifier) static var userSettings: UserSettings? + @UserDefault(key: UserDefaultName.isTrackingEnabled, suiteName: TariSettings.groupIndentifier) static var isTrackingEnabled: Bool? + @UserDefault(key: UserDefaultName.areScreenshotsDisabled, suiteName: TariSettings.groupIndentifier) static var areScreenshotsDisabled: Bool? + @UserDefault(key: UserDefaultName.trustedAddresses, suiteName: TariSettings.groupIndentifier) static var trustedAddresses: Set? } // MARK: - Extensions diff --git a/MobileWallet/Common/Property Wrappers/UserDefault.swift b/MobileWallet/Common/Property Wrappers/UserDefault.swift index 60b0433e..1d47736e 100644 --- a/MobileWallet/Common/Property Wrappers/UserDefault.swift +++ b/MobileWallet/Common/Property Wrappers/UserDefault.swift @@ -43,8 +43,12 @@ import Foundation @propertyWrapper struct UserDefault { private let key: String private let userDefaults: UserDefaults + + init(key: UserDefaultName, suiteName: String? = nil) { + self.init(key.rawValue, suiteName: suiteName) + } - init(key: String, suiteName: String? = nil) { + init(_ key: String, suiteName: String? = nil) { self.key = key userDefaults = UserDefaults(suiteName: suiteName) ?? UserDefaults.standard } diff --git a/MobileWallet/Screens/Home/Home/Scenes/Home+Actions.swift b/MobileWallet/Screens/Home/Home/Scenes/Home+Actions.swift index 57b1ea74..710519b1 100644 --- a/MobileWallet/Screens/Home/Home/Scenes/Home+Actions.swift +++ b/MobileWallet/Screens/Home/Home/Scenes/Home+Actions.swift @@ -202,28 +202,9 @@ private extension Home { } extension Home: SwapTransactionMonitoring { - var latestTransaction: ExolixTransactionResponse? { - get { swapTransaction } - nonmutating set { swapTransaction = newValue } - } - - var isTransactionProcessed: Bool { - swapTransaction?.isProcessed == true - } - - var isTransactionCancelled: Bool { - false - } - - func finaliseTransaction() { - swapTransaction = nil - swapInProgressId = nil - } - func loadSwapInProgress() { - guard let swapInProgressId else { return } Task { - await monitorTransactionStatus(transactionId: swapInProgressId) + await monitorSwapTransactions(swapTransactions) } } } diff --git a/MobileWallet/Screens/Home/Home/Scenes/Home.swift b/MobileWallet/Screens/Home/Home/Scenes/Home.swift index cc7f16f0..e3695843 100644 --- a/MobileWallet/Screens/Home/Home/Scenes/Home.swift +++ b/MobileWallet/Screens/Home/Home/Scenes/Home.swift @@ -42,9 +42,10 @@ import SwiftUI import Combine struct Home: View, ChainTipObserver { - @AppStorage("swapInProgressId") var swapInProgressId: String? + @CodableStorage("swapTransactions", defaultValue: SwapTransactionList()) var swapTransactions @ObservedObject var network = NetworkManager.shared @Environment(SheetRouter.self) var router + @Environment(\.scenePhase) var scenePhase @State var activeMiners = " " @State var totalBalance = "" @State var availableBalance = "" @@ -60,8 +61,8 @@ struct Home: View, ChainTipObserver { @State var isReceivePresented = false @State var isTransactionHistoryPresented = false @State var isConnectionStatusPresented = false + @State var exolix = Exolix.shared - let exolix = Exolix() let walletState: WalletState var body: some View { @@ -72,9 +73,7 @@ struct Home: View, ChainTipObserver { miningStatus VStack(spacing: 24) { wallet - if let swapTransaction { - swapInProgress(swapTransaction) - } + swapInProgress recentActivity } } @@ -108,7 +107,7 @@ struct Home: View, ChainTipObserver { } .fullScreenCover(item: $presentedSwapProgress) { transaction in NavigationStack { - SwapProgress(exolix: Exolix(), transaction: transaction) + SwapProgress(transaction: transaction) } } .sheet(isPresented: $isConnectionStatusPresented) { @@ -122,9 +121,18 @@ struct Home: View, ChainTipObserver { .onReceive(Tari.mainWallet.transactions.$all) { update(transactions: $0) } - .onChange(of: swapInProgressId) { + .onChange(of: swapTransactions) { loadSwapInProgress() } + .onChange(of: scenePhase) { + Task { + if scenePhase == .active { + await exolix.monitor(transactions: Array(swapTransactions.swaps)) + } else { + await exolix.stopMonitoringTransactions() + } + } + } } } } @@ -259,12 +267,18 @@ private extension Home { .foregroundStyle(.primaryText) } - func swapInProgress(_ swapTransaction: ExolixTransactionResponse) -> some View { - VStack { - sectionHeader("Swap in progress") - .frame(maxWidth: .infinity, alignment: .leading) - SwapInProgressItem(transaction: swapTransaction) { - presentedSwapProgress = swapTransaction + @ViewBuilder + var swapInProgress: some View { + let transactions = exolix.sortedTransactions + if !transactions.isEmpty { + VStack { + sectionHeader("Recent Swaps") + .frame(maxWidth: .infinity, alignment: .leading) + ForEach(transactions) { swapTransaction in + SwapInProgressItem(transaction: swapTransaction) { + presentedSwapProgress = swapTransaction + } + } } } } diff --git a/MobileWallet/Screens/Swaps/Interactors/SwapTransactionMonitoring.swift b/MobileWallet/Screens/Swaps/Interactors/SwapTransactionMonitoring.swift index e072c241..55ca1dc1 100644 --- a/MobileWallet/Screens/Swaps/Interactors/SwapTransactionMonitoring.swift +++ b/MobileWallet/Screens/Swaps/Interactors/SwapTransactionMonitoring.swift @@ -40,24 +40,18 @@ protocol SwapTransactionMonitoring { var exolix: Exolix { get } - var latestTransaction: ExolixTransactionResponse? { get nonmutating set } - var isTransactionProcessed: Bool { get } - var isTransactionCancelled: Bool { get } - func finaliseTransaction() } extension SwapTransactionMonitoring { - func monitorTransactionStatus(transactionId: String) async { - guard let transaction = try? await exolix.getTransaction(id: transactionId) else { return } - latestTransaction = transaction - if isTransactionProcessed { - finaliseTransaction() - } else if !isTransactionCancelled { - Task(after: 10) { - if transaction.id == latestTransaction?.id { - await monitorTransactionStatus(transactionId: transactionId) - } - } - } + func monitorSwapTransaction(id: String) async { + await exolix.monitor(transactions: [id]) + } + + func monitorSwapTransactions(_ transactions: SwapTransactionList) async { + await exolix.monitor(transactions: Array(transactions.swaps)) + } + + func latestTransaction(id: String) -> ExolixTransactionResponse? { + exolix.latestTransaction(id: id) } } diff --git a/MobileWallet/Screens/Swaps/Models/SwapInProgress.swift b/MobileWallet/Screens/Swaps/Models/SwapInProgress.swift new file mode 100644 index 00000000..bc1e7f0b --- /dev/null +++ b/MobileWallet/Screens/Swaps/Models/SwapInProgress.swift @@ -0,0 +1,67 @@ +// SwapInProgress.swift + +/* + Package MobileWallet + Created by Tomas Hakel on 25.11.2025 + Using Swift 6.0 + Running on macOS 26.0 + + Copyright 2019 The Tari Project + + Redistribution and use in source and binary forms, with or + without modification, are permitted provided that the + following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + 3. Neither the name of the copyright holder nor the names of + its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR + CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +import Foundation + +struct SwapTransactionList: Codable, Hashable { + var maxSwaps: Int { 5 } + var swaps = [String]() + + init() { + swaps = [] + } + + mutating func add(_ id: String) { + if let index = swaps.firstIndex(of: id) { + swaps[index] = id + } else { + swaps.insert(id, at: 0) + } + if maxSwaps < swaps.count { + _ = swaps.popLast() + } + } + + mutating func remove(_ id: String) { + if let index = swaps.firstIndex(of: id) { + swaps.remove(at: index) + } + } +} diff --git a/MobileWallet/Screens/Swaps/SelectSwapCurrency.swift b/MobileWallet/Screens/Swaps/SelectSwapCurrency.swift index d9de8631..aa4865b2 100644 --- a/MobileWallet/Screens/Swaps/SelectSwapCurrency.swift +++ b/MobileWallet/Screens/Swaps/SelectSwapCurrency.swift @@ -48,7 +48,7 @@ struct SelectSwapCurrency: View { @State var page: Int = 1 @State var isLoading = true - let exolix: Exolix + let exolix = Exolix.shared let select: (ExolixCurrency, ExolixNetwork) -> Void var body: some View { diff --git a/MobileWallet/Screens/Swaps/SwapConfirmation+Actions.swift b/MobileWallet/Screens/Swaps/SwapConfirmation+Actions.swift index aa93f6cd..537e5cad 100644 --- a/MobileWallet/Screens/Swaps/SwapConfirmation+Actions.swift +++ b/MobileWallet/Screens/Swaps/SwapConfirmation+Actions.swift @@ -38,15 +38,11 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -extension SwapConfirmation { +extension SwapConfirmation: SwapTransactionMonitoring { func load() { Task { do { - transaction = try await exolix.postTransaction( - request: sellRequest, - refundAddress: Tari.mainWallet.address.components.fullRaw, - refundExtraId: nil - ) + transaction = try await exolix.postTransaction(sellRequest) } catch { errorMessage = error.localizedDescription } @@ -63,7 +59,7 @@ extension SwapConfirmation { amount: tariAmount.rawValue, paymentID: transaction.depositExtraId ?? "" ) - swapInProgressId = transaction.id + swapTransactions.add(transaction.id) presentedTransactionProgress = transaction } catch { errorMessage = error.localizedDescription diff --git a/MobileWallet/Screens/Swaps/SwapConfirmation.swift b/MobileWallet/Screens/Swaps/SwapConfirmation.swift index f0050277..fef08677 100644 --- a/MobileWallet/Screens/Swaps/SwapConfirmation.swift +++ b/MobileWallet/Screens/Swaps/SwapConfirmation.swift @@ -41,13 +41,13 @@ import SwiftUI struct SwapConfirmation: View { - @AppStorage("swapInProgressId") var swapInProgressId: String? + @CodableStorage("swapTransactions", defaultValue: SwapTransactionList()) var swapTransactions @Environment(\.dismiss) var dismiss + @State var exolix = Exolix.shared @State var transaction: ExolixTransactionResponse? @State var presentedTransactionProgress: ExolixTransactionResponse? @State var errorMessage: String? - let exolix: Exolix var sellRequest: ExolixConfirmation var body: some View { @@ -73,7 +73,7 @@ struct SwapConfirmation: View { } .overlay(alignment: .bottom) { ExolixLogo() - .padding(.bottom, 80) + .padding(.bottom, 60) } .safeAreaInset(edge: .bottom) { TariButton("Confirm", style: .primary, size: .large) { @@ -84,9 +84,9 @@ struct SwapConfirmation: View { } .alert(title: "Exolix error", message: $errorMessage) .navigationDestination(item: $presentedTransactionProgress) { - SwapProgress(exolix: exolix, transaction: $0) + SwapProgress(transaction: $0) } - .onAppear { load() } + .onFirstAppear { load() } } } @@ -96,11 +96,14 @@ private extension SwapConfirmation { headerItem(label: "You send", amount: transaction.amount, coin: transaction.coinFrom) headerItem(label: "You receive", amount: transaction.amountTo, coin: transaction.coinTo) } + .overlay { + Image(.sendFundsSeparator) + } } func transactionInfo(for transaction: ExolixTransactionResponse) -> some View { - VStack(spacing: 0) { - if let fee = try? TransactionFeesManager().fee(for: MicroTari(decimalValue: transaction.amount)).formattedWithCurrency { + VStack(spacing: 8) { + if let fee = try? TransactionFeesManager().fee(for: MicroTari(decimalValue: transaction.amount)).formattedPreciseWithCurrency { SwapItem(label: "Network cost", value: fee) } SwapItem(label: "Rate", value: "1 \(transaction.coinFrom.coinCode) = \(transaction.rate.formatted()) \(transaction.coinTo.coinCode)") diff --git a/MobileWallet/Screens/Swaps/SwapDeposit+Actions.swift b/MobileWallet/Screens/Swaps/SwapDeposit+Actions.swift index e787b5c9..e396d2fa 100644 --- a/MobileWallet/Screens/Swaps/SwapDeposit+Actions.swift +++ b/MobileWallet/Screens/Swaps/SwapDeposit+Actions.swift @@ -39,38 +39,31 @@ */ extension SwapDeposit: SwapTransactionMonitoring { - var latestTransaction: ExolixTransactionResponse? { - get { transaction } - nonmutating set { transaction = newValue } - } - - var isTransactionProcessed: Bool { transaction?.isFunded ?? false } - - func finaliseTransaction() { - presentedTransactionProgress = transaction - } -} - -extension SwapDeposit { func load() { Task { do { - let response = try await exolix.postTransaction( - request: request, - refundAddress: Tari.mainWallet.address.components.fullRaw, - refundExtraId: nil - ) - swapInProgressId = response.id + let response = try await exolix.postTransaction(request) + transaction = response + swapTransactions.add(response.id) if response.isFunded { presentedTransactionProgress = response } else { - await monitorTransactionStatus(transactionId: response.id) + await monitorSwapTransaction(id: response.id) } } catch { errorMessage = error.localizedDescription } } } + + func cancelTransaction() { + Task { + if let transaction { + await exolix.cancelTransaction(transactionId: transaction.id) + } + router.isSwapPresented = false + } + } } extension ExolixTransactionResponse { @@ -85,9 +78,9 @@ extension ExolixTransactionResponse { var isProcessed: Bool { switch status { - case .wait, .confirmation, .confirmed, .exchanging, .sending, .overdue, .none: + case .wait, .confirmation, .confirmed, .exchanging, .sending, .none: return false - case .success, .refunded: + case .success, .refunded, .overdue: return true } } diff --git a/MobileWallet/Screens/Swaps/SwapDeposit.swift b/MobileWallet/Screens/Swaps/SwapDeposit.swift index a430d676..739c2bcf 100644 --- a/MobileWallet/Screens/Swaps/SwapDeposit.swift +++ b/MobileWallet/Screens/Swaps/SwapDeposit.swift @@ -41,18 +41,23 @@ import SwiftUI struct SwapDeposit: View { - @AppStorage("swapInProgressId") var swapInProgressId: String? + @CodableStorage("swapTransactions", defaultValue: SwapTransactionList()) var swapTransactions @Environment(SheetRouter.self) var router @Environment(\.dismiss) var dismiss + @State var exolix = Exolix.shared @State var isQrHidden = true @State var transaction: ExolixTransactionResponse? @State var presentedTransactionProgress: ExolixTransactionResponse? - @State var isTransactionCancelled = false @State var errorMessage: String? - let exolix: Exolix let request: ExolixConfirmation + var latestTransaction: ExolixTransactionResponse? { + if let transaction { + exolix.latestTransaction(id: transaction.id) ?? transaction + } else { nil } + } + var body: some View { VStack { VStack(alignment: .leading, spacing: 12) { @@ -74,8 +79,7 @@ struct SwapDeposit: View { Spacer() TariButton("Cancel transaction", style: .destructiveText, size: .medium) { - swapInProgressId = nil - router.isSwapPresented = false + cancelTransaction() } } .padding(.top, 40) @@ -89,11 +93,13 @@ struct SwapDeposit: View { } .alert(title: "Exolix error", message: $errorMessage) .navigationDestination(item: $presentedTransactionProgress) { - SwapProgress(exolix: exolix, transaction: $0) + SwapProgress(transaction: $0) } - .onAppear { load() } - .onDisappear { - isTransactionCancelled = true + .onFirstAppear { load() } + .onChange(of: exolix.latestTransactions) { + if latestTransaction?.isFunded == true { + presentedTransactionProgress = transaction + } } } } @@ -101,7 +107,7 @@ struct SwapDeposit: View { private extension SwapDeposit { var depositInfo: some View { VStack(spacing: 14) { - if let transaction { + if let transaction = latestTransaction { VStack(alignment: .leading, spacing: 4) { depositItem("You need to send", value: "\(transaction.amount) \(transaction.coinFrom.coinCode)", diff --git a/MobileWallet/Screens/Swaps/SwapProgress+Actions.swift b/MobileWallet/Screens/Swaps/SwapProgress+Actions.swift index b9e42f0e..8e223fd1 100644 --- a/MobileWallet/Screens/Swaps/SwapProgress+Actions.swift +++ b/MobileWallet/Screens/Swaps/SwapProgress+Actions.swift @@ -42,12 +42,17 @@ extension SwapProgress: SwapTransactionMonitoring { var isTransactionProcessed: Bool { transaction.isProcessed } - - func finaliseTransaction() { - swapInProgressId = nil + + func monitorTransactionStatus() async { + await monitorSwapTransaction(id: transaction.id) } - func monitorTransactionStatus() async { - await monitorTransactionStatus(transactionId: transaction.id) + func cancelTransaction() { + Task { + await exolix.cancelTransaction(transactionId: transaction.id) + swapTransactions.remove(transaction.id) + router.isSwapPresented = false + dismiss() + } } } diff --git a/MobileWallet/Screens/Swaps/SwapProgress.swift b/MobileWallet/Screens/Swaps/SwapProgress.swift index 06f32505..bcc83d1f 100644 --- a/MobileWallet/Screens/Swaps/SwapProgress.swift +++ b/MobileWallet/Screens/Swaps/SwapProgress.swift @@ -41,22 +41,19 @@ import SwiftUI struct SwapProgress: View { - @AppStorage("swapInProgressId") var swapInProgressId: String? + @CodableStorage("swapTransactions", defaultValue: SwapTransactionList()) var swapTransactions @Environment(\.scenePhase) var scenePhase @Environment(\.dismiss) var dismiss @Environment(SheetRouter.self) var router - @State var latestTransaction: ExolixTransactionResponse? - @State var isTransactionCancelled = false + @State var exolix = Exolix.shared - let exolix: Exolix let initialTransaction: ExolixTransactionResponse var transaction: ExolixTransactionResponse { - latestTransaction ?? initialTransaction + latestTransaction(id: initialTransaction.id) ?? initialTransaction } - init(exolix: Exolix, transaction: ExolixTransactionResponse) { - self.exolix = exolix + init(transaction: ExolixTransactionResponse) { self.initialTransaction = transaction } @@ -65,6 +62,11 @@ struct SwapProgress: View { VStack(spacing: 24) { header processingInfo + if (transaction.status == .wait && transaction.coinFrom.coinCode != "XTM") || transaction.status == .overdue { + TariButton("Cancel transaction", style: .destructiveText, size: .medium) { + cancelTransaction() + } + } } .padding(.vertical, 12) .padding(.horizontal, 24) @@ -83,20 +85,20 @@ struct SwapProgress: View { if transaction.isProcessed { TariButton("Done", style: .secondary, size: .large) { router.isSwapPresented = false + dismiss() } - .padding(.vertical, 12) + .padding(.horizontal, 24) .padding(.bottom, 16) } } .task { await monitorTransactionStatus() } - .onDisappear { isTransactionCancelled = true } .onChange(of: scenePhase) { - if scenePhase == .active { - Task { + Task { + if scenePhase == .active { await monitorTransactionStatus() + } else { + await exolix.stopMonitoringTransactions() } - } else { - isTransactionCancelled = true } } } @@ -131,9 +133,9 @@ private extension SwapProgress { } SwapItem(label: "Transaction id", value: transaction.id) if let createdAt = transaction.createdAtDate { - SwapItem(label: "Created", value: createdAt.formatted()) + SwapItem(label: "Created", value: createdAt.formatted(), isCoppiable: false) } - SwapItem(label: "Exchange rate", value: "1 \(transaction.coinFrom.coinCode) = \(transaction.rate.formatted()) \(transaction.coinTo.coinCode)") + SwapItem(label: "Exchange rate", value: "1 \(transaction.coinFrom.coinCode) = \(transaction.rate.formatted()) \(transaction.coinTo.coinCode)", isCoppiable: false) if let comment = transaction.comment, !comment.isEmpty { SwapItem(label: "Comment", value: comment) } @@ -150,13 +152,13 @@ private extension SwapProgress { var amountItem: some View { switch transaction.status { case .none, .wait, .confirmation, .confirmed, .exchanging, .sending: - SwapItem(label: "You will receive", value: "\(transaction.amountTo) \(transaction.coinTo.coinCode)") + SwapItem(label: "You will receive", value: "\(transaction.amountTo) \(transaction.coinTo.coinCode)", isCoppiable: false) case .success: - SwapItem(label: "Amount received", value: "\(transaction.amountTo) \(transaction.coinTo.coinCode)") + SwapItem(label: "Amount received", value: "\(transaction.amountTo) \(transaction.coinTo.coinCode)", isCoppiable: false) case .overdue: - SwapItem(label: "Amount overdue", value: "\(transaction.amount) \(transaction.coinFrom.coinCode)") + SwapItem(label: "Amount overdue", value: "\(transaction.amount) \(transaction.coinFrom.coinCode)", isCoppiable: false) case .refunded: - SwapItem(label: "Amount refunded", value: "\(transaction.amount) \(transaction.coinFrom.coinCode)") + SwapItem(label: "Amount refunded", value: "\(transaction.amount) \(transaction.coinFrom.coinCode)", isCoppiable: false) } } @@ -207,3 +209,12 @@ private extension SwapProgress { } } } + +private extension ExolixTransactionStatus { + var isCancellable: Bool { + switch self { + case .wait, .overdue: true + default: false + } + } +} diff --git a/MobileWallet/Screens/Swaps/Swaps+Actions.swift b/MobileWallet/Screens/Swaps/Swaps+Actions.swift index e1421627..5288be34 100644 --- a/MobileWallet/Screens/Swaps/Swaps+Actions.swift +++ b/MobileWallet/Screens/Swaps/Swaps+Actions.swift @@ -58,12 +58,16 @@ extension Swaps { } func load() { - Task { - externalCurrency = try await exolix.getCurrencies(page: 1, size: 1).currencies.first - externalNetwork = externalCurrency?.defaultNetwork - xtmCurrency = try await exolix.getXtmCurrency() - xtmNetwork = xtmCurrency?.defaultNetwork // try await exolix.getXtmNetwork() - isLoading = false + if externalCurrency == nil { + Task { + externalCurrency = try await exolix.getCurrencies(page: 1, size: 1).currencies.first + externalNetwork = externalCurrency?.defaultNetwork + xtmCurrency = try await exolix.getXtmCurrency() + xtmNetwork = xtmCurrency?.defaultNetwork + isLoading = false + updateRate() + } + } else { updateRate() } } @@ -94,6 +98,8 @@ extension Swaps { func loadRate(from sourceCurrency: String, to targetCurrency: String, amount: String) { Task { + isLoading = true + defer { isLoading = false } do { rate = try await exolix.getRate( from: sourceCurrency, @@ -145,21 +151,27 @@ extension Swaps { } func validateAmount(_ amount: Double) { - if let minAmount, amount < minAmount { - amountError = "Amount must be greater than \(minAmount)" - } else if let maxAmount, maxAmount < amount { - amountError = "Amount must be less than \(maxAmount)" - } else { - amountError = nil + withAnimation { + if let minAmount, amount < minAmount { + amountError = "Amount must be greater than \(minAmount)" + } else if let maxAmount, maxAmount < amount { + amountError = "Amount must be less than \(maxAmount)" + } else { + amountError = nil + } } } func validateWithdrawalAddress() { - guard let addresRegex = externalCurrency?.addresRegex else { return } - if withdrawalAddress.matches(addresRegex) { - withdrawalAddressError = nil + guard let addressRegex = externalNetwork?.addressRegex else { return } + if withdrawalAddress.isEmpty || withdrawalAddress.matches(addressRegex) { + withAnimation { + withdrawalAddressError = nil + } } else { - withdrawalAddressError = "Invalid address" + withAnimation { + withdrawalAddressError = "Invalid address" + } } } diff --git a/MobileWallet/Screens/Swaps/Swaps.swift b/MobileWallet/Screens/Swaps/Swaps.swift index 50398422..3926a94f 100644 --- a/MobileWallet/Screens/Swaps/Swaps.swift +++ b/MobileWallet/Screens/Swaps/Swaps.swift @@ -47,9 +47,9 @@ extension Swaps { } struct Swaps: View { - @AppStorage("swapInProgressId") var swapInProgressId: String? @Environment(\.dismiss) var dismiss @FocusState var fieldFocus: FieldFocus? + @State var exolix = Exolix.shared @State var availableBalance: MicroTari? @State var amount = "" @State var amountError: String? @@ -69,8 +69,6 @@ struct Swaps: View { @State var presentedDeposit: ExolixConfirmation? @State var presentedConfirmation: ExolixConfirmation? @State var errorMessage: String? - - let exolix = Exolix() var body: some View { ScrollView { @@ -84,6 +82,9 @@ struct Swaps: View { .padding(.vertical, 12) .padding(.horizontal, 24) } + .refreshable { + updateRate() + } .safeAreaInset(edge: .bottom) { TariButton("Next Step", style: .primary, size: .large) { swap() @@ -92,11 +93,6 @@ struct Swaps: View { .padding(.horizontal, 24) .padding(.bottom, 12) } - .overlay { - if isLoading { - ProgressView() - } - } .overlay(alignment: .bottom) { if fieldFocus == nil { ExolixLogo() @@ -113,13 +109,13 @@ struct Swaps: View { } .alert(title: "Exolix error", message: $errorMessage) .navigationDestination(item: $presentedDeposit) { - SwapDeposit(exolix: exolix, request: $0) + SwapDeposit(request: $0) } .navigationDestination(item: $presentedConfirmation) { - SwapConfirmation(exolix: exolix, sellRequest: $0) + SwapConfirmation(sellRequest: $0) } .fullScreenCover(isPresented: $isCurrencySelectionPresented) { - SelectSwapCurrency(exolix: exolix) { + SelectSwapCurrency() { select(currency: $0, network: $1) } } @@ -134,6 +130,9 @@ struct Swaps: View { updateRate() validateWithdrawalAddress() } + .onChange(of: isFixedRate) { + updateRate() + } .onReceive(Tari.mainWallet.walletBalance.$balance) { update(walletBalance: $0) } @@ -175,19 +174,32 @@ private extension Swaps { VStack(alignment: .leading, spacing: 4) { if !isBuyingXtm, let availableBalance { Text("Available: \(availableBalance.taris.formatted()) XTM") + .body() + .foregroundStyle(.secondaryText) } if let min { - Text("Min amount: \(min.formatted())") + rangeValue("Min", value: min) } if let max { - Text("Max amount: \(max.formatted())") + rangeValue("Max", value: max) } } - .body2() - .foregroundStyle(.secondaryText) .frame(minHeight: 50, alignment: .top) } + func rangeValue(_ label: String, value: Double) -> some View { + Button(action: { amount = value.formatted().replacingOccurrences(of: " ", with: "") }) { + HStack { + Text("\(label):") + .body() + .foregroundStyle(.secondaryText) + Text(value.formatted()) + .headingMedium() + .foregroundStyle(.primaryText) + } + } + } + var externalSource: some View { VStack { if let externalCurrency { @@ -320,11 +332,21 @@ private extension Swaps { } var reverseDirection: some View { - HStack(spacing: 30) { + HStack(spacing: 24) { VStack { Divider() } - Button(action: reverseSwapDirection) { - Image(.swap) + Group { + if isLoading { + ProgressView() + .controlSize(.large) + .tint(.primaryMain) + } else { + Button(action: reverseSwapDirection) { + Image(.swap) + } + } } + .frame(square: 44) + VStack { Divider() } } } diff --git a/MobileWallet/Screens/Swaps/Views/SwapItem.swift b/MobileWallet/Screens/Swaps/Views/SwapItem.swift index a5fd2f0a..bf1d208c 100644 --- a/MobileWallet/Screens/Swaps/Views/SwapItem.swift +++ b/MobileWallet/Screens/Swaps/Views/SwapItem.swift @@ -43,16 +43,23 @@ import SwiftUI struct SwapItem: View { let label: String let value: String + var isCoppiable = true var body: some View { VStack(spacing: 10) { - VStack(alignment: .leading, spacing: 0) { - Text(label) - .body2() - .foregroundStyle(.secondaryText) - Text(value) - .body() - .foregroundStyle(.primaryText) + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + Text(label) + .body2() + .foregroundStyle(.secondaryText) + Text(value) + .body() + .foregroundStyle(.primaryText) + } + Spacer(minLength: 8) + if isCoppiable { + CopyButton(value: value, color: .secondaryText) + } } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/MobileWallet/Services/Exolix/Exolix.swift b/MobileWallet/Services/Exolix/Exolix.swift index e0626001..8331c644 100644 --- a/MobileWallet/Services/Exolix/Exolix.swift +++ b/MobileWallet/Services/Exolix/Exolix.swift @@ -1,18 +1,38 @@ import Foundation +import Combine @globalActor actor ExolixActor: GlobalActor { static let shared = ExolixActor() } +@Observable final class Exolix { + @ObservationIgnored @CodableStorage("swapTransactions", defaultValue: SwapTransactionList()) var swapTransactions + + static let shared = Exolix() + private let baseURL = URL(string: "https://exolix.com/api/v2")! private let session: URLSession = .shared private let apiKey: String? + private var isMonitoringTransactions = false + var monitoredTransactions: Set = [] + var latestTransactions = [String: ExolixTransactionResponse]() + init() { self.apiKey = AppSecret.load()?.exolixApiKey } + + var sortedTransactions: [ExolixTransactionResponse] { + latestTransactions.values + .filter { swapTransactions.swaps.contains($0.id) } + .sorted { $1.createdAt < $0.createdAt } + } + + func latestTransaction(id: String) -> ExolixTransactionResponse? { + latestTransactions[id] + } } @ExolixActor @@ -37,19 +57,17 @@ extension Exolix { ]) } - func postTransaction(request: ExolixConfirmation, refundAddress: String?, refundExtraId: String?) async throws -> ExolixTransactionResponse { + func postTransaction(_ request: ExolixConfirmation) async throws -> ExolixTransactionResponse { try await postTransaction( coinFrom: request.coinFrom.code, networkFrom: request.networkFrom.network, coinTo: request.coinTo.code, networkTo: request.networkTo.network, amount: request.amount.double ?? 0, - withdrawalAmount: request.rate.toAmount, + withdrawalAmount: nil, withdrawalAddress: request.withdrawalAddress, withdrawalExtraId: request.withdrawalExtraId, - rateType: request.rateType, - refundAddress: refundAddress, - refundExtraId: refundExtraId + rateType: request.rateType ) } @@ -62,9 +80,7 @@ extension Exolix { withdrawalAmount: Double?, withdrawalAddress: String, withdrawalExtraId: String?, - rateType: ExolixRateType?, - refundAddress: String?, - refundExtraId: String? + rateType: ExolixRateType? ) async throws -> ExolixTransactionResponse { try await request("/transactions", method: "POST", as: ExolixTransactionResponse.self, body: ExolixTransactionRequest( coinFrom: coinFrom, @@ -75,9 +91,7 @@ extension Exolix { withdrawalAmount: withdrawalAmount, withdrawalAddress: withdrawalAddress, withdrawalExtraId: withdrawalExtraId, - rateType: rateType, - refundAddress: refundAddress, - refundExtraId: refundExtraId + rateType: rateType )) } @@ -89,6 +103,50 @@ extension Exolix { let transaction = try? await getTransaction(id: id) return transaction?.isProcessed == false ? transaction : nil } + + func monitor(transactions ids: [String]) { + for id in ids { + monitoredTransactions.insert(id) + } + if !isMonitoringTransactions { + startMonitoringTransactions() + } + } + + func startMonitoringTransactions() { + isMonitoringTransactions = true + monitorTransactions() + } + + func monitorTransactions() { + Task { + for transactionId in monitoredTransactions { + if let transaction = try? await getTransaction(id: transactionId) { + if transaction.isProcessed { + monitoredTransactions.remove(transaction.id) + } + latestTransactions[transaction.id] = transaction + } + } + if monitoredTransactions.isEmpty { + stopMonitoringTransactions() + } + if isMonitoringTransactions { + Task(after: 10) { @ExolixActor in + self.monitorTransactions() + } + } + } + } + + func cancelTransaction(transactionId: String) { + latestTransactions.removeValue(forKey: transactionId) + monitoredTransactions.remove(transactionId) + } + + func stopMonitoringTransactions() { + isMonitoringTransactions = false + } } @ExolixActor diff --git a/MobileWallet/Services/Exolix/Models/ExolixCurrency.swift b/MobileWallet/Services/Exolix/Models/ExolixCurrency.swift index 8afc14ef..e4afe976 100644 --- a/MobileWallet/Services/Exolix/Models/ExolixCurrency.swift +++ b/MobileWallet/Services/Exolix/Models/ExolixCurrency.swift @@ -42,7 +42,6 @@ struct ExolixCurrency: Decodable, Hashable { let code: String let name: String let icon: String - let addresRegex: String? let networks: [ExolixNetwork] var defaultNetwork: ExolixNetwork? { @@ -65,7 +64,6 @@ extension ExolixCurrency { code: "AAA", name: "Placeholder", icon: "", - addresRegex: nil, networks: [.placeholder] ) } diff --git a/MobileWallet/Services/Exolix/Models/ExolixNetwork.swift b/MobileWallet/Services/Exolix/Models/ExolixNetwork.swift index 8322c24d..4d288780 100644 --- a/MobileWallet/Services/Exolix/Models/ExolixNetwork.swift +++ b/MobileWallet/Services/Exolix/Models/ExolixNetwork.swift @@ -41,6 +41,7 @@ struct ExolixNetwork: Decodable, Hashable { let network: String let name: String + let addressRegex: String? let isDefault: Bool let icon: String? } @@ -53,6 +54,7 @@ extension ExolixNetwork { static let placeholder = ExolixNetwork( network: "Network", name: "Network", + addressRegex: nil, isDefault: false, icon: nil ) diff --git a/MobileWallet/Services/Exolix/Models/ExolixTransaction.swift b/MobileWallet/Services/Exolix/Models/ExolixTransaction.swift index c42e5d51..6e0d818c 100644 --- a/MobileWallet/Services/Exolix/Models/ExolixTransaction.swift +++ b/MobileWallet/Services/Exolix/Models/ExolixTransaction.swift @@ -50,8 +50,6 @@ struct ExolixTransactionRequest: Encodable { let withdrawalAddress: String let withdrawalExtraId: String? let rateType: ExolixRateType? - let refundAddress: String? - let refundExtraId: String? } enum ExolixRateType: String, Codable, Hashable {