taler-ios

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

commit d3b950b0bb25f209eba0b72b152a639ad542e413
parent 5090aad727eb51154142d403eaf15bbfbae94860
Author: Marc Stibane <marc@taler.net>
Date:   Wed,  7 Oct 2026 16:24:00 +0200

Complete DD94 Discounts & Passes

Diffstat:
MTalerWallet1/Views/Sheets/Payment/ChoicesView.swift | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
MTalerWallet1/Views/Transactions/PaidTransactionDetailV.swift | 50++++++++++++++++++++++++++++++++++----------------
MTalerWallet1/Views/Transactions/PaymentTransactionView.swift | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++------------
3 files changed, 189 insertions(+), 62 deletions(-)

diff --git a/TalerWallet1/Views/Sheets/Payment/ChoicesView.swift b/TalerWallet1/Views/Sheets/Payment/ChoicesView.swift @@ -98,32 +98,62 @@ struct InputTokenView: View, Sendable { // MARK: - struct ChoicesView: View, Sendable { let stack: CallStack - let choiceTriple: [ChoiceTriple] + let choiceTriples: [ChoiceTriple] + let tokenFamilies: [String: ContractTokenFamily]? // token_family_slug: String let showHeader: Bool let automaticIndex: Int? @Binding var selectedChoice: Int? + @Environment(\.colorScheme) private var colorScheme + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + var body: some View { - ForEach(choiceTriple, id: \.1) { choice in - let selectionDetail: ChoiceSelectionDetail = choice.0 - let contractChoice: ContractChoice = choice.1 - let index: Int = choice.2 - let isPaymentPossible = selectionDetail.status == .paymentPossible - let payAutomatic = automaticIndex == index - let scopeInfo = selectionDetail.scopeInfo - let amount = selectionDetail.amountRaw - /// If it contains money, always show it. Disable but don’t hide it if insufficient - that serves as advertisement what you could buy if you had more money / tokens. - /// If it only contains tokens, and insufficient, then hide it. Most probably there is a money payment option to acquire those tokens, which is advertisement enough. - /// Tokens only, paymentPossible. If automaticIndex, then also hide it (and auto-pay it). Otherwise show it, so the user can select it. - if (!amount.isZero || isPaymentPossible) && !payAutomatic { - Section { - let amountV = HStack { - Spacer() - AmountV(stack: stack.push(), - scope: scopeInfo, - amount: contractChoice.amount) - .foregroundColor(isPaymentPossible ? .primary : .secondary ) - } .overlay { + Section { + ForEach(choiceTriples, id: \.2) { triple in + let selectionDetail: ChoiceSelectionDetail = triple.0 + let contractChoice: ContractChoice = triple.1 + let index: Int = triple.2 + let isPaymentPossible = selectionDetail.status == .paymentPossible + let payAutomatic = automaticIndex == index + let scopeInfo = selectionDetail.scopeInfo + let amount = selectionDetail.amountRaw + /// If it contains money, always show it. Disable but don’t hide it if insufficient - that serves as advertisement what you could buy if you had more money / tokens. + /// If it only contains tokens, and insufficient, then hide it. Most probably there is a money payment option to acquire those tokens, which is advertisement enough. + /// Tokens only, paymentPossible. If automaticIndex, then also hide it (and auto-pay it). Otherwise show it, so the user can select it. + if (!amount.isZero || isPaymentPossible) && !payAutomatic { + VStack(alignment: .leading) { + if showHeader { // V1 + if let description = contractChoice.locDescription { + Text(description) + .talerFont(.body) + } } + + HStack { + Spacer() + AmountV(stack: stack.push(), + scope: scopeInfo, + amount: contractChoice.amount) + .foregroundColor(isPaymentPossible ? .primary : .secondary) + } + if let tokenFamilies { + // Spend Input tokens + let inputs = contractChoice.inputs + if !inputs.isEmpty { + Text("Spend now:") + .talerFont(.body) + .padding(.bottom, -4) + InputTokenView(inputs: inputs, tokenFamilies: tokenFamilies) + } + // Receive output tokens + let outputs = contractChoice.outputs + if !outputs.isEmpty { + Text("Get with your purchase:") + .talerFont(.body) + .padding(.bottom, -4) + OutputTokenView(outputs: outputs, tokenFamilies: tokenFamilies) + } + } + }.overlay { Color.primary.opacity(INVISIBLE) // Color.clear doesn't accept taps! .contentShape(.rect) .onTapGesture { @@ -132,20 +162,58 @@ struct ChoicesView: View, Sendable { } } } - if index == selectedChoice { - amountV.listRowBackground ( - SelectedBackground(useColor: isPaymentPossible) -// .padding(.top, n == 1 ? 0 : -15) -// .padding(.bottom, n == 10 ? 0 : -15) - ) - } else { - amountV + .listRowBackground( + SelectedBackground(useColor: index == selectedChoice ? isPaymentPossible : nil) + ) + } + }.listRowSeparator(.hidden) + } header: { + Text("Payment Options") + .talerFont(.title3) + .foregroundColor(WalletColors().secondary2(colorScheme, colorSchemeContrast)) + } + } +} +// MARK: - +struct ProductsView: View, Sendable { + let stack: CallStack + let scope: ScopeInfo? + let products: [ProductSold] + + @Environment(\.colorScheme) private var colorScheme + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + + var body: some View { + if !products.isEmpty { + Section { + ForEach(products, id: \.self) { product in + HStack { + VStack(alignment: .leading) { + if let name = product.productName { + Text(name) + .bold() + .talerFont(.body) + } + Text(product.locDescription) + .talerFont(.subheadline) + + HStack { + if let quantity = product.unitQuantity { + Text(quantity) + } + if let unit = product.unit { + // TODO: MerchantUnit + Text(unit) + } + }.talerFont(.subheadline) + } + Spacer() + if let price = product.prices?.first ?? product.price { + AmountV(scope, price, isNegative: nil) + } } - } header: { - if showHeader { - if let description = contractChoice.locDescription { - Text(description) - } } } + .listRowSeparator(.hidden) + } } } } diff --git a/TalerWallet1/Views/Transactions/PaidTransactionDetailV.swift b/TalerWallet1/Views/Transactions/PaidTransactionDetailV.swift @@ -44,12 +44,40 @@ struct PaidTransactionDetailV: View { } if let info { // Text(info.merchant.name) TODO: show name & logo here? - Text("Order-ID:") - .talerFont(.title3) - .listRowSeparator(.hidden) - Text(info.orderId) - .talerFont(.body) - + if common.isDone { + VStack(alignment: .leading) { + Text("Order-ID:") + .talerFont(.title3) + HStack { + Spacer() + Text(info.orderId) + .talerFont(.body) + } + } + } + } + VStack(alignment: .leading) { + if let info { + Text(info.summary) + .talerFont(.title3) + } + if let choiceIndex = details.choiceIndex { + if let contractTerms = details.contractTerms { + if let choices = contractTerms.choices, + choices.indices.contains(choiceIndex) { + let choice = choices[choiceIndex] + HStack { + if let info { + Spacer() + } + Text(choice.locDescription ?? "Index: \(choiceIndex)") // fallback should never happen + .talerFont(.body) + } + } + } + } + } + if let info { if let fulfillmentUrl = info.fulfillmentUrl, common.isDone { // per api-common.rst's WebURL definition, fulfillment_url must be http(s) - // reject any other scheme (tel:, sms:, a third-party app's custom scheme, ...) @@ -84,16 +112,6 @@ struct PaidTransactionDetailV: View { } } } - if let choiceIndex = details.choiceIndex { - if let contractTerms = details.contractTerms { - if let choices = contractTerms.choices, - choices.indices.contains(choiceIndex) { - let choice = choices[choiceIndex] - Text(choice.locDescription ?? "Index: \(choiceIndex)") // fallback should never happen - .talerFont(.body) - } - } - } } if let products = terms?.products { if !products.isEmpty { diff --git a/TalerWallet1/Views/Transactions/PaymentTransactionView.swift b/TalerWallet1/Views/Transactions/PaymentTransactionView.swift @@ -13,23 +13,49 @@ import SymLog struct MerchantHeader: View { let terms: MerchantContractTerms? + @Environment(\.colorScheme) private var colorScheme + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + @State private var image: Image? = nil + func compactAddress(_ address: Location?) -> String? { + if let address { + var result: String = EMPTYSTRING + if let street = address.street { + result.append(street) + } + if let town = address.town { + if !result.isEmpty { + result.append(", ") + } + result.append(town) + } + if let country = address.country { + if !result.isEmpty { + result.append(", ") + } + result.append(country) + } + return result.isEmpty ? nil : result + } + return nil + } + var body: some View { if let terms { Section { Text(terms.locSummary) .talerFont(.title3) } header: { - HStack { - Spacer() - VStack(alignment: .center) { + VStack(alignment: .leading) { + HStack { if terms.merchant.logo != nil { if let image { image .resizable() .aspectRatio(contentMode: .fit) .frame(maxHeight: 50) + .padding(.trailing, 8) } } else { #if TALER_NIGHTLY @@ -39,16 +65,25 @@ struct MerchantHeader: View { .frame(width: 44, height: 44) #endif } - let merchant = terms.merchant.name - Text(merchant) - .talerFont(.title3) + VStack(alignment: .leading) { + let merchant = terms.merchant + Text(merchant.name) + .talerFont(.title3) + if let address = compactAddress(merchant.address) { + Text(address) + .talerFont(.body) + } + } } .foregroundStyle(Color.primary) + .padding(.bottom) .task { if let imageBase64 = terms.merchant.logo { image = Image(imageBase64: imageBase64) } } - Spacer() + Text("Summary") + .talerFont(.title3) + .foregroundColor(WalletColors().secondary2(colorScheme, colorSchemeContrast)) } } } @@ -147,22 +182,28 @@ struct PaymentTransactionView: View { } else { symLog.log("❗️❗️ choicesForPayment exists, no need for task: \(state)") } - } + } // loadChoicesForPayment let choicesTuple = controller.choicesTuple if let txId = choicesTuple.0, let choicesForPayment = choicesTuple.1 { - let terms = choicesForPayment.contractTerms if txId == common.transactionId { + let terms = choicesForPayment.contractTerms + if let products = terms.products { + ProductsView(stack: stack.push(), + scope: scope, + products: products) + } if let (choices, showHeader) = choicesForPayment.choiceTriple() { let hasAutomatic = choicesForPayment.automaticExecution ?? false let automaticIndex = hasAutomatic ? choicesForPayment.automaticExecutableIndex : nil ChoicesView(stack: stack.push(), - choiceTriple: choices, + choiceTriples: choices, + tokenFamilies: terms.tokenFamilies, showHeader: showHeader, automaticIndex: automaticIndex, selectedChoice: $selectedChoice) .task { - automaticOrDefault(automaticIndex, choicesForPayment.defaultChoiceIndex, of: choices) + automaticOrDefault(automaticIndex, choicesForPayment.defaultChoiceIndex, of: choices) } .onChange(of: selectedChoice) { newValue in if let newValue, newValue < choices.count { @@ -185,7 +226,7 @@ struct PaymentTransactionView: View { PaymentView2(stack: stack.push(), // TODO: details.info.merchant.name paid: false, raw: selectionDetail.amountRaw, - effective: effective, + effective: effective, // V0? firstScope: scope, baseURL: nil, summary: summary,