mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-22 15:55:40 +00:00
229 lines
9.6 KiB
Swift
229 lines
9.6 KiB
Swift
//
|
|
// 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..<j]))
|
|
i = j
|
|
}
|
|
return groups.joined(separator: "-")
|
|
}
|
|
|
|
struct BadgesRedeemCodeView: View {
|
|
@EnvironmentObject var theme: AppTheme
|
|
@EnvironmentObject var chatModel: ChatModel
|
|
@AppStorage(DEFAULT_SUPPORTER_BANNER_SHOWN) private var supporterBannerShown = false
|
|
@Environment(\.dismiss) var dismiss: DismissAction
|
|
@State private var code = ""
|
|
@State private var canonicalCode: String? = nil
|
|
@State private var submitting = false
|
|
@State private var showQRCodeScanner = true
|
|
|
|
var body: some View {
|
|
GeometryReader { g in
|
|
ScrollView {
|
|
VStack(alignment: .center, spacing: 16) {
|
|
entryContent(g)
|
|
}
|
|
.padding(.horizontal, 25)
|
|
.padding(.top, 8)
|
|
.padding(.bottom, 20)
|
|
.frame(minHeight: g.size.height)
|
|
}
|
|
}
|
|
.frame(maxHeight: .infinity)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func entryContent(_ g: GeometryProxy) -> 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<ScanResult, ScanError>) {
|
|
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()
|
|
}
|
|
}
|
|
}
|