mycode/myApp/Expiranner/IOS/MonthCalendarView.swift
songyc macbook 9902d44848 feat(expiranner): rebuild as store-based expiry photo checker
- 매장 선택 → 연/월 → 월 캘린더(날짜별 제품 사진 썸네일) → 날짜 상세 플로우로 전면 재작성
- 스캔 플로우: 촬영 → 재촬영/사용 → 그 달 날짜만 선택 → 저장 후 카메라 자동 복귀 (손전등 지원)
- 날짜 상세 다중 선택 삭제 (전체 선택/해제 + 일괄 삭제 확인)
- 다크 전용 민트 테마, SwiftData 모델(Store ↔ ProductRecord, 원본+썸네일 분리 저장)
- 밝은 민트 앱 아이콘(SVG 원본 포함) + 런치 스크린/인앱 스플래시
- CLAUDE.md 새 구조 기준으로 재작성

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:53:14 +09:00

214 lines
7.7 KiB
Swift

import SwiftUI
import SwiftData
/// . .
struct MonthCalendarView: View {
let store: Store
@Query private var allRecords: [ProductRecord]
@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 {
HStack(spacing: 6) {
ForEach(0..<7, id: \.self) { index in
Text(KoreanCalendar.weekdaySymbols[index])
.font(.caption.weight(.semibold))
.foregroundStyle(KoreanCalendar.weekdayColor(columnIndex: index))
.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)
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)
}
// 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)
}
}