- 매장 선택 → 연/월 → 월 캘린더(날짜별 제품 사진 썸네일) → 날짜 상세 플로우로 전면 재작성 - 스캔 플로우: 촬영 → 재촬영/사용 → 그 달 날짜만 선택 → 저장 후 카메라 자동 복귀 (손전등 지원) - 날짜 상세 다중 선택 삭제 (전체 선택/해제 + 일괄 삭제 확인) - 다크 전용 민트 테마, SwiftData 모델(Store ↔ ProductRecord, 원본+썸네일 분리 저장) - 밝은 민트 앱 아이콘(SVG 원본 포함) + 런치 스크린/인앱 스플래시 - CLAUDE.md 새 구조 기준으로 재작성 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
159 lines
4.4 KiB
Swift
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) {}
|
|
}
|