diff --git a/RadixWallet/Core/DesignSystem/Components/RadioButton.swift b/RadixWallet/Core/DesignSystem/Components/RadioButton.swift index 76b27fdcbb..e6646a5a86 100644 --- a/RadixWallet/Core/DesignSystem/Components/RadioButton.swift +++ b/RadixWallet/Core/DesignSystem/Components/RadioButton.swift @@ -3,7 +3,6 @@ public struct RadioButton: View { public enum State { case unselected case selected - case disabled } public enum Appearance { @@ -12,32 +11,39 @@ public struct RadioButton: View { } public let appearance: Appearance - public var state: State + public let state: State + public let isDisabled: Bool public init( appearance: Appearance, - state: State + state: State, + disabled: Bool = false ) { self.appearance = appearance self.state = state + self.isDisabled = disabled } } extension RadioButton { public var body: some View { - let resource: ImageAsset = switch (appearance, state) { - case (.light, .unselected): + let resource: ImageAsset = switch (appearance, state, isDisabled) { + case (.light, .unselected, false): AssetResource.radioButtonLightUnselected - case (.light, .selected): + case (.light, .selected, false): AssetResource.radioButtonLightSelected - case (.light, .disabled): + case (.light, .selected, true): AssetResource.radioButtonLightDisabled - case (.dark, .unselected): + case (.light, .unselected, true): + AssetResource.radioButtonLightDisabledUnselected + case (.dark, .unselected, false): AssetResource.radioButtonDarkUnselected - case (.dark, .selected): + case (.dark, .selected, false): AssetResource.radioButtonDarkSelected - case (.dark, .disabled): + case (.dark, .selected, true): AssetResource.radioButtonDarkDisabled + case (.dark, .unselected, true): + AssetResource.radioButtonDarkDisabledUnselected } return Image(asset: resource) diff --git a/RadixWallet/Core/Resources/Generated/AssetResource.generated.swift b/RadixWallet/Core/Resources/Generated/AssetResource.generated.swift index 4867bb4fd2..dee29ddd95 100644 --- a/RadixWallet/Core/Resources/Generated/AssetResource.generated.swift +++ b/RadixWallet/Core/Resources/Generated/AssetResource.generated.swift @@ -82,9 +82,11 @@ public enum AssetResource { public static let lock = ImageAsset(name: "lock") public static let minusCircle = ImageAsset(name: "minus-circle") public static let plusCircle = ImageAsset(name: "plus-circle") + public static let radioButtonDarkDisabledUnselected = ImageAsset(name: "radioButton-dark-disabled-unselected") public static let radioButtonDarkDisabled = ImageAsset(name: "radioButton-dark-disabled") public static let radioButtonDarkSelected = ImageAsset(name: "radioButton-dark-selected") public static let radioButtonDarkUnselected = ImageAsset(name: "radioButton-dark-unselected") + public static let radioButtonLightDisabledUnselected = ImageAsset(name: "radioButton-light-disabled-unselected") public static let radioButtonLightDisabled = ImageAsset(name: "radioButton-light-disabled") public static let radioButtonLightSelected = ImageAsset(name: "radioButton-light-selected") public static let radioButtonLightUnselected = ImageAsset(name: "radioButton-light-unselected") diff --git a/RadixWallet/Core/Resources/Resources/Assets.xcassets/Common/radioButton-dark-disabled-unselected.imageset/Contents.json b/RadixWallet/Core/Resources/Resources/Assets.xcassets/Common/radioButton-dark-disabled-unselected.imageset/Contents.json new file mode 100644 index 0000000000..91b66b4677 --- /dev/null +++ b/RadixWallet/Core/Resources/Resources/Assets.xcassets/Common/radioButton-dark-disabled-unselected.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "radioButton-dark-disabled-unselected.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RadixWallet/Core/Resources/Resources/Assets.xcassets/Common/radioButton-dark-disabled-unselected.imageset/radioButton-dark-disabled-unselected.pdf b/RadixWallet/Core/Resources/Resources/Assets.xcassets/Common/radioButton-dark-disabled-unselected.imageset/radioButton-dark-disabled-unselected.pdf new file mode 100644 index 0000000000..bcebabc25b Binary files /dev/null and b/RadixWallet/Core/Resources/Resources/Assets.xcassets/Common/radioButton-dark-disabled-unselected.imageset/radioButton-dark-disabled-unselected.pdf differ diff --git a/RadixWallet/Core/Resources/Resources/Assets.xcassets/Common/radioButton-light-disabled-unselected.imageset/Contents.json b/RadixWallet/Core/Resources/Resources/Assets.xcassets/Common/radioButton-light-disabled-unselected.imageset/Contents.json new file mode 100644 index 0000000000..ea73614789 --- /dev/null +++ b/RadixWallet/Core/Resources/Resources/Assets.xcassets/Common/radioButton-light-disabled-unselected.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "radioButton-light-disabled-unselected.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RadixWallet/Core/Resources/Resources/Assets.xcassets/Common/radioButton-light-disabled-unselected.imageset/radioButton-light-disabled-unselected.pdf b/RadixWallet/Core/Resources/Resources/Assets.xcassets/Common/radioButton-light-disabled-unselected.imageset/radioButton-light-disabled-unselected.pdf new file mode 100644 index 0000000000..87fce75d40 Binary files /dev/null and b/RadixWallet/Core/Resources/Resources/Assets.xcassets/Common/radioButton-light-disabled-unselected.imageset/radioButton-light-disabled-unselected.pdf differ diff --git a/RadixWallet/Features/TransactionReviewFeature/CustomizeFees/CustomizeFees+View.swift b/RadixWallet/Features/TransactionReviewFeature/CustomizeFees/CustomizeFees+View.swift index 3f2096a65a..8b8d252147 100644 --- a/RadixWallet/Features/TransactionReviewFeature/CustomizeFees/CustomizeFees+View.swift +++ b/RadixWallet/Features/TransactionReviewFeature/CustomizeFees/CustomizeFees+View.swift @@ -58,7 +58,7 @@ extension CustomizeFees { public struct ViewState: Equatable { let mode: TransactionFee.Mode let feePayer: Account? - let feePayingValidation: FeeValidationOutcome? + let feePayingValidation: FeePayerValidationOutcome? } @MainActor diff --git a/RadixWallet/Features/TransactionReviewFeature/CustomizeFees/CustomizeFees.swift b/RadixWallet/Features/TransactionReviewFeature/CustomizeFees/CustomizeFees.swift index 302bbe0cf5..7c4cc1e915 100644 --- a/RadixWallet/Features/TransactionReviewFeature/CustomizeFees/CustomizeFees.swift +++ b/RadixWallet/Features/TransactionReviewFeature/CustomizeFees/CustomizeFees.swift @@ -27,7 +27,7 @@ public struct CustomizeFees: FeatureReducer, Sendable { reviewedTransaction.transactionFee } - var feePayingValidation: FeeValidationOutcome? { + var feePayingValidation: FeePayerValidationOutcome? { reviewedTransaction.feePayingValidation.wrappedValue } @@ -107,7 +107,13 @@ public struct CustomizeFees: FeatureReducer, Sendable { public func reduce(into state: inout State, viewAction: ViewAction) -> Effect { switch viewAction { case .changeFeePayerTapped: - state.destination = .selectFeePayer(.init(feePayer: state.feePayer, transactionFee: state.transactionFee)) + state.destination = .selectFeePayer( + .init( + reviewedTransaction: state.reviewedTransaction, + selectedFeePayer: state.feePayer, + transactionFee: state.transactionFee + ) + ) return .none case .toggleMode: state.reviewedTransaction.transactionFee.toggleMode() diff --git a/RadixWallet/Features/TransactionReviewFeature/SelectFeePayer/SelectFeePayer+View.swift b/RadixWallet/Features/TransactionReviewFeature/SelectFeePayer/SelectFeePayer+View.swift index 5175d65774..36a56bf0a9 100644 --- a/RadixWallet/Features/TransactionReviewFeature/SelectFeePayer/SelectFeePayer+View.swift +++ b/RadixWallet/Features/TransactionReviewFeature/SelectFeePayer/SelectFeePayer+View.swift @@ -2,34 +2,24 @@ import ComposableArchitecture import SwiftUI extension SelectFeePayer.State { - var viewState: SelectFeePayer.ViewState { - .init( - feePayerCandidates: feePayerCandidates.rawValue, - fee: transactionFee.totalFee.displayedTotalFee, - selectedPayer: feePayer - ) + var feeString: String { + transactionFee.totalFee.displayedTotalFee } -} -// MARK: - SelectFeePayer.View -extension SelectFeePayer { - public struct ViewState: Equatable { - let feePayerCandidates: Loadable> - let fee: String - let selectedPayer: FeePayerCandidate? - - var selectButtonControlState: ControlState { - switch feePayerCandidates { - case .idle, .loading: - .loading(.local) - case .success: - .enabled - case .failure: - .disabled - } + var selectButtonControlState: ControlState { + switch feePayerCandidates { + case .idle, .loading: + .loading(.local) + case .success: + .enabled + case .failure: + .disabled } } +} +// MARK: - SelectFeePayer.View +extension SelectFeePayer { @MainActor public struct View: SwiftUI.View { private let store: StoreOf @@ -39,7 +29,7 @@ extension SelectFeePayer { } public var body: some SwiftUI.View { - WithViewStore(store, observe: \.viewState, send: { .view($0) }) { viewStore in + WithViewStore(store, observe: { $0 }, send: { .view($0) }) { viewStore in VStack { Text(L10n.CustomizeNetworkFees.SelectFeePayer.navigationTitle) .multilineTextAlignment(.center) @@ -48,7 +38,7 @@ extension SelectFeePayer { .padding(.horizontal, .medium1) .padding(.bottom, .small2) - Text(L10n.CustomizeNetworkFees.SelectFeePayer.subtitle(viewStore.fee)) + Text(L10n.CustomizeNetworkFees.SelectFeePayer.subtitle(viewStore.feeString)) .multilineTextAlignment(.center) .textStyle(.body1HighImportance) .foregroundColor(.app.gray2) @@ -63,8 +53,8 @@ extension SelectFeePayer { VStack(spacing: .small1) { Selection( viewStore.binding( - get: \.selectedPayer, - send: { .selectedPayer($0) } + get: \.selectedFeePayer, + send: { .selectedFeePayer($0) } ), from: candidates ) { item in @@ -79,7 +69,7 @@ extension SelectFeePayer { .padding(.horizontal, .medium1) .padding(.bottom, .medium2) .onFirstAppear { - proxy.scrollTo(viewStore.selectedPayer?.id, anchor: .center) + proxy.scrollTo(viewStore.selectedFeePayer?.id, anchor: .center) } } } @@ -93,8 +83,8 @@ extension SelectFeePayer { } .footer { WithControlRequirements( - viewStore.selectedPayer, - forAction: { viewStore.send(.confirmedFeePayer($0)) } + viewStore.selectedFeePayer, + forAction: { viewStore.send(.confirmedFeePayer($0.candidate)) } ) { action in Button(L10n.CustomizeNetworkFees.SelectFeePayer.selectAccountButtonTitle, action: action) .buttonStyle(.primaryRectangular) @@ -114,10 +104,12 @@ enum SelectAccountToPayForFeeRow { struct ViewState: Equatable { let account: Account let fungible: ResourceBalance.ViewState.Fungible + let isBalanceInsufficient: Bool - init(candidate: FeePayerCandidate) { - self.account = candidate.account - self.fungible = .xrd(balance: .init(nominalAmount: candidate.xrdBalance), network: account.networkID) + init(candidate: ValidatedFeePayerCandidate) { + self.account = candidate.candidate.account + self.fungible = .xrd(balance: .init(nominalAmount: candidate.candidate.xrdBalance), network: account.networkID) + self.isBalanceInsufficient = candidate.outcome == .insufficientBalance } } @@ -127,22 +119,36 @@ enum SelectAccountToPayForFeeRow { let isSelected: Bool let action: () -> Void + var buttonState: RadioButton.State { + isSelected ? .selected : .unselected + } + + var isDisabled: Bool { + viewState.isBalanceInsufficient + } + var body: some SwiftUI.View { Button(action: action) { Card { - VStack(spacing: .zero) { + VStack(alignment: .leading, spacing: .zero) { AccountCard(kind: .innerCompact, account: viewState.account) HStack { ResourceBalanceView(.fungible(viewState.fungible), appearance: .compact) - RadioButton(appearance: .dark, state: isSelected ? .selected : .unselected) + RadioButton(appearance: .dark, state: buttonState, disabled: isDisabled) } .padding(.medium3) + + if viewState.isBalanceInsufficient { + WarningErrorView(text: L10n.TransactionReview.FeePayerValidation.insufficientBalance, type: .error) + .padding([.horizontal, .bottom], .medium3) + } } } } .buttonStyle(.inert) + .disabled(isDisabled) } } } diff --git a/RadixWallet/Features/TransactionReviewFeature/SelectFeePayer/SelectFeePayer.swift b/RadixWallet/Features/TransactionReviewFeature/SelectFeePayer/SelectFeePayer.swift index d09a9bb96d..299a75a9a9 100644 --- a/RadixWallet/Features/TransactionReviewFeature/SelectFeePayer/SelectFeePayer.swift +++ b/RadixWallet/Features/TransactionReviewFeature/SelectFeePayer/SelectFeePayer.swift @@ -1,27 +1,37 @@ import ComposableArchitecture import SwiftUI +// MARK: - ValidatedFeePayerCandidate +public struct ValidatedFeePayerCandidate: Sendable, Hashable, Identifiable { + public var id: FeePayerCandidate.ID { candidate.id } + public let candidate: FeePayerCandidate + public let outcome: FeePayerValidationOutcome +} + // MARK: - SelectFeePayer public struct SelectFeePayer: Sendable, FeatureReducer { public typealias FeePayerCandidates = NonEmpty> public struct State: Sendable, Hashable { - public var feePayer: FeePayerCandidate? + public let reviewedTransaction: ReviewedTransaction + public var selectedFeePayer: ValidatedFeePayerCandidate? public let transactionFee: TransactionFee - public var feePayerCandidates: Loadable = .idle + public var feePayerCandidates: Loadable<[ValidatedFeePayerCandidate]> = .idle public init( - feePayer: FeePayerCandidate?, + reviewedTransaction: ReviewedTransaction, + selectedFeePayer: FeePayerCandidate?, transactionFee: TransactionFee ) { - self.feePayer = feePayer + self.reviewedTransaction = reviewedTransaction + self.selectedFeePayer = selectedFeePayer.map { .init(candidate: $0, outcome: reviewedTransaction.validateFeePayer($0)) } self.transactionFee = transactionFee } } public enum ViewAction: Sendable, Equatable { case task - case selectedPayer(FeePayerCandidate?) + case selectedFeePayer(ValidatedFeePayerCandidate?) case confirmedFeePayer(FeePayerCandidate) case pullToRefreshStarted case closeButtonTapped @@ -47,8 +57,8 @@ public struct SelectFeePayer: Sendable, FeatureReducer { state.feePayerCandidates = .loading return loadCandidates(refresh: false) - case let .selectedPayer(candidate): - state.feePayer = candidate + case let .selectedFeePayer(candidate): + state.selectedFeePayer = candidate return .none case let .confirmedFeePayer(payer): @@ -67,7 +77,13 @@ public struct SelectFeePayer: Sendable, FeatureReducer { public func reduce(into state: inout State, internalAction: InternalAction) -> Effect { switch internalAction { case let .feePayerCandidatesLoaded(.success(candidates)): - state.feePayerCandidates = .success(candidates) + let validated = candidates.rawValue.map { candidate in + ValidatedFeePayerCandidate( + candidate: candidate, + outcome: state.reviewedTransaction.validateFeePayer(candidate) + ) + } + state.feePayerCandidates = .success(validated) return .none case let .feePayerCandidatesLoaded(.failure(error)): errorQueue.schedule(error) diff --git a/RadixWallet/Features/TransactionReviewFeature/TransactionReview.swift b/RadixWallet/Features/TransactionReviewFeature/TransactionReview.swift index 9fbc803be6..f4d66b5b6d 100644 --- a/RadixWallet/Features/TransactionReviewFeature/TransactionReview.swift +++ b/RadixWallet/Features/TransactionReviewFeature/TransactionReview.swift @@ -972,18 +972,18 @@ public struct ReviewedTransaction: Hashable, Sendable { let isNonConforming: Bool } -// MARK: - FeeValidationOutcome -enum FeeValidationOutcome: Equatable { - case valid(Details?) +// MARK: - FeePayerValidationOutcome +public enum FeePayerValidationOutcome: Sendable, Hashable { case needsFeePayer case insufficientBalance + case valid(Details?) - enum Details { + public enum Details: Sendable { case introducesNewAccount case feePayerSuperfluous } - var isValid: Bool { + public var isValid: Bool { guard case .valid = self else { return false } return true } @@ -994,39 +994,41 @@ extension ReviewedTransaction { Set(accountWithdraws.keys).union(accountDeposits.keys) } - var feePayingValidation: Loadable { - feePayer.map { selected in - guard let selected else { - if transactionFee.totalFee.lockFee == .zero { - // No fee is required - no fee payer needed - return .valid(.feePayerSuperfluous) - } else { - // Fee is required, but no fee payer selected - invalid - return .needsFeePayer - } + var feePayingValidation: Loadable { + feePayer.map(validateFeePayer) + } + + func validateFeePayer(_ candidate: FeePayerCandidate?) -> FeePayerValidationOutcome { + guard let candidate else { + if transactionFee.totalFee.lockFee == .zero { + // No fee is required - no fee payer needed + return .valid(.feePayerSuperfluous) + } else { + // Fee is required, but no fee payer selected - invalid + return .needsFeePayer } + } - let xrdAddress: ResourceAddress = .xrd(on: networkID) - let feePayerWithdraws = accountWithdraws[selected.account.address] ?? [] - let xrdTransfer: Decimal192 = feePayerWithdraws.reduce(.zero) { partialResult, resource in - if case let .fungible(resourceAddress, indicator) = resource, resourceAddress == xrdAddress { - return partialResult + indicator.amount - } - return partialResult + let xrdAddress: ResourceAddress = .xrd(on: networkID) + let feePayerWithdraws = accountWithdraws[candidate.account.address] ?? [] + let xrdTransfer: Decimal192 = feePayerWithdraws.reduce(.zero) { partialResult, resource in + if case let .fungible(resourceAddress, indicator) = resource, resourceAddress == xrdAddress { + return partialResult + indicator.amount } + return partialResult + } - let totalAmountNeeded = xrdTransfer + transactionFee.totalFee.lockFee + let totalAmountNeeded = xrdTransfer + transactionFee.totalFee.lockFee - guard selected.xrdBalance >= totalAmountNeeded else { - // Insufficient balance to pay for withdraws and transaction fee - return .insufficientBalance - } + guard candidate.xrdBalance >= totalAmountNeeded else { + // Insufficient balance to pay for withdraws and transaction fee + return .insufficientBalance + } - if !involvedAccounts.contains(selected.account.address) { - return .valid(.introducesNewAccount) - } else { - return .valid(nil) - } + if !involvedAccounts.contains(candidate.account.address) { + return .valid(.introducesNewAccount) + } else { + return .valid(nil) } } } diff --git a/RadixWalletTests/Features/TransactionReviewFeatureTests/CustomizeFeePayerTests.swift b/RadixWalletTests/Features/TransactionReviewFeatureTests/CustomizeFeePayerTests.swift index bad1642af8..dcf8b2f54c 100644 --- a/RadixWalletTests/Features/TransactionReviewFeatureTests/CustomizeFeePayerTests.swift +++ b/RadixWalletTests/Features/TransactionReviewFeatureTests/CustomizeFeePayerTests.swift @@ -46,7 +46,13 @@ final class CustomizeFeePayerTests: TestCase { let selectedFeePayer = FeePayerCandidate(account: .previewValue1, xrdBalance: 20) await sut.send(.view(.changeFeePayerTapped)) { - $0.destination = .selectFeePayer(.init(feePayer: nil, transactionFee: .nonContingentLockPaying)) + $0.destination = .selectFeePayer( + .init( + reviewedTransaction: transactionStub, + selectedFeePayer: nil, + transactionFee: .nonContingentLockPaying + ) + ) } await sut.send(.destination(.presented(.selectFeePayer(.delegate(.selected(selectedFeePayer)))))) { $0.destination = nil