756 lines
28 KiB
Swift
756 lines
28 KiB
Swift
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(KTheme.background)
|
||
.toolbar { walletToolbar }
|
||
.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<Bool> {
|
||
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(.black.opacity(0.35), in: Capsule())
|
||
.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"))
|
||
}
|
||
|
||
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)
|
||
}
|
||
.shadow(color: KTheme.pink.opacity(0.24), radius: 28, y: 16)
|
||
|
||
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))
|
||
}
|
||
}
|