KasztelanKarta/Kasztelan/CardView.swift
2026-10-23 01:05:11 +01:00

250 lines
10 KiB
Swift

import SwiftUI
private enum CardAlert: Identifiable {
case generate
case scannerError(String)
var id: String {
switch self {
case .generate: return "generate"
case .scannerError: return "scanner-error"
}
}
}
struct CardView: View {
@EnvironmentObject private var model: AppModel
@State private var cardNumber = ""
@State private var cardScannerPresented = false
@State private var activeAlert: CardAlert?
@FocusState private var cardNumberFocused: Bool
var body: some View {
GeometryReader { geometry in
let compact = geometry.size.height < 680
VStack(alignment: .leading, spacing: compact ? 10 : 18) {
Text(model.text("Witaj, ", "Hello, ") + firstName)
.font(compact ? .title.bold() : .largeTitle.bold())
.lineLimit(1)
.minimumScaleFactor(0.72)
Text(model.text("Pokaż kod karty przy kasie.", "Show your card code at the checkout."))
.foregroundStyle(.secondary)
.font(compact ? .subheadline : .body)
loyaltyCard(compact: compact)
summary(compact: compact)
}
.padding(.horizontal, 20)
.padding(.vertical, compact ? 8 : 14)
.frame(width: geometry.size.width, height: geometry.size.height, alignment: .top)
}
.background(.clear)
.fullScreenCover(isPresented: $cardScannerPresented) {
CardScannerScreen(
onScan: acceptScannedCard,
onCancel: { cardScannerPresented = false },
onError: { error in
cardScannerPresented = false
activeAlert = .scannerError(error)
}
)
.ignoresSafeArea()
}
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button(model.text("Gotowe", "Done")) { cardNumberFocused = false }
.fontWeight(.semibold)
}
}
.alert(item: $activeAlert) { alert in
switch alert {
case .generate:
return Alert(
title: Text(model.text("Wygenerować nową kartę?", "Create a new card?")),
message: Text(model.text(
"Do konta zostanie na stałe przypisana nowa karta cyfrowa.",
"A new digital card will be permanently assigned to this account."
)),
primaryButton: .default(Text(model.text("Wygeneruj", "Create"))) {
Task { _ = await model.generateCard() }
},
secondaryButton: .cancel(Text(model.text("Anuluj", "Cancel")))
)
case let .scannerError(message):
return Alert(
title: Text(model.text("Nie udało się odczytać karty", "Could not read the card")),
message: Text(message),
dismissButton: .cancel(Text("OK"))
)
}
}
}
private var firstName: String {
model.loyalty?.firstName?.value.nonEmpty
?? model.user?.firstName?.nonEmpty
?? model.text("Kliencie", "Customer")
}
private func loyaltyCard(compact: Bool) -> some View {
return VStack(spacing: compact ? 10 : 20) {
Image("Logo")
.resizable()
.scaledToFit()
.frame(maxHeight: compact ? 54 : 76)
if model.cardNumber.count == 13 {
VStack(spacing: compact ? 4 : 8) {
CardBarcodeView(digits: model.cardNumber)
.frame(height: compact ? 108 : 126)
Text(model.cardNumber)
.font(.system(.headline, design: .monospaced).weight(.bold))
.tracking(2)
.foregroundStyle(.black)
}
.padding(compact ? 8 : 12)
.background(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
} else {
VStack(alignment: .leading, spacing: 10) {
Text(model.text("Przypisz kartę", "Assign a card")).font(.headline)
Text(model.text("Wpisz 13-cyfrowy numer znajdujący się na karcie.", "Enter the 13-digit number shown on the card."))
.font(.subheadline)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
TextField(model.text("Numer karty", "Card number"), text: $cardNumber)
.keyboardType(.numberPad)
.focused($cardNumberFocused)
.foregroundStyle(.white)
.tint(.white)
.padding(.horizontal, 14)
.frame(minHeight: 50)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.white.opacity(0.35), lineWidth: 1)
}
.onChange(of: cardNumber) { value in
cardNumber = String(value.filter(\.isNumber).prefix(13))
}
Button {
Task { if await model.assignCard(cardNumber) { cardNumber = "" } }
} label: {
Text(model.text("Przypisz", "Assign"))
.frame(maxWidth: .infinity, minHeight: 22)
}
.buttonStyle(.borderedProminent)
.tint(KTheme.pink)
Button {
cardNumberFocused = false
cardScannerPresented = true
} label: {
Label(model.text("Skanuj kartę", "Scan card"), systemImage: "camera.viewfinder")
.frame(maxWidth: .infinity, minHeight: 22)
}
.buttonStyle(GlassButtonStyle())
.font(.subheadline.weight(.semibold))
Button {
cardNumberFocused = false
activeAlert = .generate
} label: {
Text(model.text("Nie mam karty — wygeneruj cyfrową", "I don't have a card — create a digital one"))
.frame(maxWidth: .infinity, minHeight: 22)
}
.buttonStyle(GlassButtonStyle())
.font(.subheadline.weight(.semibold))
}
}
}
.frame(maxWidth: .infinity)
.padding(compact ? 14 : 22)
.background {
ZStack {
LinearGradient(
colors: [KTheme.orange.opacity(0.94), KTheme.pink.opacity(0.90), KTheme.purple.opacity(0.94)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Circle()
.fill(.white.opacity(0.16))
.frame(width: 210, height: 210)
.blur(radius: 10)
.offset(x: 150, y: -130)
Circle()
.fill(KTheme.cyan.opacity(0.14))
.frame(width: 140, height: 140)
.blur(radius: 16)
.offset(x: -145, y: 150)
LinearGradient(
colors: [.white.opacity(0.22), .clear, .black.opacity(0.12)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
}
.clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.stroke(.white.opacity(0.32), lineWidth: 1)
}
.shadow(color: KTheme.pink.opacity(0.28), radius: 28, y: 16)
}
private func acceptScannedCard(_ scan: ScannedCard) {
cardScannerPresented = false
let digits = scan.value.filter(\.isNumber)
guard digits.count == 13 else {
activeAlert = .scannerError(model.text(
"Odczytany kod nie zawiera 13 cyfr. Ustaw całą kartę w ramce i spróbuj ponownie.",
"The scanned code does not contain 13 digits. Place the whole card inside the frame and try again."
))
return
}
cardNumber = digits
}
private func summary(compact: Bool) -> some View {
VStack(spacing: 0) {
Text(model.text("PODSUMOWANIE", "SUMMARY"))
.font(.caption.bold())
.foregroundStyle(KTheme.orange)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 8)
StatRow(label: model.text("Punkty", "Points"), value: model.pointsWithValue(model.loyalty?.points?.value), highlighted: true, compact: compact)
Divider().overlay(Color.white.opacity(0.15))
StatRow(label: model.text("Wartość zakupów", "Purchase value"), value: model.money(model.loyalty?.transactionsValue?.value), compact: compact)
Divider().overlay(Color.white.opacity(0.15))
StatRow(label: model.text("Wartość upustu", "Discount value"), value: model.money(model.loyalty?.discountValue?.value), compact: compact)
if let savings = model.loyalty?.savings?.value.nonEmpty {
Divider().overlay(Color.white.opacity(0.15))
StatRow(label: model.text("Oszczędności", "Savings"), value: model.money(savings), compact: compact)
}
}
.kasztelanPanel()
}
}
private struct StatRow: View {
let label: String
let value: String
var highlighted = false
var compact = false
var body: some View {
HStack {
Text(label).foregroundStyle(.secondary)
Spacer()
Text(value)
.font(highlighted ? .title3.bold() : .headline)
.foregroundStyle(highlighted ? KTheme.orange : .white)
.multilineTextAlignment(.trailing)
}
.padding(.vertical, compact ? 8 : 13)
}
}
extension String {
var nonEmpty: String? { isEmpty ? nil : self }
}