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