- RecordEditView 신규: 저장된 기록의 유통기한을 그래픽 달력 + '시간 추가'로 수정. 날짜 상세 그리드의 길게 누르기 메뉴와 전체화면 뷰어 상단 버튼 양쪽에서 진입 - 설정 '데이터 관리'를 매장별로 분리: 지난 기록이 있는 매장만 행으로 나열해 개별 삭제, 2개 매장 이상일 때만 '모든 매장에서 삭제' 행 노출(각각 확인 대화상자) - 주 시작 요일 설정(일요일/월요일): 월 캘린더와 촬영 플로우 미니 달력의 그리드·요일 기호·주말 색이 모두 따라감 (기본은 기존과 같은 일요일 시작) - CLAUDE.md를 현재 동작(설정 화면, hasTime, 동적 색)에 맞게 갱신 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J2HQuA6RPtkh45QTMN83i2
479 lines
17 KiB
Swift
479 lines
17 KiB
Swift
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<Void, Never>?
|
|
|
|
/// 시간 추가 시 기본으로 보여줄 시각 (오후 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)
|
|
}
|
|
}
|