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