import SwiftUI import SwiftData /// 특정 달의 캘린더. 날짜 칸마다 기록된 제품 사진 썸네일이 보인다. struct MonthCalendarView: View { let store: Store @Query private var allRecords: [ProductRecord] @AppStorage(weekStartStorageKey) private var weekStartsMonday = false @State private var year: Int @State private var month: Int @State private var selectedDay: DaySelection? @State private var showCapture = false init(store: Store, year: Int, month: Int) { self.store = store _year = State(initialValue: year) _month = State(initialValue: month) } private struct DaySelection: Identifiable { let day: Int var id: Int { day } } private var monthRecords: [ProductRecord] { allRecords.filter { guard $0.store === store else { return false } let c = KoreanCalendar.calendar.dateComponents([.year, .month], from: $0.expiryDate) return c.year == year && c.month == month } } private var recordsByDay: [Int: [ProductRecord]] { Dictionary(grouping: monthRecords) { KoreanCalendar.calendar.component(.day, from: $0.expiryDate) } .mapValues { $0.sorted { $0.createdAt < $1.createdAt } } } var body: some View { ZStack { Theme.bg.ignoresSafeArea() VStack(spacing: 16) { monthHeader weekdayRow ScrollView { calendarGrid .padding(.bottom, 100) } } .padding(.horizontal, 16) } .overlay(alignment: .bottomTrailing) { scanButton } .navigationTitle(store.name) .navigationBarTitleDisplayMode(.inline) .sheet(item: $selectedDay) { selection in DayDetailView(store: store, year: year, month: month, day: selection.day) } .fullScreenCover(isPresented: $showCapture) { CaptureFlowView(store: store, year: year, month: month) } } // MARK: - 헤더 private var monthHeader: some View { HStack(spacing: 12) { monthNavButton(systemName: "chevron.left") { moveMonth(-1) } Spacer() VStack(spacing: 2) { Text(verbatim: "\(year)년 \(month)월") .font(.system(size: 22, weight: .bold, design: .rounded)) .foregroundStyle(Theme.textPrimary) .contentTransition(.numericText()) Text(monthRecords.isEmpty ? "기록 없음" : "제품 \(monthRecords.count)개") .font(.caption) .foregroundStyle(monthRecords.isEmpty ? Theme.textSecondary : Theme.accent) } Spacer() monthNavButton(systemName: "chevron.right") { moveMonth(1) } } .padding(.top, 8) } private func monthNavButton(systemName: String, action: @escaping () -> Void) -> some View { Button { withAnimation(.snappy) { action() } Haptics.tap() } label: { Image(systemName: systemName) .font(.body.weight(.semibold)) .foregroundStyle(Theme.textPrimary) .frame(width: 38, height: 38) .background(Circle().fill(Theme.surface)) .overlay(Circle().strokeBorder(Theme.stroke, lineWidth: 1)) } } private func moveMonth(_ delta: Int) { var m = month + delta var y = year if m < 1 { m = 12; y -= 1 } if m > 12 { m = 1; y += 1 } month = m year = y } // MARK: - 요일 / 날짜 그리드 private var weekdayRow: some View { let symbols = KoreanCalendar.weekdaySymbols(startsOnMonday: weekStartsMonday) return HStack(spacing: 6) { ForEach(0..<7, id: \.self) { index in Text(symbols[index]) .font(.caption.weight(.semibold)) .foregroundStyle(KoreanCalendar.weekdayColor(columnIndex: index, startsOnMonday: weekStartsMonday)) .frame(maxWidth: .infinity) } } } private var calendarGrid: some View { let columns = Array(repeating: GridItem(.flexible(), spacing: 6), count: 7) let days = KoreanCalendar.gridDays(year: year, month: month, startsOnMonday: weekStartsMonday) return LazyVGrid(columns: columns, spacing: 6) { ForEach(Array(days.enumerated()), id: \.offset) { index, day in if let day { dayCell(day: day, columnIndex: index % 7) } else { Color.clear.frame(height: 68) } } } } private func dayCell(day: Int, columnIndex: Int) -> some View { let records = recordsByDay[day] ?? [] let isToday = KoreanCalendar.isToday(year: year, month: month, day: day) let hasPhoto = !records.isEmpty return Button { selectedDay = DaySelection(day: day) Haptics.tap() } label: { RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Theme.surface) .frame(height: 68) .overlay { if let first = records.first, let image = UIImage(data: first.thumbnailData) { Image(uiImage: image) .resizable() .scaledToFill() .overlay( LinearGradient( colors: [.black.opacity(0.55), .clear], startPoint: .top, endPoint: .center ) ) } } .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay(alignment: .topLeading) { Text(verbatim: "\(day)") .font(.system(size: 12, weight: isToday ? .heavy : .semibold, design: .rounded)) .foregroundStyle(dayNumberColor(columnIndex: columnIndex, hasPhoto: hasPhoto, isToday: isToday)) .padding(5) } .overlay(alignment: .bottomTrailing) { if records.count > 1 { Text(verbatim: "\(records.count)") .font(.system(size: 10, weight: .bold, design: .rounded)) .foregroundStyle(Theme.onAccent) .padding(.horizontal, 5) .padding(.vertical, 2) .background(Capsule().fill(Theme.accent)) .padding(4) } } .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder( isToday ? Theme.accent : Theme.stroke, lineWidth: isToday ? 1.5 : 1 ) ) } .buttonStyle(.plain) } private func dayNumberColor(columnIndex: Int, hasPhoto: Bool, isToday: Bool) -> Color { if isToday { return Theme.accent } if hasPhoto { return .white } return KoreanCalendar.weekdayColor(columnIndex: columnIndex, startsOnMonday: weekStartsMonday) } // MARK: - 스캔 버튼 private var scanButton: some View { Button { showCapture = true Haptics.tap() } label: { Image(systemName: "camera.fill") .font(.title2.weight(.semibold)) .foregroundStyle(Theme.onAccent) .frame(width: 62, height: 62) .background(Circle().fill(Theme.accentGradient)) .shadow(color: Theme.accent.opacity(0.4), radius: 16, y: 6) } .padding(.trailing, 24) .padding(.bottom, 28) } }