From 5206867b748fbc7350101e0fe9525c296fdbd790 Mon Sep 17 00:00:00 2001 From: mike-dydx Date: Wed, 19 Jun 2024 15:13:29 -0400 Subject: [PATCH] cancel orders --- .../PlatformUI/PlatformViewModel+Ext.swift | 15 +++ ...ncelPendingIsolatedOrdersViewBuilder.swift | 89 ++++++++++++++- .../dydxStateManager/AbacusStateManager.swift | 17 ++- .../dydxStateManager/Models+Ext.swift | 2 + .../dydxCancelPendingIsolatedOrdersView.swift | 103 ++++++++++++++++-- 5 files changed, 215 insertions(+), 11 deletions(-) diff --git a/PlatformUI/PlatformUI/PlatformViewModel+Ext.swift b/PlatformUI/PlatformUI/PlatformViewModel+Ext.swift index 9d97b428f..5fdd20a1e 100644 --- a/PlatformUI/PlatformUI/PlatformViewModel+Ext.swift +++ b/PlatformUI/PlatformUI/PlatformViewModel+Ext.swift @@ -7,6 +7,7 @@ import Foundation import UIKit +import SwiftUI public extension PlatformViewModel { var safeAreaInsets: UIEdgeInsets? { @@ -21,3 +22,17 @@ public extension PlatformViewModel { .last } } + +public extension View { + var safeAreaInsets: UIEdgeInsets? { + keyWindow?.safeAreaInsets + } + + var keyWindow: UIWindow? { + UIApplication + .shared + .connectedScenes + .compactMap { ($0 as? UIWindowScene)?.keyWindow } + .last + } +} diff --git a/dydx/dydxPresenters/dydxPresenters/_v4/CancelOrders/dydxCancelPendingIsolatedOrdersViewBuilder.swift b/dydx/dydxPresenters/dydxPresenters/_v4/CancelOrders/dydxCancelPendingIsolatedOrdersViewBuilder.swift index a40da1f03..a2bef61be 100644 --- a/dydx/dydxPresenters/dydxPresenters/_v4/CancelOrders/dydxCancelPendingIsolatedOrdersViewBuilder.swift +++ b/dydx/dydxPresenters/dydxPresenters/_v4/CancelOrders/dydxCancelPendingIsolatedOrdersViewBuilder.swift @@ -11,6 +11,8 @@ import PlatformParticles import RoutingKit import ParticlesKit import PlatformUI +import dydxStateManager +import Combine public class dydxCancelPendingIsolatedOrdersViewBuilder: NSObject, ObjectBuilderProtocol { public func build() -> T? { @@ -22,7 +24,10 @@ public class dydxCancelPendingIsolatedOrdersViewBuilder: NSObject, ObjectBuilder private class dydxCancelPendingIsolatedOrdersViewBuilderController: HostingViewController { override public func arrive(to request: RoutingRequest?, animated: Bool) -> Bool { - if request?.path == "/portfolio/cancel_pending_position" { + if let marketId = request?.params?["market"] as? String, + request?.path == "/portfolio/cancel_pending_position", + let presenter = presenter as? dydxCancelPendingIsolatedOrdersViewBuilderPresenterProtocol { + presenter.marketId = marketId return true } return false @@ -31,12 +36,92 @@ private class dydxCancelPendingIsolatedOrdersViewBuilderController: HostingViewC private protocol dydxCancelPendingIsolatedOrdersViewBuilderPresenterProtocol: HostedViewPresenterProtocol { var viewModel: dydxCancelPendingIsolatedOrdersViewModel? { get } + var marketId: String? { get set } } private class dydxCancelPendingIsolatedOrdersViewBuilderPresenter: HostedViewPresenter, dydxCancelPendingIsolatedOrdersViewBuilderPresenterProtocol { + fileprivate var marketId: String? + override init() { super.init() - self.viewModel = .previewValue + self.viewModel = .init(marketLogoUrl: nil, marketName: "", marketId: "", orderCount: 0, cancelAction: {}) + } + + override func start() { + super.start() + + Publishers.CombineLatest( + AbacusStateManager.shared.state.configsAndAssetMap, + AbacusStateManager.shared.state.selectedSubaccountOrders + ) + .receive(on: RunLoop.main) + .sink { [weak self] configsAndAssetMap, orders in + guard let self = self, + let marketId = self.marketId, + let asset = configsAndAssetMap[marketId]?.asset + else { return } + let pendingOrders = orders.filter { $0.marketId == marketId && $0.status == .open } + self.viewModel?.marketLogoUrl = URL(string: asset.resources?.imageUrl ?? "") + self.viewModel?.marketName = asset.name ?? "--" + self.viewModel?.marketId = asset.id + self.viewModel?.orderCount = pendingOrders.count + self.viewModel?.failureCount = self.viewModel?.failureCount + self.viewModel?.cancelAction = { [weak self] in + self?.tryCancelOrders(orderIds: orders.map(\.id)) + } + } + .store(in: &subscriptions) + } + + private func tryCancelOrders(orderIds: [String]) { + viewModel?.state = viewModel?.failureCount == nil ? .submitting : .resubmitting + Task { [weak self] in + guard let self = self else { return } + + // Create an array to hold the results of the cancellations + var results: [Result] = [] + + // Use a TaskGroup to kick off multiple calls and wait for all to finish + await withTaskGroup(of: Result.self) { group in + for orderId in orderIds { + group.addTask { + do { + let status = try await AbacusStateManager.shared.cancelOrder(orderId: orderId) + return .success(status) + } catch { + return .failure(error) + } + } + } + + // Collect the results of all tasks + for await result in group { + results.append(result) + } + } + + // Count the number of failed cancellations + let failureCount = results.filter { result in + if case .failure = result { + return true + } + return false + }.count + + await updateState(failureCount: failureCount) + } } + + @MainActor + private func updateState(failureCount: Int) { + self.viewModel?.failureCount = failureCount + + if failureCount > 0 { + self.viewModel?.state = .failed + } else { + Router.shared?.navigate(to: RoutingRequest(path: "/action/dismiss"), animated: true, completion: nil) + } + } + } diff --git a/dydx/dydxStateManager/dydxStateManager/AbacusStateManager.swift b/dydx/dydxStateManager/dydxStateManager/AbacusStateManager.swift index b05a77560..5236e4bf8 100644 --- a/dydx/dydxStateManager/dydxStateManager/AbacusStateManager.swift +++ b/dydx/dydxStateManager/dydxStateManager/AbacusStateManager.swift @@ -481,7 +481,22 @@ extension AbacusStateManager { } } } -} + + public func cancelOrder(orderId: String) async throws -> SubmissionStatus { + try await withCheckedThrowingContinuation { continuation in + asyncStateManager.cancelOrder(orderId: orderId) { successful, error, _ in + if successful.boolValue { + continuation.resume(returning: .success) + } else { + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume(throwing: ParsingError.unknown) + } + } + } + } + }} public extension V4Environment { var usdcTokenInfo: TokenInfo? { diff --git a/dydx/dydxStateManager/dydxStateManager/Models+Ext.swift b/dydx/dydxStateManager/dydxStateManager/Models+Ext.swift index a0791b60f..88135140c 100644 --- a/dydx/dydxStateManager/dydxStateManager/Models+Ext.swift +++ b/dydx/dydxStateManager/dydxStateManager/Models+Ext.swift @@ -16,6 +16,8 @@ extension ParsingError: Error { } return message } + + public static let unknown = ParsingError(type: .unhandled, message: "", stringKey: "APP.GENERAL.UNKNOWN_ERROR", stackTrace: nil, codespace: nil) } public extension TradeInput { diff --git a/dydx/dydxViews/dydxViews/_v4/CancelOrders/dydxCancelPendingIsolatedOrdersView.swift b/dydx/dydxViews/dydxViews/_v4/CancelOrders/dydxCancelPendingIsolatedOrdersView.swift index 54782a51c..d34ce5783 100644 --- a/dydx/dydxViews/dydxViews/_v4/CancelOrders/dydxCancelPendingIsolatedOrdersView.swift +++ b/dydx/dydxViews/dydxViews/_v4/CancelOrders/dydxCancelPendingIsolatedOrdersView.swift @@ -11,22 +11,34 @@ import Utilities import SDWebImageSwiftUI public class dydxCancelPendingIsolatedOrdersViewModel: PlatformViewModel { + public enum State { + case readyToSubmit + case failed + case submitting + case resubmitting + } + + @Published public var state: State = .readyToSubmit + @Published public var marketLogoUrl: URL? @Published public var marketName: String @Published public var marketId: String @Published public var orderCount: Int + @Published public var failureCount: Int? @Published public var cancelAction: (() -> Void) public init(marketLogoUrl: URL?, marketName: String, marketId: String, orderCount: Int, + failureCount: Int? = nil, cancelAction: @escaping (() -> Void) ) { self.marketLogoUrl = marketLogoUrl self.marketName = marketName self.marketId = marketId self.orderCount = orderCount + self.failureCount = failureCount self.cancelAction = cancelAction } @@ -35,6 +47,7 @@ public class dydxCancelPendingIsolatedOrdersViewModel: PlatformViewModel { marketName: "Ethereum", marketId: "ETH-USDC", orderCount: 1, + failureCount: nil, cancelAction: {} ) } @@ -42,8 +55,74 @@ public class dydxCancelPendingIsolatedOrdersViewModel: PlatformViewModel { public override func createView(parentStyle: ThemeStyle = ThemeStyle.defaultStyle, styleKey: String? = nil) -> PlatformUI.PlatformView { PlatformView(viewModel: self, parentStyle: parentStyle, styleKey: styleKey) { [weak self] _ in guard let self = self else { return AnyView(PlatformView.nilView) } - return AnyView(CancelView(viewModel: self)) + switch self.state { + case .readyToSubmit, .submitting: + return CancelView(viewModel: self).wrappedInAnyView() + case .failed, .resubmitting: + return TryAgainView(viewModel: self).wrappedInAnyView() + } + } + } +} + +private struct TryAgainView: View { + @StateObject var viewModel = dydxCancelPendingIsolatedOrdersViewModel.previewValue + + var errorImage: some View { + Image("icon_error", bundle: .dydxView) + .resizable() + .templateColor(.colorRed) + .scaledToFit() + .frame(width: 40, height: 40) + .padding(.all, 20) + .themeColor(background: .layer3) + .clipShape(.circle) + } + + var title: some View { + if let failureCount = viewModel.failureCount { + let key = viewModel.failureCount == 1 ? "APP.TRADE.CANCELING_ONE_ORDER_FAILED" : "APP.TRADE.CANCELING_N_ORDERS_FAILED" + let params = ["COUNT": "\(failureCount)"] + return Text(localizerPathKey: key, params: params) + .themeFont(fontType: .plus, fontSize: .largest) + .themeColor(foreground: .textPrimary) + } else { + return Text("") + } + + } + + var button: some View { + let content: Text + let buttonState: PlatformButtonState = viewModel.state == .resubmitting ? .disabled : .destructive + if viewModel.state == .resubmitting { + content = Text(localizerPathKey: "APP.TRADE.CANCELING_ORDERS_COUNT", params: ["COUNT": "\(viewModel.orderCount)"]) + .themeFont(fontType: .base, fontSize: .medium) + .themeColor(foreground: .textTertiary) + } else { + content = Text(localizerPathKey: "APP.ONBOARDING.TRY_AGAIN") + .themeFont(fontType: .base, fontSize: .medium) + .themeColor(foreground: .textPrimary) } + return PlatformButtonViewModel(content: content.wrappedViewModel, + type: .defaultType(), + state: buttonState, + action: viewModel.cancelAction) + .createView() + } + + var body: some View { + VStack(spacing: 24) { + errorImage + title + button + } + .padding(.horizontal, 24) + .padding(.top, 60) + .padding(.bottom, max((safeAreaInsets?.bottom ?? 0), 24)) + .themeColor(background: .layer4) + .makeSheet(sheetStyle: .fitSize) + .ignoresSafeArea(edges: [.bottom]) } } @@ -60,7 +139,7 @@ private struct CancelView: View { var title: some View { return Text(localizerPathKey: "APP.TRADE.CANCEL_ORDERS") - .themeFont(fontType: .base, fontSize: .large) + .themeFont(fontType: .plus, fontSize: .largest) .themeColor(foreground: .textPrimary) } @@ -93,14 +172,22 @@ private struct CancelView: View { } var button: some View { - let key = viewModel.orderCount == 1 ? "APP.TRADE.CANCEL_ORDER" : "APP.TRADE.CANCEL_ORDERS_COUNT" - let params = ["COUNT": "\(viewModel.orderCount)"] - let content = Text(localizerPathKey: key, params: params) - .themeFont(fontType: .base, fontSize: .medium) - .themeColor(foreground: .textPrimary) + let content: Text + let buttonState: PlatformButtonState = viewModel.state == .submitting ? .disabled : .destructive + if viewModel.state == .submitting { + content = Text(localizerPathKey: "APP.TRADE.CANCELING_ORDERS_COUNT", params: ["COUNT": "\(viewModel.orderCount)"]) + .themeFont(fontType: .base, fontSize: .medium) + .themeColor(foreground: .textTertiary) + } else { + let key = viewModel.orderCount == 1 ? "APP.TRADE.CANCEL_ORDER" : "APP.TRADE.CANCEL_ORDERS_COUNT" + let params = ["COUNT": "\(viewModel.orderCount)"] + content = Text(localizerPathKey: key, params: params) + .themeFont(fontType: .base, fontSize: .medium) + .themeColor(foreground: .colorRed) + } return PlatformButtonViewModel(content: content.wrappedViewModel, type: .defaultType(), - state: .destructive, + state: buttonState, action: viewModel.cancelAction) .createView() }