mycode/myApp/Expiranner/IOS/CameraService.swift
songyc macbook 9902d44848 feat(expiranner): rebuild as store-based expiry photo checker
- 매장 선택 → 연/월 → 월 캘린더(날짜별 제품 사진 썸네일) → 날짜 상세 플로우로 전면 재작성
- 스캔 플로우: 촬영 → 재촬영/사용 → 그 달 날짜만 선택 → 저장 후 카메라 자동 복귀 (손전등 지원)
- 날짜 상세 다중 선택 삭제 (전체 선택/해제 + 일괄 삭제 확인)
- 다크 전용 민트 테마, SwiftData 모델(Store ↔ ProductRecord, 원본+썸네일 분리 저장)
- 밝은 민트 앱 아이콘(SVG 원본 포함) + 런치 스크린/인앱 스플래시
- CLAUDE.md 새 구조 기준으로 재작성

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:53:14 +09:00

159 lines
4.4 KiB
Swift

@preconcurrency import AVFoundation
import Combine
import UIKit
import SwiftUI
/// AVCaptureSession .
@MainActor
final class CameraService: NSObject, ObservableObject {
enum State: Equatable {
case idle
case running
case denied
case failed
}
@Published var state: State = .idle
@Published var isTorchOn = false
let session = AVCaptureSession()
private let photoOutput = AVCapturePhotoOutput()
private var videoDevice: AVCaptureDevice?
private var isConfigured = false
private var captureHandler: ((Data?) -> Void)?
func start() async {
let granted: Bool
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
granted = true
case .notDetermined:
granted = await AVCaptureDevice.requestAccess(for: .video)
default:
granted = false
}
guard granted else {
state = .denied
return
}
if !isConfigured { configure() }
guard isConfigured else {
state = .failed
return
}
guard !session.isRunning else {
state = .running
return
}
let session = self.session
await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
session.startRunning()
continuation.resume()
}
}
state = .running
}
func stop() {
setTorch(on: false)
guard session.isRunning else { return }
let session = self.session
DispatchQueue.global(qos: .userInitiated).async {
session.stopRunning()
}
state = .idle
}
private func configure() {
session.beginConfiguration()
defer { session.commitConfiguration() }
session.sessionPreset = .photo
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input),
session.canAddOutput(photoOutput)
else {
print("카메라 구성 실패")
return
}
session.addInput(input)
session.addOutput(photoOutput)
videoDevice = device
isConfigured = true
}
// MARK: -
func toggleTorch() {
setTorch(on: !isTorchOn)
}
private func setTorch(on: Bool) {
guard let device = videoDevice, device.hasTorch else { return }
do {
try device.lockForConfiguration()
device.torchMode = on ? .on : .off
device.unlockForConfiguration()
isTorchOn = on
} catch {
print("손전등 설정 실패: \(error)")
}
}
// MARK: -
func capturePhoto(completion: @escaping (Data?) -> Void) {
guard state == .running, captureHandler == nil else {
completion(nil)
return
}
captureHandler = completion
let settings = AVCapturePhotoSettings()
photoOutput.capturePhoto(with: settings, delegate: self)
}
fileprivate func finishCapture(_ data: Data?) {
captureHandler?(data)
captureHandler = nil
}
}
extension CameraService: AVCapturePhotoCaptureDelegate {
nonisolated func photoOutput(
_ output: AVCapturePhotoOutput,
didFinishProcessingPhoto photo: AVCapturePhoto,
error: Error?
) {
if let error {
print("사진 촬영 실패: \(error)")
}
let data = photo.fileDataRepresentation()
Task { @MainActor in
self.finishCapture(data)
}
}
}
/// .
struct CameraPreview: UIViewRepresentable {
let session: AVCaptureSession
final class PreviewView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var previewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
}
func makeUIView(context: Context) -> PreviewView {
let view = PreviewView()
view.previewLayer.session = session
view.previewLayer.videoGravity = .resizeAspectFill
return view
}
func updateUIView(_ uiView: PreviewView, context: Context) {}
}