import AVFoundation import CryptoKit import SwiftUI import UIKit struct WalletView: View { @EnvironmentObject private var model: AppModel @StateObject private var store = DiscountCardStore() @State private var selectedPage = 0 @State private var scannerPresented = false @State private var pendingScan: ScannedCard? @State private var scannerError: String? @State private var dragOffset: CGFloat = 0 @State private var changingPage = false var body: some View { walletCarousel .background(.clear) .toolbar { walletToolbar } .toolbarBackground(.hidden, for: .navigationBar) .onAppear { selectedPage = 0 } .task(id: model.user?.id) { await store.sync(using: model) } .fullScreenCover(isPresented: $scannerPresented) { scannerView } .sheet(item: $pendingScan, content: nameSheet) .alert( model.text("Nie można uruchomić aparatu", "Cannot open the camera"), isPresented: scannerErrorBinding, actions: scannerAlertActions, message: { Text(scannerError ?? "") } ) } private var walletCarousel: some View { GeometryReader { geometry in ZStack { AmbientBackground() WalletMotionBubbles(progress: carouselProgress(width: geometry.size.width)) AnyView(currentPage) .id(pageIdentity) .offset(x: dragOffset) .offset(y: carouselArc(width: geometry.size.width)) .scaleEffect(carouselScale(width: geometry.size.width)) .rotation3DEffect( carouselAngle(width: geometry.size.width), axis: (x: 0.08, y: 1, z: 0), perspective: 0.62 ) .rotationEffect(.degrees(Double(carouselProgress(width: geometry.size.width)) * -2.4)) .opacity(carouselOpacity(width: geometry.size.width)) .shadow(color: KTheme.purple.opacity(carouselShadow), radius: 32, x: dragOffset < 0 ? 16 : -16, y: 12) .contentShape(Rectangle()) .simultaneousGesture(carouselGesture(width: geometry.size.width)) } .overlay(alignment: .bottom) { pageIndicator } .clipped() } } @ToolbarContentBuilder private var walletToolbar: some ToolbarContent { AppToolbar() } private var scannerView: some View { CardScannerScreen( onScan: { scan in scannerPresented = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { pendingScan = scan } }, onCancel: { scannerPresented = false }, onError: { error in scannerPresented = false scannerError = error } ) .ignoresSafeArea() } private func nameSheet(for scan: ScannedCard) -> some View { CardNameSheet(scan: scan) { name in store.add(name: name, scan: scan) pendingScan = nil selectedPage = store.cards.count Task { await store.upload(using: model) } } .environmentObject(model) .presentationDetents([.medium]) } private var scannerErrorBinding: Binding { Binding( get: { scannerError != nil }, set: { if !$0 { scannerError = nil } } ) } @ViewBuilder private func scannerAlertActions() -> some View { Button("OK", role: .cancel) { scannerError = nil } Button(model.text("Ustawienia", "Settings")) { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } } private func carouselScale(width: CGFloat) -> CGFloat { 1 - min(abs(carouselProgress(width: width)), 1) * 0.10 } private func carouselAngle(width: CGFloat) -> Angle { .degrees(Double(carouselProgress(width: width)) * 54) } private func carouselArc(width: CGFloat) -> CGFloat { let progress = min(abs(carouselProgress(width: width)), 1) return progress * progress * 24 } private func carouselOpacity(width: CGFloat) -> Double { 1 - min(Double(abs(carouselProgress(width: width))), 1) * 0.22 } private func carouselProgress(width: CGFloat) -> CGFloat { dragOffset / max(width, 1) } private var carouselShadow: Double { 0.12 + min(Double(abs(dragOffset) / 520), 0.34) } private func carouselGesture(width: CGFloat) -> some Gesture { DragGesture(minimumDistance: 18) .onChanged { value in guard !changingPage, abs(value.translation.width) > abs(value.translation.height) else { return } dragOffset = value.translation.width } .onEnded { value in finishDrag(value, width: width) } } @ViewBuilder private var currentPage: some View { if selectedPage == 0 { CardView() } else if selectedPage <= store.cards.count { let card = store.cards[selectedPage - 1] DiscountCardView(card: card) { store.delete(card) selectedPage = 0 Task { await store.upload(using: model) } } } else { AddDiscountCardView { scannerPresented = true } } } private var pageIdentity: String { if selectedPage == 0 { return "kasztelan" } if selectedPage <= store.cards.count { return store.cards[selectedPage - 1].id.uuidString } return "add-card" } private var pageIndicator: some View { HStack(spacing: 7) { ForEach(0..<(store.cards.count + 2), id: \.self) { index in Circle() .fill(index == selectedPage ? KTheme.orange : Color.secondary.opacity(0.5)) .frame(width: index == selectedPage ? 8 : 6, height: index == selectedPage ? 8 : 6) } } .padding(.horizontal, 12) .padding(.vertical, 8) .background(.ultraThinMaterial, in: Capsule()) .overlay { Capsule() .stroke(.white.opacity(0.18), lineWidth: 1) } .padding(.bottom, 2) .allowsHitTesting(false) } private func finishDrag(_ value: DragGesture.Value, width: CGFloat) { guard !changingPage else { return } let horizontal = abs(value.translation.width) > abs(value.translation.height) let predicted = value.predictedEndTranslation.width guard horizontal, max(abs(value.translation.width), abs(predicted)) > max(52, width * 0.15) else { changingPage = true withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) { dragOffset = 0 } DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { changingPage = false } return } changingPage = true let direction = predicted < 0 ? 1 : -1 let pageCount = store.cards.count + 2 var instant = Transaction() instant.disablesAnimations = true withTransaction(instant) { selectedPage = (selectedPage + direction + pageCount) % pageCount dragOffset = direction > 0 ? width * 0.82 : -width * 0.82 } DispatchQueue.main.async { withAnimation(.spring(response: 0.48, dampingFraction: 0.84, blendDuration: 0.08)) { dragOffset = 0 } DispatchQueue.main.asyncAfter(deadline: .now() + 0.50) { changingPage = false } } } } private struct WalletMotionBubbles: View { let progress: CGFloat var body: some View { ZStack { bubble(size: 112, tint: KTheme.pink.opacity(0.20)) .offset(x: -145 + progress * 90, y: -230 - abs(progress) * 24) bubble(size: 74, tint: KTheme.orange.opacity(0.22)) .offset(x: 145 + progress * 55, y: -80 + progress * 42) bubble(size: 148, tint: KTheme.purple.opacity(0.17)) .offset(x: 125 - progress * 100, y: 255) bubble(size: 48, tint: KTheme.cyan.opacity(0.18)) .offset(x: -120 - progress * 50, y: 150 + progress * 30) } .blur(radius: abs(progress) * 1.5) .allowsHitTesting(false) } private func bubble(size: CGFloat, tint: Color) -> some View { Circle() .fill(.ultraThinMaterial) .frame(width: size, height: size) .overlay { Circle() .fill( RadialGradient( colors: [.white.opacity(0.28), tint, .clear], center: .topLeading, startRadius: 1, endRadius: size ) ) } .overlay { Circle().stroke(.white.opacity(0.14), lineWidth: 1) } .shadow(color: tint, radius: 24) } } private struct AddDiscountCardView: View { @EnvironmentObject private var model: AppModel let scan: () -> Void var body: some View { VStack(spacing: 22) { Spacer() Image(systemName: "barcode.viewfinder") .font(.system(size: 72, weight: .semibold)) .foregroundStyle(KTheme.orange) Text(model.text("Dodaj kartę rabatową", "Add a loyalty card")) .font(.title2.bold()) Text(model.text( "Zeskanuj kod z plastikowej karty. Zaszyfrujemy go i przypiszemy do Twojego konta.", "Scan the code from a plastic card. It will be encrypted and linked to your account." )) .multilineTextAlignment(.center) .foregroundStyle(.secondary) .padding(.horizontal, 30) Button(model.text("Skanuj kartę", "Scan card"), action: scan) .buttonStyle(PrimaryButtonStyle()) .padding(.horizontal, 24) Spacer() } .padding(.bottom, 34) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(.clear) } } private struct DiscountCardView: View { @EnvironmentObject private var model: AppModel let card: DiscountCard let delete: () -> Void @State private var confirmingDelete = false var body: some View { VStack(alignment: .leading, spacing: 18) { HStack { VStack(alignment: .leading, spacing: 3) { Text(card.name).font(.largeTitle.bold()).lineLimit(2) Text(model.text("Karta rabatowa", "Loyalty card")).foregroundStyle(.secondary) } Spacer() Button(role: .destructive) { confirmingDelete = true } label: { Image(systemName: "trash.circle.fill").font(.title) } .accessibilityLabel(model.text("Usuń kartę", "Delete card")) } GlassDepthDeck { VStack(spacing: 18) { StoredBarcodeView(value: card.payload, symbology: card.symbology) .frame(maxWidth: .infinity) .frame(height: card.isTwoDimensional ? 220 : 132) .padding(14) .background(.white) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) Text(card.payload) .font(.system(.headline, design: .monospaced).weight(.bold)) .multilineTextAlignment(.center) .textSelection(.enabled) .lineLimit(3) .minimumScaleFactor(0.65) } .frame(maxWidth: .infinity) .padding(22) .background( ZStack { LinearGradient( colors: [KTheme.purple.opacity(0.92), KTheme.pink.opacity(0.88), KTheme.orange.opacity(0.86)], startPoint: .topLeading, endPoint: .bottomTrailing ) Circle() .fill(.white.opacity(0.15)) .frame(width: 170, height: 170) .blur(radius: 8) .offset(x: 145, y: -100) } ) .clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 26, style: .continuous) .stroke(.white.opacity(0.28), lineWidth: 1) } } .rotation3DEffect(.degrees(-2.8), axis: (x: 1, y: 0, z: 0), perspective: 0.62) Text(model.text("Dostępna offline • zaszyfrowana synchronizacja", "Available offline • encrypted sync")) .font(.footnote) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .center) Spacer() } .padding(.horizontal, 20) .padding(.vertical, 14) .background(.clear) .alert(model.text("Usunąć kartę?", "Delete this card?"), isPresented: $confirmingDelete) { Button(model.text("Anuluj", "Cancel"), role: .cancel) {} Button(model.text("Usuń", "Delete"), role: .destructive, action: delete) } } } private struct CardNameSheet: View { @EnvironmentObject private var model: AppModel @Environment(\.dismiss) private var dismiss let scan: ScannedCard let save: (String) -> Void @State private var name = "" var body: some View { NavigationStack { VStack(alignment: .leading, spacing: 16) { Text(model.text("Jak nazywa się ta karta?", "What card is this?")) .font(.title2.bold()) KasztelanField(title: model.text("Np. Tesco Clubcard", "e.g. Tesco Clubcard"), text: $name) Button(model.text("Zapisz kartę", "Save card")) { let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) save(trimmed.isEmpty ? model.text("Karta rabatowa", "Loyalty card") : trimmed) dismiss() } .buttonStyle(PrimaryButtonStyle()) Spacer() } .padding(20) .background(AmbientBackground()) .toolbar { ToolbarItem(placement: .cancellationAction) { Button(model.text("Anuluj", "Cancel")) { dismiss() } } } } } } struct DiscountCard: Codable, Identifiable, Equatable { let id: UUID let name: String let payload: String let symbology: String let createdAt: Date var isTwoDimensional: Bool { let type = AVMetadataObject.ObjectType(rawValue: symbology) return type == .qr || type == .aztec || type == .pdf417 } } @MainActor final class DiscountCardStore: ObservableObject { @Published private(set) var cards: [DiscountCard] = [] private var userID: Int? private var syncing = false init() {} func add(name: String, scan: ScannedCard) { cards.append(DiscountCard( id: UUID(), name: name, payload: scan.value, symbology: scan.symbology, createdAt: Date() )) save() setDirty(true) } func delete(_ card: DiscountCard) { cards.removeAll { $0.id == card.id } save() setDirty(true) } func sync(using model: AppModel) async { guard let id = model.user?.id else { return } activate(userID: id) guard !syncing else { return } syncing = true defer { syncing = false } do { let cloud = try await model.cloudWallet() guard let key = cloud.key, !key.isEmpty else { return } if isDirty { try await saveToCloud(key: key, using: model) } else if let payload = cloud.payload, !payload.isEmpty { cards = try Self.decrypt(payload: payload, key: key) save() setDirty(false) } else if !cards.isEmpty { try await saveToCloud(key: key, using: model) } } catch { // Local cards remain available offline and will sync next time. } } func upload(using model: AppModel) async { guard let id = model.user?.id else { return } activate(userID: id) guard !syncing else { return } syncing = true defer { syncing = false } do { let cloud = try await model.cloudWallet() guard let key = cloud.key, !key.isEmpty else { return } try await saveToCloud(key: key, using: model) } catch { setDirty(true) } } private func activate(userID: Int) { guard self.userID != userID else { return } self.userID = userID let accountCards = Self.load(from: fileURL) if accountCards.isEmpty, let legacyURL = Self.legacyFileURL { let legacyCards = Self.load(from: legacyURL) cards = legacyCards if !legacyCards.isEmpty { save() setDirty(true) } } else { cards = accountCards } } static func clearLocal(for userID: Int?) { guard let userID, let supportDirectory else { return } let accountURL = supportDirectory.appendingPathComponent("kasztelan-discount-cards-\(userID).json") try? FileManager.default.removeItem(at: accountURL) if let legacyFileURL { try? FileManager.default.removeItem(at: legacyFileURL) } UserDefaults.standard.removeObject(forKey: "kasztelan-wallet-dirty-\(userID)") } private static var supportDirectory: URL? { FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first } private static var legacyFileURL: URL? { supportDirectory?.appendingPathComponent("kasztelan-discount-cards.json") } private var fileURL: URL? { guard let userID else { return nil } return Self.supportDirectory?.appendingPathComponent("kasztelan-discount-cards-\(userID).json") } private var dirtyKey: String? { userID.map { "kasztelan-wallet-dirty-\($0)" } } private var isDirty: Bool { dirtyKey.map { UserDefaults.standard.bool(forKey: $0) } ?? false } private func setDirty(_ dirty: Bool) { guard let dirtyKey else { return } UserDefaults.standard.set(dirty, forKey: dirtyKey) } private static func load(from fileURL: URL?) -> [DiscountCard] { guard let fileURL, let data = try? Data(contentsOf: fileURL) else { return [] } return (try? JSONDecoder().decode([DiscountCard].self, from: data)) ?? [] } private func save() { guard let fileURL, let data = try? JSONEncoder().encode(cards) else { return } try? FileManager.default.createDirectory( at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication] ) try? data.write(to: fileURL, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) } private func saveToCloud(key: String, using model: AppModel) async throws { let payload = try Self.encrypt(cards: cards, key: key) try await model.saveCloudWallet(payload) setDirty(false) if let legacyURL = Self.legacyFileURL { try? FileManager.default.removeItem(at: legacyURL) } } private struct CloudCard: Codable { let id: String let name: String let payload: String let format: String let createdAt: Int64 enum CodingKeys: String, CodingKey { case id, name, payload, format case createdAt = "created_at" } } private static func encrypt(cards: [DiscountCard], key: String) throws -> String { guard let keyData = Data(base64Encoded: key), keyData.count == 32 else { throw APIError.invalidResponse } let cloudCards = cards.map { CloudCard( id: $0.id.uuidString, name: $0.name, payload: $0.payload, format: canonicalFormat($0.symbology), createdAt: Int64($0.createdAt.timeIntervalSince1970 * 1000) ) } let plain = try JSONEncoder().encode(cloudCards) let sealed = try AES.GCM.seal(plain, using: SymmetricKey(data: keyData)) guard let combined = sealed.combined else { throw APIError.invalidResponse } return combined.base64EncodedString() } private static func decrypt(payload: String, key: String) throws -> [DiscountCard] { guard let keyData = Data(base64Encoded: key), keyData.count == 32, let encrypted = Data(base64Encoded: payload) else { throw APIError.invalidResponse } let plain = try AES.GCM.open( AES.GCM.SealedBox(combined: encrypted), using: SymmetricKey(data: keyData) ) return try JSONDecoder().decode([CloudCard].self, from: plain).compactMap { item in guard let id = UUID(uuidString: item.id), !item.payload.isEmpty else { return nil } return DiscountCard( id: id, name: item.name, payload: item.payload, symbology: nativeFormat(item.format), createdAt: Date(timeIntervalSince1970: Double(item.createdAt) / 1000) ) } } private static func canonicalFormat(_ format: String) -> String { let type = AVMetadataObject.ObjectType(rawValue: format) switch type { case .ean13: return "EAN_13" case .ean8: return "EAN_8" case .code128: return "CODE_128" case .code39: return "CODE_39" case .code93: return "CODE_93" case .upce: return "UPC_E" case .qr: return "QR_CODE" case .aztec: return "AZTEC" case .pdf417: return "PDF_417" case .dataMatrix: return "DATA_MATRIX" case .itf14: return "ITF" default: return format } } private static func nativeFormat(_ format: String) -> String { switch format { case "EAN_13": return AVMetadataObject.ObjectType.ean13.rawValue case "EAN_8": return AVMetadataObject.ObjectType.ean8.rawValue case "CODE_128": return AVMetadataObject.ObjectType.code128.rawValue case "CODE_39": return AVMetadataObject.ObjectType.code39.rawValue case "CODE_93": return AVMetadataObject.ObjectType.code93.rawValue case "UPC_E": return AVMetadataObject.ObjectType.upce.rawValue case "QR_CODE": return AVMetadataObject.ObjectType.qr.rawValue case "AZTEC": return AVMetadataObject.ObjectType.aztec.rawValue case "PDF_417": return AVMetadataObject.ObjectType.pdf417.rawValue case "DATA_MATRIX": return AVMetadataObject.ObjectType.dataMatrix.rawValue case "ITF": return AVMetadataObject.ObjectType.itf14.rawValue default: return format } } } struct ScannedCard: Identifiable { let id = UUID() let value: String let symbology: String } struct CardScannerScreen: View { @EnvironmentObject private var model: AppModel let onScan: (ScannedCard) -> Void let onCancel: () -> Void let onError: (String) -> Void var body: some View { ZStack { CardScannerCamera(onScan: onScan, onError: onError) VStack { HStack { Button(action: onCancel) { Image(systemName: "xmark.circle.fill") .font(.system(size: 34)) .foregroundStyle(.white) .shadow(radius: 4) } Spacer() } .padding(20) Spacer() Text(model.text("Umieść cały kod w ramce", "Place the whole code inside the frame")) .font(.headline) .foregroundStyle(.white) .padding(.horizontal, 18) .padding(.vertical, 12) .background(.black.opacity(0.65), in: Capsule()) Button(action: onCancel) { Text(model.text("Anuluj", "Cancel")) .font(.headline) .foregroundStyle(.white) .frame(maxWidth: .infinity) .padding(.vertical, 14) .background(KTheme.orange, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) } .contentShape(Rectangle()) .padding(.horizontal, 44) .padding(.top, 14) .padding(.bottom, 32) } RoundedRectangle(cornerRadius: 24) .stroke(KTheme.orange, lineWidth: 4) .frame(width: 310, height: 230) .shadow(color: .black.opacity(0.5), radius: 8) .allowsHitTesting(false) } .background(.black) } } private struct CardScannerCamera: UIViewControllerRepresentable { let onScan: (ScannedCard) -> Void let onError: (String) -> Void func makeUIViewController(context: Context) -> ScannerViewController { let controller = ScannerViewController() controller.onScan = onScan controller.onError = onError return controller } func updateUIViewController(_ uiViewController: ScannerViewController, context: Context) {} } private final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { var onScan: ((ScannedCard) -> Void)? var onError: ((String) -> Void)? private let session = AVCaptureSession() private let sessionQueue = DispatchQueue(label: "uk.co.kasztelan.card-scanner") private var previewLayer: AVCaptureVideoPreviewLayer? private var completed = false override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black prepareCamera() } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() previewLayer?.frame = view.bounds } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) sessionQueue.async { [session] in if session.isRunning { session.stopRunning() } } } private func prepareCamera() { switch AVCaptureDevice.authorizationStatus(for: .video) { case .authorized: configureSession() case .notDetermined: AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in DispatchQueue.main.async { if granted { self?.configureSession() } else { self?.onError?("Aplikacja potrzebuje dostępu do aparatu, aby skanować karty.") } } } default: onError?("Włącz dostęp do aparatu dla Kasztelan w Ustawieniach iPhone’a.") } } private func configureSession() { guard let camera = AVCaptureDevice.default(for: .video), let input = try? AVCaptureDeviceInput(device: camera), session.canAddInput(input) else { onError?("Nie znaleziono dostępnego aparatu.") return } session.addInput(input) let output = AVCaptureMetadataOutput() guard session.canAddOutput(output) else { onError?("Nie można uruchomić skanera kodów.") return } session.addOutput(output) output.setMetadataObjectsDelegate(self, queue: .main) let supported: [AVMetadataObject.ObjectType] = [ .code128, .ean13, .ean8, .qr, .aztec, .pdf417 ] output.metadataObjectTypes = supported.filter { output.availableMetadataObjectTypes.contains($0) } let preview = AVCaptureVideoPreviewLayer(session: session) preview.videoGravity = .resizeAspectFill view.layer.insertSublayer(preview, at: 0) previewLayer = preview preview.frame = view.bounds sessionQueue.async { [session] in session.startRunning() } } func metadataOutput( _ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection ) { guard !completed, let code = metadataObjects.compactMap({ $0 as? AVMetadataMachineReadableCodeObject }).first, let value = code.stringValue, !value.isEmpty else { return } completed = true UINotificationFeedbackGenerator().notificationOccurred(.success) sessionQueue.async { [session] in if session.isRunning { session.stopRunning() } } onScan?(ScannedCard(value: value, symbology: code.type.rawValue)) } }