KasztelanKarta/Kasztelan/WalletView.swift
2026-10-16 23:37:49 +01:00

690 lines
26 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
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, y: 1, z: 0), perspective: 0.9)
.opacity(carouselOpacity(width: geometry.size.width))
.shadow(color: .black.opacity(carouselShadow), radius: 24, x: dragOffset < 0 ? 12 : -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(dragOffset) / max(width, 1), 1) * 0.14
}
private func carouselAngle(width: CGFloat) -> Angle {
.degrees(Double(dragOffset / max(width, 1)) * 68)
}
private func carouselArc(width: CGFloat) -> CGFloat {
let progress = min(abs(dragOffset) / max(width, 1), 1)
return progress * progress * 34
}
private func carouselOpacity(width: CGFloat) -> Double {
1 - min(Double(abs(dragOffset) / max(width, 1)), 1) * 0.28
}
private var carouselShadow: Double { min(Double(abs(dragOffset) / 520), 0.42) }
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(.easeOut(duration: 0.14)) { dragOffset = 0 }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { 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.72 : -width * 0.72
}
DispatchQueue.main.async {
withAnimation(.easeOut(duration: 0.26)) { dragOffset = 0 }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.27) { changingPage = false }
}
}
}
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(KTheme.background)
}
}
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(LinearGradient(
colors: [KTheme.purple, KTheme.pink, KTheme.orange],
startPoint: .topLeading,
endPoint: .bottomTrailing
))
.clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous))
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(KTheme.background)
.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(KTheme.background)
.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))
}
}