import SwiftUI import SwiftData /// 스캔 플로우: 촬영 → 확인(재촬영/사용) → 날짜 선택 → 저장 → 다시 촬영. struct CaptureFlowView: View { let store: Store let year: Int let month: Int @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var context @StateObject private var camera = CameraService() @AppStorage(weekStartStorageKey) private var weekStartsMonday = false private enum Phase { case camera case review(UIImage) case pickDate(UIImage) } @State private var phase: Phase = .camera @State private var selectedDay: Int? @State private var includeTime = false @State private var timeSelection = Self.defaultTime @State private var isCapturing = false @State private var toastText: String? @State private var toastTask: Task? /// 시간 추가 시 기본으로 보여줄 시각 (오후 6시). 자정 기본값이 실수로 저장되는 것을 막는다. private static var defaultTime: Date { KoreanCalendar.calendar.date(bySettingHour: 18, minute: 0, second: 0, of: .now) ?? .now } var body: some View { ZStack { Color.black.ignoresSafeArea() switch phase { case .camera: cameraPhase case .review(let image): reviewPhase(image) case .pickDate(let image): pickDatePhase(image) } } .statusBarHidden() .task { await camera.start() } .onDisappear { camera.stop() toastTask?.cancel() } } // MARK: - 1. 카메라 private var cameraPhase: some View { ZStack { switch camera.state { case .running, .idle: CameraPreview(session: camera.session) .ignoresSafeArea() case .denied: permissionDeniedView case .failed: cameraFailedView } VStack { topBar if let toastText { toast(toastText) .transition(.move(edge: .top).combined(with: .opacity)) } Spacer() if camera.state == .running || camera.state == .idle { shutterButton } } .padding(.horizontal, 20) .padding(.bottom, 30) } .animation(.snappy, value: toastText) } private var topBar: some View { HStack { overlayCircleButton(systemName: "xmark") { dismiss() } Spacer() Text(verbatim: "\(year)년 \(month)월") .font(.system(.subheadline, design: .rounded).weight(.bold)) .foregroundStyle(.white) .padding(.horizontal, 14) .padding(.vertical, 7) .background(Capsule().fill(.white.opacity(0.12))) Spacer() overlayCircleButton( systemName: camera.isTorchOn ? "bolt.fill" : "bolt.slash", tint: camera.isTorchOn ? Theme.warn : .white ) { camera.toggleTorch() Haptics.tap() } } .padding(.top, 14) } private var shutterButton: some View { Button { capture() } label: { ZStack { Circle() .strokeBorder(.white.opacity(0.9), lineWidth: 4) .frame(width: 76, height: 76) Circle() .fill(.white) .frame(width: 60, height: 60) .scaleEffect(isCapturing ? 0.8 : 1) } } .disabled(isCapturing || camera.state != .running) .animation(.spring(duration: 0.2), value: isCapturing) } private func capture() { guard !isCapturing else { return } isCapturing = true Haptics.shutter() camera.capturePhoto { data in isCapturing = false guard let data, let image = UIImage(data: data) else { showToast("촬영에 실패했어요. 다시 시도해 주세요.") return } withAnimation(.snappy) { phase = .review(image) } } } // MARK: - 2. 확인 (재촬영 / 사용) private func reviewPhase(_ image: UIImage) -> some View { VStack(spacing: 0) { Image(uiImage: image) .resizable() .scaledToFit() .frame(maxWidth: .infinity, maxHeight: .infinity) .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) .padding(.horizontal, 16) .padding(.top, 24) HStack(spacing: 12) { secondaryButton(title: "재촬영", systemName: "arrow.counterclockwise") { withAnimation(.snappy) { phase = .camera } } primaryButton(title: "사용하기", systemName: "checkmark") { withAnimation(.snappy) { phase = .pickDate(image) } } } .padding(20) } } // MARK: - 3. 날짜 선택 private func pickDatePhase(_ image: UIImage) -> some View { VStack(spacing: 20) { VStack(spacing: 4) { Text("유통기한 선택") .font(.system(.title3, design: .rounded).weight(.bold)) .foregroundStyle(Theme.textPrimary) Text(verbatim: "\(year)년 \(month)월 중에서 선택하세요") .font(.footnote) .foregroundStyle(Theme.textSecondary) } .padding(.top, 24) Image(uiImage: image) .resizable() .scaledToFill() .frame(width: 130, height: 130) .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 18, style: .continuous) .strokeBorder(Theme.stroke, lineWidth: 1) ) dayPickerGrid .padding(.horizontal, 20) if selectedDay != nil { timeRow .padding(.horizontal, 20) } Spacer(minLength: 0) HStack(spacing: 12) { secondaryButton(title: "재촬영", systemName: "arrow.counterclockwise") { resetPickState() withAnimation(.snappy) { phase = .camera } } primaryButton(title: "저장하기", systemName: "checkmark.circle.fill") { save(image: image) } .disabled(selectedDay == nil) .opacity(selectedDay == nil ? 0.4 : 1) } .padding(20) } .background(Theme.bg.ignoresSafeArea()) } private var dayPickerGrid: some View { let columns = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7) let days = KoreanCalendar.gridDays(year: year, month: month, startsOnMonday: weekStartsMonday) let symbols = KoreanCalendar.weekdaySymbols(startsOnMonday: weekStartsMonday) return VStack(spacing: 8) { HStack(spacing: 4) { ForEach(0..<7, id: \.self) { index in Text(symbols[index]) .font(.caption2.weight(.semibold)) .foregroundStyle(KoreanCalendar.weekdayColor(columnIndex: index, startsOnMonday: weekStartsMonday)) .frame(maxWidth: .infinity) } } LazyVGrid(columns: columns, spacing: 4) { ForEach(Array(days.enumerated()), id: \.offset) { _, day in if let day { dayButton(day) } else { Color.clear.frame(height: 42) } } } } .padding(14) .cardStyle() } private func dayButton(_ day: Int) -> some View { let isSelected = selectedDay == day let isToday = KoreanCalendar.isToday(year: year, month: month, day: day) return Button { selectedDay = day Haptics.tap() } label: { Text(verbatim: "\(day)") .font(.system(size: 15, weight: isSelected ? .bold : .medium, design: .rounded)) .foregroundStyle(isSelected ? Theme.onAccent : Theme.textPrimary) .frame(maxWidth: .infinity) .frame(height: 42) .background( Circle() .fill(isSelected ? AnyShapeStyle(Theme.accentGradient) : AnyShapeStyle(.clear)) .aspectRatio(1, contentMode: .fit) ) .overlay( Circle() .strokeBorder(isToday && !isSelected ? Theme.accent.opacity(0.7) : .clear, lineWidth: 1.2) .aspectRatio(1, contentMode: .fit) ) } .buttonStyle(.plain) } // MARK: - 시간 추가 (선택) /// 기본은 날짜만. 유제품처럼 시각이 필요한 경우에만 눈에 띄지 않게 펼쳐 쓴다. @ViewBuilder private var timeRow: some View { if includeTime { HStack(spacing: 10) { Image(systemName: "clock.fill") .font(.footnote) .foregroundStyle(Theme.accent) Text("유통 시각") .font(.system(.subheadline, design: .rounded).weight(.semibold)) .foregroundStyle(Theme.textPrimary) Spacer() DatePicker( "", selection: $timeSelection, displayedComponents: .hourAndMinute ) .labelsHidden() .tint(Theme.accent) Button { withAnimation(.snappy) { includeTime = false } Haptics.tap() } label: { Image(systemName: "xmark.circle.fill") .font(.title3) .foregroundStyle(Theme.textSecondary) } .buttonStyle(.plain) } .padding(.horizontal, 14) .padding(.vertical, 10) .cardStyle() } else { Button { withAnimation(.snappy) { includeTime = true } Haptics.tap() } label: { Label("시간 추가", systemImage: "clock") .font(.system(.footnote, design: .rounded).weight(.semibold)) .foregroundStyle(Theme.textSecondary) .padding(.horizontal, 14) .padding(.vertical, 8) .background(Capsule().fill(Theme.surfaceHi)) } .buttonStyle(.plain) .frame(maxWidth: .infinity, alignment: .center) } } private func resetPickState() { selectedDay = nil includeTime = false timeSelection = Self.defaultTime } // MARK: - 저장 private func save(image: UIImage) { guard let day = selectedDay, let baseDate = KoreanCalendar.date(year: year, month: month, day: day) else { return } let calendar = KoreanCalendar.calendar let expiryDate: Date if includeTime { let t = calendar.dateComponents([.hour, .minute], from: timeSelection) expiryDate = calendar.date( bySettingHour: t.hour ?? 0, minute: t.minute ?? 0, second: 0, of: baseDate ) ?? calendar.startOfDay(for: baseDate) } else { expiryDate = calendar.startOfDay(for: baseDate) } let fullImage = image.downscaled(maxDimension: 1600) let thumbnail = image.downscaled(maxDimension: 360) guard let imageData = fullImage.jpegData(compressionQuality: 0.8), let thumbnailData = thumbnail.jpegData(compressionQuality: 0.7) else { showToast("사진 저장에 실패했어요.") return } let record = ProductRecord( expiryDate: expiryDate, hasTime: includeTime, imageData: imageData, thumbnailData: thumbnailData, store: store ) context.insert(record) try? context.save() Haptics.success() showToast(savedToastText(day: day, date: expiryDate)) resetPickState() withAnimation(.snappy) { phase = .camera } } private func savedToastText(day: Int, date: Date) -> String { if includeTime { let formatter = DateFormatter() formatter.locale = Locale(identifier: "ko_KR") formatter.dateFormat = "a h:mm" return "\(month)월 \(day)일 \(formatter.string(from: date))에 저장했어요" } return "\(month)월 \(day)일에 저장했어요" } // MARK: - 공용 컴포넌트 private func showToast(_ text: String) { toastTask?.cancel() toastText = text toastTask = Task { try? await Task.sleep(for: .seconds(1.8)) guard !Task.isCancelled else { return } toastText = nil } } private func toast(_ text: String) -> some View { Label(text, systemImage: "checkmark.circle.fill") .font(.system(.subheadline, design: .rounded).weight(.semibold)) .foregroundStyle(Theme.onAccent) .padding(.horizontal, 16) .padding(.vertical, 10) .background(Capsule().fill(Theme.accent)) .padding(.top, 10) } private func overlayCircleButton( systemName: String, tint: Color = .white, action: @escaping () -> Void ) -> some View { Button(action: action) { Image(systemName: systemName) .font(.body.weight(.semibold)) .foregroundStyle(tint) .frame(width: 42, height: 42) .background(Circle().fill(.white.opacity(0.12))) } } private func primaryButton(title: String, systemName: String, action: @escaping () -> Void) -> some View { Button(action: action) { Label(title, systemImage: systemName) .font(.system(.body, design: .rounded).weight(.bold)) .foregroundStyle(Theme.onAccent) .frame(maxWidth: .infinity) .frame(height: 54) .background(Capsule().fill(Theme.accentGradient)) } } private func secondaryButton(title: String, systemName: String, action: @escaping () -> Void) -> some View { Button(action: action) { Label(title, systemImage: systemName) .font(.system(.body, design: .rounded).weight(.semibold)) .foregroundStyle(Theme.textPrimary) .frame(maxWidth: .infinity) .frame(height: 54) .background(Capsule().fill(Theme.surfaceHi)) } } // MARK: - 권한/오류 안내 private var permissionDeniedView: some View { VStack(spacing: 16) { Image(systemName: "camera.badge.ellipsis") .font(.system(size: 44)) .foregroundStyle(Theme.textSecondary) Text("카메라 권한이 필요해요") .font(.system(.headline, design: .rounded)) .foregroundStyle(Theme.textPrimary) Text("설정에서 Expiranner의 카메라 접근을 허용해 주세요.") .font(.footnote) .foregroundStyle(Theme.textSecondary) .multilineTextAlignment(.center) Button { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } label: { Text("설정 열기") .font(.system(.subheadline, design: .rounded).weight(.bold)) .foregroundStyle(Theme.onAccent) .padding(.horizontal, 24) .padding(.vertical, 12) .background(Capsule().fill(Theme.accentGradient)) } } .padding(40) } private var cameraFailedView: some View { VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 40)) .foregroundStyle(Theme.warn) Text("카메라를 사용할 수 없어요") .font(.system(.headline, design: .rounded)) .foregroundStyle(Theme.textPrimary) } .padding(40) } }