Add scan flow MVP and local Axiom skill workspace
This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
This commit is contained in:
172
StackDex/Services/CameraService.swift
Normal file
172
StackDex/Services/CameraService.swift
Normal file
@@ -0,0 +1,172 @@
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import OSLog
|
||||
import UIKit
|
||||
|
||||
struct CameraSessionState {
|
||||
private(set) var configurationDepth = 0
|
||||
|
||||
var canStartSession: Bool {
|
||||
configurationDepth == 0
|
||||
}
|
||||
|
||||
mutating func beginConfiguration() {
|
||||
configurationDepth += 1
|
||||
}
|
||||
|
||||
mutating func commitConfiguration() {
|
||||
configurationDepth = max(0, configurationDepth - 1)
|
||||
}
|
||||
}
|
||||
|
||||
enum CameraServiceError: LocalizedError {
|
||||
case unauthorized
|
||||
case unavailable
|
||||
case captureFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unauthorized:
|
||||
return "Camera access is required to capture a card photo."
|
||||
case .unavailable:
|
||||
return "The device camera is unavailable."
|
||||
case .captureFailed:
|
||||
return "The photo could not be captured."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class CameraService: NSObject, ObservableObject {
|
||||
private static let logger = Logger(subsystem: "dev.matthiasmeister.StackDex", category: "CameraService")
|
||||
|
||||
@Published private(set) var authorizationStatus: AVAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
@Published private(set) var isSessionRunning = false
|
||||
@Published private(set) var isConfigured = false
|
||||
|
||||
let session = AVCaptureSession()
|
||||
|
||||
private let photoOutput = AVCapturePhotoOutput()
|
||||
private var captureContinuation: CheckedContinuation<UIImage, Error>?
|
||||
private var sessionState = CameraSessionState()
|
||||
|
||||
func prepareIfAuthorized() async {
|
||||
authorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
guard authorizationStatus == .authorized else { return }
|
||||
await configureAndStartSessionIfNeeded()
|
||||
}
|
||||
|
||||
func requestAccessAndStart() async {
|
||||
authorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
|
||||
if authorizationStatus == .notDetermined {
|
||||
authorizationStatus = await AVCaptureDevice.requestAccess(for: .video) ? .authorized : .denied
|
||||
}
|
||||
|
||||
guard authorizationStatus == .authorized else { return }
|
||||
await configureAndStartSessionIfNeeded()
|
||||
}
|
||||
|
||||
func stopSession() {
|
||||
if session.isRunning {
|
||||
session.stopRunning()
|
||||
isSessionRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
func capturePhoto() async throws -> UIImage {
|
||||
guard authorizationStatus == .authorized else {
|
||||
throw CameraServiceError.unauthorized
|
||||
}
|
||||
|
||||
if !isSessionRunning {
|
||||
await configureAndStartSessionIfNeeded()
|
||||
}
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
captureContinuation = continuation
|
||||
photoOutput.capturePhoto(with: AVCapturePhotoSettings(), delegate: self)
|
||||
}
|
||||
}
|
||||
|
||||
private func configureAndStartSessionIfNeeded() async {
|
||||
if isConfigured {
|
||||
startSessionIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
var shouldStartSession = false
|
||||
|
||||
sessionState.beginConfiguration()
|
||||
Self.logger.debug("Camera session beginConfiguration depth=\(self.sessionState.configurationDepth)")
|
||||
session.beginConfiguration()
|
||||
|
||||
do {
|
||||
session.sessionPreset = .photo
|
||||
|
||||
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else {
|
||||
throw CameraServiceError.unavailable
|
||||
}
|
||||
|
||||
let input = try AVCaptureDeviceInput(device: device)
|
||||
|
||||
if !session.inputs.contains(where: { ($0 as? AVCaptureDeviceInput)?.device == device }) && session.canAddInput(input) {
|
||||
session.addInput(input)
|
||||
}
|
||||
|
||||
if !session.outputs.contains(photoOutput) && session.canAddOutput(photoOutput) {
|
||||
session.addOutput(photoOutput)
|
||||
}
|
||||
|
||||
isConfigured = true
|
||||
shouldStartSession = true
|
||||
} catch {
|
||||
isConfigured = false
|
||||
Self.logger.error("Camera session configuration failed: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
|
||||
session.commitConfiguration()
|
||||
sessionState.commitConfiguration()
|
||||
Self.logger.debug("Camera session commitConfiguration depth=\(self.sessionState.configurationDepth)")
|
||||
|
||||
if shouldStartSession {
|
||||
startSessionIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func startSessionIfNeeded() {
|
||||
guard sessionState.canStartSession else {
|
||||
let message = "Attempted to start AVCaptureSession before commitConfiguration completed."
|
||||
Self.logger.fault("\(message, privacy: .public)")
|
||||
assertionFailure(message)
|
||||
return
|
||||
}
|
||||
|
||||
guard !session.isRunning else { return }
|
||||
|
||||
Self.logger.debug("Starting camera session")
|
||||
session.startRunning()
|
||||
isSessionRunning = true
|
||||
}
|
||||
}
|
||||
|
||||
extension CameraService: AVCapturePhotoCaptureDelegate {
|
||||
nonisolated func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
|
||||
Task { @MainActor in
|
||||
if let error {
|
||||
captureContinuation?.resume(throwing: error)
|
||||
captureContinuation = nil
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = photo.fileDataRepresentation(), let image = UIImage(data: data) else {
|
||||
captureContinuation?.resume(throwing: CameraServiceError.captureFailed)
|
||||
captureContinuation = nil
|
||||
return
|
||||
}
|
||||
|
||||
captureContinuation?.resume(returning: image)
|
||||
captureContinuation = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user