Fill in payments by scanning a QR code or picking one from photos

This commit is contained in:
2026-09-20 02:00:52 +08:00
parent 399502cf72
commit c0903f4f02
8 changed files with 423 additions and 11 deletions
+141
View File
@@ -0,0 +1,141 @@
import AVFoundation
import SwiftUI
/// Camera QR scanner, presented as a sheet. On a simulator (no capture device)
/// it explains that instead of showing a dead viewfinder.
struct QRScannerView: UIViewControllerRepresentable {
var onScan: (String) -> Void
var onCancel: () -> Void
func makeUIViewController(context: Context) -> ScannerViewController {
let controller = ScannerViewController()
controller.onScan = onScan
controller.onCancel = onCancel
return controller
}
func updateUIViewController(_ controller: ScannerViewController, context: Context) {}
}
final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
var onScan: ((String) -> Void)?
var onCancel: (() -> Void)?
private let session = AVCaptureSession()
private var previewLayer: AVCaptureVideoPreviewLayer?
private var hasScanned = false
private var messageLabel: UILabel?
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
let cancel = UIButton(type: .system)
cancel.setTitle("Cancel", for: .normal)
cancel.setTitleColor(.white, for: .normal)
cancel.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
cancel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(cancel)
NSLayoutConstraint.activate([
cancel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
cancel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
])
let label = UILabel()
label.numberOfLines = 0
label.textAlignment = .center
label.textColor = .white
label.font = .systemFont(ofSize: 14)
label.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)
NSLayoutConstraint.activate([
label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32),
label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32),
])
messageLabel = label
requestCamera()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
previewLayer?.frame = view.bounds
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if session.isRunning { session.stopRunning() }
}
@objc private func cancelTapped() {
onCancel?()
}
private func requestCamera() {
guard let device = AVCaptureDevice.default(for: .video) else {
showMessage("This device has no camera. Use “Photo” to pick a QR from the library instead.")
return
}
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
configure(device: device)
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
DispatchQueue.main.async {
if granted {
self?.configure(device: device)
} else {
self?.showMessage("Camera access is off. Enable it in Settings, or use “Photo”.")
}
}
}
default:
showMessage("Camera access is off. Enable it in Settings, or use “Photo”.")
}
}
private func configure(device: AVCaptureDevice) {
guard let input = try? AVCaptureDeviceInput(device: device), session.canAddInput(input) else {
showMessage("The camera could not be started. Use “Photo” instead.")
return
}
session.addInput(input)
let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else {
showMessage("The camera could not be started. Use “Photo” instead.")
return
}
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
output.metadataObjectTypes = [.qr]
let layer = AVCaptureVideoPreviewLayer(session: session)
layer.videoGravity = .resizeAspectFill
layer.frame = view.bounds
view.layer.insertSublayer(layer, at: 0)
previewLayer = layer
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.session.startRunning()
}
}
private func showMessage(_ text: String) {
messageLabel?.text = text
}
func metadataOutput(_ output: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection) {
guard !hasScanned else { return }
guard let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
let value = object.stringValue, !value.isEmpty else { return }
hasScanned = true
session.stopRunning()
onScan?(value)
}
}