mycode/myApp/Expiranner/IOS/MonthCalendarView.swift
songyc macbook ceb0ab8e16 feat(expiranner): 기록 날짜·시간 수정 + 매장별 지난 데이터 정리 + 주 시작 요일 설정
- RecordEditView 신규: 저장된 기록의 유통기한을 그래픽 달력 + '시간 추가'로 수정.
  날짜 상세 그리드의 길게 누르기 메뉴와 전체화면 뷰어 상단 버튼 양쪽에서 진입
- 설정 '데이터 관리'를 매장별로 분리: 지난 기록이 있는 매장만 행으로 나열해
  개별 삭제, 2개 매장 이상일 때만 '모든 매장에서 삭제' 행 노출(각각 확인 대화상자)
- 주 시작 요일 설정(일요일/월요일): 월 캘린더와 촬영 플로우 미니 달력의
  그리드·요일 기호·주말 색이 모두 따라감 (기본은 기존과 같은 일요일 시작)
- CLAUDE.md를 현재 동작(설정 화면, hasTime, 동적 색)에 맞게 갱신

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J2HQuA6RPtkh45QTMN83i2
2026-07-28 02:29:36 +09:00

216 lines
8.0 KiB
Swift

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)
}
}