243 lines
8.2 KiB
Swift
243 lines
8.2 KiB
Swift
//
|
|
// 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<Date> {
|
|
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("스캔 중지")
|
|
}
|
|
}
|
|
}
|