// // BadgesRedeemCodeView.swift // SimpleX (iOS) // // Created by spaced4ndy on 28.07.2026. // Copyright © 2026 SimpleX Chat. All rights reserved. // import SwiftUI import SimpleXChat import CodeScanner private let badgeCodePrefix = "SB" private let badgeCodeBodyLength = 20 private let badgeCodeGroupLength = 5 // Regroups what was typed; validity and the folding of ambiguous characters are core's alone. private func formatBadgeCodeInput(_ s: String) -> String { var normalized = "" for c in s.prefix(256).uppercased() { guard c.isLetter || c.isNumber else { continue } normalized.append(c) if normalized.count == badgeCodePrefix.count + badgeCodeBodyLength { break } } guard normalized.hasPrefix(badgeCodePrefix) else { return normalized } var groups = [badgeCodePrefix] var i = normalized.index(normalized.startIndex, offsetBy: badgeCodePrefix.count) while i < normalized.endIndex { let j = normalized.index(i, offsetBy: badgeCodeGroupLength, limitedBy: normalized.endIndex) ?? normalized.endIndex groups.append(String(normalized[i.. some View { Text("Redeem code") .font(.largeTitle) .bold() .foregroundColor(theme.colors.primary) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) Text("Paste the code from your receipt.") .font(.body) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) codeField() pasteButton() ScannerInView( showQRCodeScanner: $showQRCodeScanner, scannerPaused: $submitting, processQRCode: processQRCode, scanMode: .oncePerCode, placeholderBackground: Color(.tertiarySystemFill) ) .fixedSize(horizontal: false, vertical: true) .padding(.top, 12) Spacer(minLength: 0) VStack(spacing: 10) { submitButton() .padding(.vertical, 10) Color.clear .frame(height: 22) } .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) } private func codeField() -> some View { TextField(text: $code) { Text(verbatim: "SB-XXXXX-XXXXX-XXXXX-XXXXX") } .font(.body.monospaced()) .multilineTextAlignment(.center) .autocorrectionDisabled(true) .textInputAutocapitalization(.characters) .disabled(submitting) .padding(EdgeInsets(top: 14, leading: 12, bottom: 14, trailing: 12)) .background(Color(.tertiarySystemFill)) .cornerRadius(10.0) .onChange(of: code) { applyCodeInput($0) } } private func pasteButton() -> some View { Button { if let pasted = UIPasteboard.general.string { applyCodeInput(pasted) } } label: { Text("Paste") .font(.body) .fontWeight(.medium) .foregroundColor(theme.colors.primary) } .disabled(submitting) } private func applyCodeInput(_ s: String) { let formatted = formatBadgeCodeInput(s) if formatted != code { code = formatted } canonicalCode = parseBadgeCode(formatted) } private func processQRCode(_ resp: Result) { switch resp { case let .success(r): let formatted = formatBadgeCodeInput(r.string) if parseBadgeCode(formatted) == nil { showAlert( NSLocalizedString("Invalid QR code", comment: "alert title"), message: NSLocalizedString("The code you scanned is not a badge code.", comment: "alert message") ) } else { applyCodeInput(formatted) redeem() } case let .failure(e): logger.error("processQRCode QR code error: \(e.localizedDescription)") showAlert( NSLocalizedString("Invalid QR code", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Error scanning code: %@", comment: "alert message"), e.localizedDescription) ) } } private func submitButton() -> some View { let disabled = canonicalCode == nil || submitting return Button { redeem() } label: { Text("Redeem") } .buttonStyle(OnboardingButtonStyle(isDisabled: disabled)) .disabled(disabled) } private func redeem() { guard let sending = canonicalCode, let user = chatModel.currentUser else { return } submitting = true Task { do { let (redeemedUser, newBadge) = try await apiRedeemBadgeCode(user.userId, sending) let badgeState = try? await apiGetBadgeState(user.userId) await MainActor.run { submitting = false // written before the pop: BadgesView swaps its content under this pushed view, so // the pop reveals Your Badge already in place rather than animating it afterwards if let badgeState { BadgeModel.shared.set(userId: user.userId, badgeState: badgeState) } // the response is the only carrier: redeeming raises no event that refreshes the // profile, so without this the badge beside the name is the one from before chatModel.updateUser(redeemedUser) if let badgeState, !badgeState.shown { // a replay adds no purchase; a fresh code's badge can be retired on arrival showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: failureMessage(newBadge ? .badgeEnded : .codeUsed)) } else { supporterBannerShown = true dismiss() } } } catch let error { let redeemError = error as? BadgeRedeemError ?? .unknown // the mapped case only - core embeds the service's response in some of these messages logger.error("apiRedeemBadgeCode: \(String(describing: redeemError))") await MainActor.run { submitting = false showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: failureMessage(redeemError)) } } } } private func failureMessage(_ failure: BadgeRedeemError) -> String { switch failure { case .invalidCode: NSLocalizedString("This code is not valid.", comment: "alert message") case .serviceNotConfigured: NSLocalizedString("This app version cannot redeem badge codes.", comment: "alert message") case .alreadyActive: NSLocalizedString("This profile already has a badge. Redeem the code on another profile, or once this badge ends.", comment: "alert message") case .codeInvalid: NSLocalizedString("This code was not recognised.", comment: "alert message") case .codeUsed: NSLocalizedString("This code has already been used.", comment: "alert message") case .codeExpired: NSLocalizedString("This code has expired.", comment: "alert message") case .rateLimited: NSLocalizedString("Too many attempts. Please try again later.", comment: "alert message") case .serviceFailed: NSLocalizedString("The badge service is unavailable. Please try again later.", comment: "alert message") case .badServiceResponse: NSLocalizedString("The badge service sent an unexpected response.", comment: "alert message") case .credentialNotVerified: NSLocalizedString("This app version cannot verify this badge. Please update the app.", comment: "alert message") case .unsupportedVersion: NSLocalizedString("This app version is too old for the badge service. Please update the app.", comment: "alert message") case .networkError: NSLocalizedString("Connection error. Please check your network connection.", comment: "alert message") case .badgeEnded: NSLocalizedString("The code was accepted, but the badge it grants has already ended.", comment: "alert message") case .unknown: NSLocalizedString("The code could not be redeemed.", comment: "alert message") } } } struct BadgesRedeemCodeView_Previews: PreviewProvider { static var previews: some View { NavigationView { BadgesRedeemCodeView() } } }