taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

commit 1a1ed961660b7ebc878d1a1c422c0b67d427cb87
parent 32d6729e955835f9d40ce076f1e6cbbb04ffbb33
Author: Marc Stibane <marc@taler.net>
Date:   Tue, 30 Jun 2026 08:01:31 +0200

Let controller handle choicesForPayment

Diffstat:
MTalerWallet1/Controllers/Controller.swift | 29+++++++++++++++++++++++++++--
MTalerWallet1/Model/Model+Payment.swift | 28+++++++++++++++++++++++++---
MTalerWallet1/Views/Sheets/Payment/ChoicesView.swift | 2--
MTalerWallet1/Views/Transactions/TransactionSummaryList.swift | 124+++++++++++++++++++++++--------------------------------------------------------
MTalerWallet1/Views/Transactions/TransactionTypeDetail.swift | 23+++++++++++++++++++----
5 files changed, 107 insertions(+), 99 deletions(-)

diff --git a/TalerWallet1/Controllers/Controller.swift b/TalerWallet1/Controllers/Controller.swift @@ -139,6 +139,8 @@ class Controller: ObservableObject { @Published var oimSheetActive: Bool = false @Published var diagnosticModeEnabled: Bool = false @Published var talerURI: URL? = nil + @Published var choicesForPayment: ChoicesForPayment? = nil + @AppStorage("useHaptics") var useHaptics: Bool = true // extension mustn't define this, so it must be here @AppStorage("playSounds") var playSounds: Bool = false @AppStorage("talerFontIndex") var talerFontIndex: Int = 0 // extension mustn't define this, so it must be here @@ -155,6 +157,7 @@ class Controller: ObservableObject { private var currencyInfos: [ScopeInfo : CurrencyInfo] var exchanges: [Exchange] var messageForSheet: String? = nil + var isLoadingChoices: String? = nil private let monitor = NWPathMonitor() @@ -344,7 +347,7 @@ class Controller: ObservableObject { } return nil } - // MARK: - +// MARK: - @MainActor @discardableResult func loadDiscounts(_ stack: CallStack,_ model: WalletModel) async -> Int? { @@ -360,7 +363,7 @@ class Controller: ObservableObject { } return nil } - // MARK: - +// MARK: - @MainActor @discardableResult func loadSubscriptions(_ stack: CallStack,_ model: WalletModel) async -> Int? { @@ -376,6 +379,28 @@ class Controller: ObservableObject { } return nil } + // MARK: - + @MainActor + @discardableResult + func loadChoicesForPayment(_ stack: CallStack, + _ model: WalletModel, + txId: String) async -> Bool { + self.logger.log("getChoicesForPayment: \(txId)") + if isLoadingChoices != txId { + isLoadingChoices = txId + if let choiceResponse = try? await model.getChoicesForPayment(txId) { + choicesForPayment = choiceResponse + isLoadingChoices = nil + return true + } else { + isLoadingChoices = nil + self.logger.log("getChoicesForPayment failed: \(txId)") + } + } else { + self.logger.log("getChoicesForPayment already in progress: \(txId)") + } + return false + } // MARK: - func exchange(for baseUrl: String) -> Exchange? { for exchange in exchanges { diff --git a/TalerWallet1/Model/Model+Payment.swift b/TalerWallet1/Model/Model+Payment.swift @@ -350,7 +350,9 @@ struct ChoiceSelectionDetail: Codable, Hashable, Sendable { var balanceDetails: PaymentInsufficientBalanceDetails? // only if insufficient } -struct GetChoicesForPaymentResult: Codable { +typealias ChoiceTriple = (ChoiceSelectionDetail, ContractChoice, Int) + +struct ChoicesForPayment: Codable { var choices: [ChoiceSelectionDetail] /** * Index of the choice in @e choices array to present to the user as default. @@ -370,11 +372,31 @@ struct GetChoicesForPaymentResult: Codable { var automaticExecutableIndex: Int? var contractTerms: MerchantContractTerms + + func choiceTriple() -> ([ChoiceTriple], Bool)? { + let terms = self.contractTerms + if let ctChoices = terms.choices { + let combined = Array(zip(choices, ctChoices, ctChoices.indices)) + return (combined, true) + } else if let amount = terms.amount { // V0 + let maxFee = terms.maxFee ?? Amount.zero(currency: amount.currencyStr) + let ctChoice = ContractChoice(amount: amount, + maxFee: maxFee, + description: terms.summary, + descriptionI18n: terms.summaryI18n, + inputs: [], + outputs: []) + let combined = Array(zip(choices, [ctChoice], [0])) + return (combined, false) + } +// symLog.log(" ❗️Yikes, neither choices nor amount in contractTerms!\n\(stack)") + return nil + } } /// A request to get an exchange's payment contract terms. fileprivate struct GetChoicesForPayment: WalletBackendFormattedRequest { - typealias Response = GetChoicesForPaymentResult + typealias Response = ChoicesForPayment func operation() -> String { "getChoicesForPayment" } func args() -> Args { Args(transactionId: transactionId, forcedCoinSel: forcedCoinSel) } @@ -575,7 +597,7 @@ extension WalletModel { } nonisolated func getChoicesForPayment(_ transactionId: String, viewHandles: Bool = false) - async throws -> GetChoicesForPaymentResult { + async throws -> ChoicesForPayment { let request = GetChoicesForPayment(transactionId: transactionId, forcedCoinSel: nil) let response = try await sendRequest(request, viewHandles: viewHandles) return response diff --git a/TalerWallet1/Views/Sheets/Payment/ChoicesView.swift b/TalerWallet1/Views/Sheets/Payment/ChoicesView.swift @@ -8,8 +8,6 @@ import SwiftUI import taler_swift -typealias ChoiceTriple = (ChoiceSelectionDetail, ContractChoice, Int) - struct ChoicesView: View, Sendable { let stack: CallStack let choiceTriple: [ChoiceTriple] diff --git a/TalerWallet1/Views/Transactions/TransactionSummaryList.swift b/TalerWallet1/Views/Transactions/TransactionSummaryList.swift @@ -81,61 +81,17 @@ struct MerchantHeader: View { } // MARK: - struct PaymentTransactionView: View { - private let symLog = SymLogV() + private let symLog = SymLogV(0) let stack: CallStack let common: TransactionCommon let paymentTransaction: PaymentTransaction @Binding var scope: ScopeInfo? @Binding var effective: Amount? @Binding var payNow: Bool - @Binding var isLoadingChoices: Bool? @Binding var selectedChoice: Int + @EnvironmentObject private var controller: Controller @EnvironmentObject private var model: WalletModel - @State var choicesForPayment: GetChoicesForPaymentResult? = nil -// @State var isLoadingChoices: Bool? = nil - - @MainActor - func choiceTriple() -> ([ChoiceTriple], Bool)? { - if let choicesForPayment { - let choices = choicesForPayment.choices - let terms = choicesForPayment.contractTerms - if let ctChoices = terms.choices { - let combined = Array(zip(choices, ctChoices, ctChoices.indices)) - return (combined, true) - } else if let amount = terms.amount { // V0 - let maxFee = terms.maxFee ?? Amount.zero(currency: amount.currencyStr) - let ctChoice = ContractChoice(amount: amount, - maxFee: maxFee, - description: terms.summary, - descriptionI18n: terms.summaryI18n, - inputs: [], - outputs: []) - let combined = Array(zip(choices, [ctChoice], [0])) - return (combined, false) - } else { - symLog.log(" ❗️Yikes, neither choices nor amount in contractTerms!\n\(stack)") - } - } - return nil - } - - private func getChoicesForPayment() async { - if isLoadingChoices == nil { - symLog.log("first getChoicesForPayment, stack: \(stack)") - isLoadingChoices = false - } - if isLoadingChoices == false { - isLoadingChoices = true - let txId = common.transactionId - if let choiceResponse = try? await model.getChoicesForPayment(txId) { - choicesForPayment = choiceResponse - isLoadingChoices = false - } - } else { - symLog.log("getChoicesForPayment already in progress") - } - } func summary(_ info: OrderShortInfo?) -> String? { if let i18nDict = info?.summary_i18n { @@ -154,16 +110,18 @@ struct PaymentTransactionView: View { } var body: some View { +#if PRINT_CHANGES let _ = Self._printChanges() let _ = symLog.vlog() - Group { - let details = paymentTransaction.details - if common.isDialog { // show payment confirmation dialog - MerchantHeader(terms: details.contractTerms) +#endif + let details = paymentTransaction.details + if common.isDialog { // show payment confirmation dialog + MerchantHeader(terms: details.contractTerms) - if let (choices, showHeader) = choiceTriple() { - let hasAutomatic = choicesForPayment?.automaticExecution ?? false - let automaticIndex = hasAutomatic ? choicesForPayment?.automaticExecutableIndex : nil + if let choicesForPayment = controller.choicesForPayment { + if let (choices, showHeader) = choicesForPayment.choiceTriple() { + let hasAutomatic = choicesForPayment.automaticExecution ?? false + let automaticIndex = hasAutomatic ? choicesForPayment.automaticExecutableIndex : nil ChoicesView(stack: stack.push(), choiceTriple: choices, showHeader: showHeader, @@ -201,33 +159,20 @@ struct PaymentTransactionView: View { balanceDetails: selectionDetail.balanceDetails) // } } - } else { // show finished payment - TransactionPayDetailV(paymentTx: paymentTransaction) // TODO: details.info.merchant.name - ThreeAmountsSheet(stack: stack.push(), - scope: scope, - common: common, - topAbbrev: String(localized: "Price:", comment: "mini"), - topTitle: String(localized: "Price (net):"), - baseURL: nil, // TODO: baseURL - noFees: nil, // TODO: noFees - feeIsNegative: false, - large: true, - summary: details.info?.summary ?? EMPTYSTRING) - } // show finished payment - } - .task(id: common.txState.hashValue) { - let state = common.txState - if common.isDialog { - if choicesForPayment == nil { - symLog.log("❗️❗️ task: \(common.txState)") - await getChoicesForPayment() - } else { - symLog.log("❗️❗️ choicesForPayment exists, no need for task: \(state)") - } - } else { - symLog.log("❗️❗️ \(common.type.rawValue), no need for task: \(state)") } - } + } else { // show finished payment + TransactionPayDetailV(paymentTx: paymentTransaction) // TODO: details.info.merchant.name + ThreeAmountsSheet(stack: stack.push(), + scope: scope, + common: common, + topAbbrev: String(localized: "Price:", comment: "mini"), + topTitle: String(localized: "Price (net):"), + baseURL: nil, // TODO: baseURL + noFees: nil, // TODO: noFees + feeIsNegative: false, + large: true, + summary: details.info?.summary ?? EMPTYSTRING) + } // show finished payment } // body } // PaymentTransactionView // MARK: - @@ -358,14 +303,17 @@ struct TransactionSummaryList: View { } func localizedState(_ txState: TransactionState) -> String { - if let minorState = txState.minor { - if developerMode { return minorState.localizedDbgState } - if talerTX.isPayment { -// return String(localized: "Payment", comment: "TxMajorState heading") + let major = txState.major + if major != .failed { + if let minorState = txState.minor { + if developerMode { return minorState.localizedDbgState } +// if talerTX.isPayment { +// return String(localized: "Payment", comment: "TxMajorState heading") +// } + return minorState.localizedState ?? major.localizedState } - return minorState.localizedState ?? txState.major.localizedState } - return txState.major.localizedState + return major.localizedState } @ViewBuilder @@ -386,7 +334,7 @@ struct TransactionSummaryList: View { imageT Spacer(minLength: 0) statusT - } + } // TODO: a11y for tx icon if developerMode { if !jsonTransaction.isEmpty { CopyButton(textToCopy: jsonTransaction, isCopied: $isCopied, title: "Copy JSON") @@ -449,10 +397,10 @@ struct TransactionSummaryList: View { } var body: some View { -//#if PRINT_CHANGES +#if PRINT_CHANGES let _ = Self._printChanges() let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear -//#endif +#endif let common = talerTX.common // let scope = common.scopes.first // might be nil if scopes == [] let locale = TalerDater.shared.locale diff --git a/TalerWallet1/Views/Transactions/TransactionTypeDetail.swift b/TalerWallet1/Views/Transactions/TransactionTypeDetail.swift @@ -19,13 +19,15 @@ struct TransactionTypeDetail: View { @Binding var scope: ScopeInfo? @Binding var effective: Amount? let hasDone: Bool + + @EnvironmentObject private var controller: Controller + @EnvironmentObject private var model: WalletModel @Environment(\.colorScheme) private var colorScheme @Environment(\.colorSchemeContrast) private var colorSchemeContrast @AppStorage("minimalistic") var minimalistic: Bool = false @State private var rotationEnabled = true @State private var ignoreAccept: Bool = false // accept could be set by OIM to trigger accept @State private var isCopied: Bool = false - @State var isLoadingChoices: Bool? = nil func refreshFee(input: Amount, output: Amount) -> Amount? { do { @@ -51,8 +53,10 @@ struct TransactionTypeDetail: View { } var body: some View { +#if PRINT_CHANGES let _ = Self._printChanges() let _ = symLog.vlog() +#endif let common = transaction.common let pending = transaction.isPending let isDialog = transaction.isDialog @@ -111,16 +115,27 @@ struct TransactionTypeDetail: View { } case .payment(let paymentTransaction): - /// this will always be recreated, thus we need to pass isLoadingChoices as Binding PaymentTransactionView(stack: stack.push(), common: common, paymentTransaction: paymentTransaction, scope: $scope, effective: $effective, payNow: $payNow, - isLoadingChoices: $isLoadingChoices, selectedChoice: $selectedChoice) - + .task(id: common.txState.hashValue) { + let state = common.txState + if common.isDialog { + if controller.choicesForPayment == nil { + symLog.log("❗️❗️ task: \(common.txState)") + await controller.loadChoicesForPayment(stack.push(),model, + txId: common.transactionId) + } else { + symLog.log("❗️❗️ choicesForPayment exists, no need for task: \(state)") + } + } else { + symLog.log("❗️❗️ \(common.type.rawValue), no need for task: \(state)") + } + } case .refund(let refundTransaction): Group { let details = refundTransaction.details // TODO: more details, details.info?.merchant.name ThreeAmountsSheet(stack: stack.push(),