import SwiftUI import SwiftData /// 특정 날짜에 기록된 제품 사진 목록. struct DayDetailView: View { let store: Store let year: Int let month: Int let day: Int @Environment(\.modelContext) private var context @Query private var allRecords: [ProductRecord] @State private var viewer: ViewerLaunch? @State private var isSelecting = false @State private var selectedIDs = Set() @State private var confirmBulkDelete = false @State private var editTarget: ProductRecord? private struct ViewerLaunch: Identifiable { let index: Int var id: Int { index } } private var records: [ProductRecord] { allRecords .filter { guard $0.store === store else { return false } let c = KoreanCalendar.calendar.dateComponents([.year, .month, .day], from: $0.expiryDate) return c.year == year && c.month == month && c.day == day } .sorted { $0.createdAt < $1.createdAt } } var body: some View { ZStack { Theme.bg.ignoresSafeArea() VStack(alignment: .leading, spacing: 18) { header if records.isEmpty { emptyState } else { photoGrid } Spacer(minLength: 0) } .padding(20) } .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) .presentationBackground(Theme.bg) .fullScreenCover(item: $viewer) { launch in PhotoViewerView(store: store, year: year, month: month, day: day, startIndex: launch.index) } .overlay(alignment: .bottom) { if isSelecting { selectionBar } } .onChange(of: records.count) { if records.isEmpty { exitSelection() } } .confirmationDialog( "사진 \(selectedIDs.count)장을 삭제할까요?", isPresented: $confirmBulkDelete, titleVisibility: .visible ) { Button("삭제", role: .destructive) { deleteSelected() } Button("취소", role: .cancel) {} } .sheet(item: $editTarget) { record in RecordEditView(record: record) } } // MARK: - 헤더 private var header: some View { HStack(alignment: .center, spacing: 10) { VStack(alignment: .leading, spacing: 3) { Text(dateTitle) .font(.system(.title3, design: .rounded).weight(.bold)) .foregroundStyle(Theme.textPrimary) if !records.isEmpty { Text("제품 \(records.count)개") .font(.caption) .foregroundStyle(Theme.textSecondary) } } Spacer() ddayChip if !records.isEmpty { selectToggleButton } } .padding(.top, 14) } private var selectToggleButton: some View { Button { if isSelecting { exitSelection() } else { isSelecting = true } Haptics.tap() } label: { Text(isSelecting ? "취소" : "선택") .font(.caption.weight(.bold)) .foregroundStyle(isSelecting ? Theme.textPrimary : Theme.accent) .padding(.horizontal, 12) .padding(.vertical, 6) .background(Capsule().fill(isSelecting ? Theme.surfaceHi : Theme.accentSoft)) } } private func exitSelection() { isSelecting = false selectedIDs = [] } private var date: Date { KoreanCalendar.date(year: year, month: month, day: day) ?? .now } private var dateTitle: String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "ko_KR") formatter.dateFormat = "M월 d일 EEEE" return formatter.string(from: date) } private func timeText(_ date: Date) -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "ko_KR") formatter.dateFormat = "a h:mm" return formatter.string(from: date) } private var ddayChip: some View { let diff = KoreanCalendar.daysFromToday(to: date) let (text, color): (String, Color) = diff > 0 ? ("D-\(diff)", Theme.accent) : diff == 0 ? ("오늘까지", Theme.warn) : ("\(-diff)일 지남", Theme.danger) return Text(text) .font(.caption.weight(.bold)) .foregroundStyle(color) .padding(.horizontal, 10) .padding(.vertical, 5) .background(Capsule().fill(color.opacity(0.15))) } // MARK: - 사진 그리드 private var photoGrid: some View { let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 3) return ScrollView { LazyVGrid(columns: columns, spacing: 8) { ForEach(Array(records.enumerated()), id: \.element.persistentModelID) { index, record in photoCell(record: record, index: index) } } .padding(.bottom, isSelecting ? 70 : 0) } .scrollIndicators(.hidden) } private func photoCell(record: ProductRecord, index: Int) -> some View { let isSelected = selectedIDs.contains(record.persistentModelID) return Button { if isSelecting { toggleSelection(record) } else { viewer = ViewerLaunch(index: index) } } label: { Color.clear .aspectRatio(1, contentMode: .fit) .overlay { if let image = UIImage(data: record.thumbnailData) { Image(uiImage: image) .resizable() .scaledToFill() } else { Theme.surfaceHi } } .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay { if isSelecting && isSelected { RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(Theme.accent.opacity(0.22)) } } .overlay(alignment: .topTrailing) { if isSelecting { Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") .font(.title3) .symbolRenderingMode(isSelected ? .palette : .monochrome) .foregroundStyle(isSelected ? Theme.onAccent : .white.opacity(0.85), Theme.accent) .shadow(color: .black.opacity(0.5), radius: 3) .padding(6) } } .overlay(alignment: .bottomLeading) { if record.hasTime { Label(timeText(record.expiryDate), systemImage: "clock.fill") .font(.system(size: 10, weight: .bold, design: .rounded)) .foregroundStyle(.white) .padding(.horizontal, 6) .padding(.vertical, 3) .background(Capsule().fill(.black.opacity(0.55))) .padding(5) } } .overlay( RoundedRectangle(cornerRadius: 14, style: .continuous) .strokeBorder( isSelecting && isSelected ? Theme.accent : Theme.stroke, lineWidth: isSelecting && isSelected ? 2 : 1 ) ) } .buttonStyle(.plain) .contextMenu { if !isSelecting { Button { editTarget = record } label: { Label("날짜·시간 수정", systemImage: "calendar.badge.clock") } Button(role: .destructive) { delete(record) } label: { Label("삭제", systemImage: "trash") } } } } private func toggleSelection(_ record: ProductRecord) { let id = record.persistentModelID if selectedIDs.contains(id) { selectedIDs.remove(id) } else { selectedIDs.insert(id) } Haptics.tap() } // MARK: - 선택 삭제 바 private var selectionBar: some View { let allSelected = selectedIDs.count == records.count && !records.isEmpty return HStack(spacing: 12) { Button { selectedIDs = allSelected ? [] : Set(records.map(\.persistentModelID)) Haptics.tap() } label: { Text(allSelected ? "전체 해제" : "전체 선택") .font(.system(.subheadline, design: .rounded).weight(.semibold)) .foregroundStyle(Theme.textPrimary) .padding(.horizontal, 18) .frame(height: 46) .background(Capsule().fill(Theme.surfaceHi)) } Button { confirmBulkDelete = true } label: { Label("\(selectedIDs.count)장 삭제", systemImage: "trash.fill") .font(.system(.subheadline, design: .rounded).weight(.bold)) .foregroundStyle(.white) .frame(maxWidth: .infinity) .frame(height: 46) .background(Capsule().fill(selectedIDs.isEmpty ? Theme.danger.opacity(0.3) : Theme.danger)) } .disabled(selectedIDs.isEmpty) } .padding(.horizontal, 20) .padding(.bottom, 12) } private func deleteSelected() { let targets = records.filter { selectedIDs.contains($0.persistentModelID) } for record in targets { context.delete(record) } try? context.save() exitSelection() Haptics.warning() } private func delete(_ record: ProductRecord) { context.delete(record) try? context.save() Haptics.warning() } // MARK: - 빈 상태 private var emptyState: some View { VStack(spacing: 12) { Image(systemName: "photo.on.rectangle.angled") .font(.system(size: 38)) .foregroundStyle(Theme.textSecondary.opacity(0.6)) Text("이 날짜에 기록이 없어요") .font(.subheadline) .foregroundStyle(Theme.textSecondary) } .frame(maxWidth: .infinity) .padding(.vertical, 60) } } /// 전체 화면 사진 뷰어 (좌우 스와이프 + 삭제) struct PhotoViewerView: View { let store: Store let year: Int let month: Int let day: Int let startIndex: Int @Environment(\.modelContext) private var context @Environment(\.dismiss) private var dismiss @Query private var allRecords: [ProductRecord] @State private var index: Int @State private var confirmDelete = false @State private var editTarget: ProductRecord? init(store: Store, year: Int, month: Int, day: Int, startIndex: Int) { self.store = store self.year = year self.month = month self.day = day self.startIndex = startIndex _index = State(initialValue: startIndex) } private var records: [ProductRecord] { allRecords .filter { guard $0.store === store else { return false } let c = KoreanCalendar.calendar.dateComponents([.year, .month, .day], from: $0.expiryDate) return c.year == year && c.month == month && c.day == day } .sorted { $0.createdAt < $1.createdAt } } var body: some View { ZStack { Color.black.ignoresSafeArea() if records.isEmpty { Color.clear.onAppear { dismiss() } } else { pager } } .overlay(alignment: .top) { topBar } .onChange(of: records.count) { if records.isEmpty { dismiss() } else if index > records.count - 1 { index = records.count - 1 } } .confirmationDialog("이 사진을 삭제할까요?", isPresented: $confirmDelete, titleVisibility: .visible) { Button("삭제", role: .destructive) { deleteCurrent() } Button("취소", role: .cancel) {} } .sheet(item: $editTarget) { record in RecordEditView(record: record) } } private var currentRecord: ProductRecord? { let current = records guard !current.isEmpty else { return nil } return current[min(index, current.count - 1)] } private var pager: some View { TabView(selection: $index) { ForEach(Array(records.enumerated()), id: \.element.persistentModelID) { i, record in Group { if let image = UIImage(data: record.imageData) { Image(uiImage: image) .resizable() .scaledToFit() } else { Text("사진을 불러올 수 없어요") .foregroundStyle(Theme.textSecondary) } } .tag(i) } } .tabViewStyle(.page(indexDisplayMode: .never)) } private var topBar: some View { HStack { circleButton(systemName: "xmark") { dismiss() } Spacer() if !records.isEmpty { Text(verbatim: "\(min(index, records.count - 1) + 1) / \(records.count)") .font(.system(.subheadline, design: .rounded).weight(.semibold)) .foregroundStyle(.white.opacity(0.9)) } Spacer() HStack(spacing: 10) { circleButton(systemName: "calendar.badge.clock") { editTarget = currentRecord Haptics.tap() } circleButton(systemName: "trash", tint: Theme.danger) { confirmDelete = true } } } .padding(.horizontal, 20) .padding(.top, 8) } private func circleButton(systemName: String, tint: Color = .white, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: systemName) .font(.body.weight(.semibold)) .foregroundStyle(tint) .frame(width: 40, height: 40) .background(Circle().fill(.white.opacity(0.12))) } } private func deleteCurrent() { guard let target = currentRecord else { return } context.delete(target) try? context.save() Haptics.warning() } }