226 lines
9.6 KiB
Swift
226 lines
9.6 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(KTheme.background)
|
|
.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(KTheme.panel.opacity(0.92))
|
|
.clipShape(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(.bordered)
|
|
.tint(.white)
|
|
.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(.bordered)
|
|
.tint(.white)
|
|
.font(.subheadline.weight(.semibold))
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(compact ? 14 : 22)
|
|
.background(LinearGradient(colors: [KTheme.orange, KTheme.pink, KTheme.purple], startPoint: .topLeading, endPoint: .bottomTrailing))
|
|
.clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous))
|
|
.shadow(color: KTheme.pink.opacity(0.2), radius: 18, y: 8)
|
|
}
|
|
|
|
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 }
|
|
}
|