// // ScannerView.swift // Expiranner // // 전체 화면 카메라 스캐너: 촬영 → OCR → 팝업 → 날짜 선택 → 저장 import SwiftUI import SwiftData import VisionKit import Vision import UIKit struct ScannerView: View { /// 현재 보고 있는 연/월 (날짜 선택 범위 제한용) let year: Int let month: Int @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @State private var scannerHolder = ScannerHolder() @State private var isScanning = true @State private var capturedImage: UIImage? @State private var recognizedText = "" @State private var showPopup = false @State private var showDatePicker = false @State private var selectedDate = Date() /// 선택 가능한 날짜 범위: 현재 보고 있는 월로 제한 private var monthRange: ClosedRange { let calendar = Calendar.current let start = calendar.date(from: DateComponents(year: year, month: month, day: 1)) ?? Date() let last = calendar.date(byAdding: DateComponents(month: 1, day: -1), to: start) ?? start return start...last } var body: some View { ZStack { if DataScannerViewController.isSupported && DataScannerViewController.isAvailable { DataScannerRepresentable(holder: scannerHolder, isScanning: $isScanning) .ignoresSafeArea() } else { Color.black.ignoresSafeArea() Text("이 기기에서는 카메라 스캐너를 사용할 수 없습니다") .foregroundStyle(.white) } VStack { HStack { Spacer() Button { print("스캐너 종료") dismiss() } label: { Image(systemName: "xmark") .font(.title2) .foregroundStyle(.white) .padding(12) .background(Circle().fill(.black.opacity(0.5))) } .padding() } Spacer() Button { capturePhoto() } label: { Circle() .stroke(.white, lineWidth: 4) .frame(width: 72, height: 72) .overlay(Circle().fill(.white).frame(width: 58, height: 58)) } .padding(.bottom, 32) } if showPopup { ocrPopup } } .sheet(isPresented: $showDatePicker) { datePickerSheet } } /// OCR 결과 팝업: 썸네일 + 편집 가능한 텍스트 + [재촬영]/[적용] private var ocrPopup: some View { VStack(spacing: 16) { if let capturedImage { Image(uiImage: capturedImage) .resizable() .scaledToFit() .frame(maxHeight: 180) .clipShape(RoundedRectangle(cornerRadius: 8)) } TextField("인식된 텍스트", text: $recognizedText, axis: .vertical) .lineLimit(1...5) .textFieldStyle(.roundedBorder) HStack(spacing: 12) { Button("재촬영") { print("재촬영 선택") resumeScanning() } .frame(maxWidth: .infinity) .buttonStyle(.bordered) Button("적용") { print("적용 선택: \(recognizedText)") selectedDate = defaultDate() showDatePicker = true } .frame(maxWidth: .infinity) .buttonStyle(.borderedProminent) } } .padding() .background(RoundedRectangle(cornerRadius: 14).fill(Color(.systemBackground))) .padding(24) } /// 날짜 선택 시트: 현재 보고 있는 월로 제한 private var datePickerSheet: some View { NavigationStack { VStack { DatePicker("날짜", selection: $selectedDate, in: monthRange, displayedComponents: .date) .datePickerStyle(.graphical) .padding() Spacer() } .navigationTitle("\(String(year))년 \(month)월 날짜 선택") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("취소") { showDatePicker = false } } ToolbarItem(placement: .confirmationAction) { Button("저장") { saveItem() } } } } } /// 기본 선택 날짜: 오늘이 해당 월에 포함되면 오늘, 아니면 월의 첫날 private func defaultDate() -> Date { let today = Date() return monthRange.contains(today) ? today : monthRange.lowerBound } private func capturePhoto() { guard let scanner = scannerHolder.scanner else { print("스캐너를 사용할 수 없습니다") return } Task { do { let photo = try await scanner.capturePhoto() print("사진 촬영 완료") isScanning = false capturedImage = photo recognizedText = recognizeText(in: photo) showPopup = true } catch { print("사진 촬영 실패: \(error.localizedDescription)") } } } /// Vision 프레임워크로 한국어 텍스트 추출 private func recognizeText(in image: UIImage) -> String { guard let cgImage = image.cgImage else { return "" } let request = VNRecognizeTextRequest() request.recognitionLanguages = ["ko-KR", "en-US"] request.recognitionLevel = .accurate request.usesLanguageCorrection = true let handler = VNImageRequestHandler(cgImage: cgImage) do { try handler.perform([request]) } catch { print("텍스트 인식 실패: \(error.localizedDescription)") return "" } let text = request.results? .compactMap { $0.topCandidates(1).first?.string } .joined(separator: "\n") ?? "" print("인식된 텍스트: \(text)") return text } private func saveItem() { let imageData = capturedImage?.jpegData(compressionQuality: 0.5) let item = ProductItem(name: recognizedText, date: selectedDate, imageData: imageData) modelContext.insert(item) print("저장 완료: \(item.name) → \(selectedDate)") // 저장 시 햅틱 피드백 UINotificationFeedbackGenerator().notificationOccurred(.success) showDatePicker = false resumeScanning() } /// 팝업을 닫고 카메라 스캐너로 복귀 private func resumeScanning() { showPopup = false capturedImage = nil recognizedText = "" isScanning = true } } /// DataScannerViewController 참조 보관용 @Observable final class ScannerHolder { var scanner: DataScannerViewController? } struct DataScannerRepresentable: UIViewControllerRepresentable { let holder: ScannerHolder @Binding var isScanning: Bool func makeUIViewController(context: Context) -> DataScannerViewController { let scanner = DataScannerViewController( recognizedDataTypes: [.text(languages: ["ko-KR"])], qualityLevel: .balanced, isHighlightingEnabled: false ) holder.scanner = scanner return scanner } func updateUIViewController(_ scanner: DataScannerViewController, context: Context) { if isScanning, !scanner.isScanning { do { try scanner.startScanning() print("스캔 시작") } catch { print("스캔 시작 실패: \(error.localizedDescription)") } } else if !isScanning, scanner.isScanning { scanner.stopScanning() print("스캔 중지") } } }