mycode/myApp/Expiranner/IOS/DayDetailView.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

437 lines
15 KiB
Swift

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<PersistentIdentifier>()
@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()
}
}