- 매장 선택 → 연/월 → 월 캘린더(날짜별 제품 사진 썸네일) → 날짜 상세 플로우로 전면 재작성 - 스캔 플로우: 촬영 → 재촬영/사용 → 그 달 날짜만 선택 → 저장 후 카메라 자동 복귀 (손전등 지원) - 날짜 상세 다중 선택 삭제 (전체 선택/해제 + 일괄 삭제 확인) - 다크 전용 민트 테마, SwiftData 모델(Store ↔ ProductRecord, 원본+썸네일 분리 저장) - 밝은 민트 앱 아이콘(SVG 원본 포함) + 런치 스크린/인앱 스플래시 - CLAUDE.md 새 구조 기준으로 재작성 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
230 lines
7.8 KiB
Swift
230 lines
7.8 KiB
Swift
import SwiftUI
|
|
import SwiftData
|
|
|
|
/// 첫 화면: 점검할 매장을 고르거나 새로 추가한다.
|
|
struct StoreListView: View {
|
|
@Environment(\.modelContext) private var context
|
|
@Query(sort: \Store.createdAt) private var stores: [Store]
|
|
@Query private var allRecords: [ProductRecord]
|
|
|
|
@State private var showingAdd = false
|
|
@State private var newName = ""
|
|
@State private var renameTarget: Store?
|
|
@State private var renameText = ""
|
|
@State private var deleteTarget: Store?
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
Theme.bg.ignoresSafeArea()
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 24) {
|
|
header
|
|
if stores.isEmpty {
|
|
emptyState
|
|
} else {
|
|
storeList
|
|
}
|
|
}
|
|
.padding(20)
|
|
}
|
|
}
|
|
.navigationDestination(for: Store.self) { store in
|
|
YearMonthView(store: store)
|
|
}
|
|
.toolbar(.hidden, for: .navigationBar)
|
|
}
|
|
.alert("새 매장 추가", isPresented: $showingAdd) {
|
|
TextField("매장 이름", text: $newName)
|
|
Button("추가") { addStore() }
|
|
Button("취소", role: .cancel) { newName = "" }
|
|
} message: {
|
|
Text("점검할 매장의 이름을 입력하세요.")
|
|
}
|
|
.alert("이름 변경", isPresented: isRenaming) {
|
|
TextField("매장 이름", text: $renameText)
|
|
Button("변경") { renameStore() }
|
|
Button("취소", role: .cancel) { renameTarget = nil }
|
|
}
|
|
.confirmationDialog(
|
|
deleteMessage,
|
|
isPresented: isDeleting,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("삭제", role: .destructive) { deleteStore() }
|
|
Button("취소", role: .cancel) { deleteTarget = nil }
|
|
}
|
|
}
|
|
|
|
// MARK: - 헤더
|
|
|
|
private var header: some View {
|
|
HStack(alignment: .top) {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("유통기한 점검")
|
|
.font(.caption.weight(.bold))
|
|
.foregroundStyle(Theme.accent)
|
|
.kerning(1)
|
|
Text("어느 매장을\n점검할까요?")
|
|
.font(.system(size: 30, weight: .bold, design: .rounded))
|
|
.foregroundStyle(Theme.textPrimary)
|
|
.lineSpacing(3)
|
|
}
|
|
Spacer()
|
|
Button {
|
|
newName = ""
|
|
showingAdd = true
|
|
} label: {
|
|
Image(systemName: "plus")
|
|
.font(.title3.weight(.semibold))
|
|
.foregroundStyle(Theme.onAccent)
|
|
.frame(width: 44, height: 44)
|
|
.background(Circle().fill(Theme.accentGradient))
|
|
.shadow(color: Theme.accent.opacity(0.35), radius: 12, y: 4)
|
|
}
|
|
}
|
|
.padding(.top, 12)
|
|
}
|
|
|
|
// MARK: - 매장 목록
|
|
|
|
private var storeList: some View {
|
|
VStack(spacing: 12) {
|
|
ForEach(stores) { store in
|
|
NavigationLink(value: store) {
|
|
storeCard(store)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.contextMenu {
|
|
Button {
|
|
renameText = store.name
|
|
renameTarget = store
|
|
} label: {
|
|
Label("이름 변경", systemImage: "pencil")
|
|
}
|
|
Button(role: .destructive) {
|
|
deleteTarget = store
|
|
} label: {
|
|
Label("매장 삭제", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func storeCard(_ store: Store) -> some View {
|
|
let counts = recordCounts(for: store)
|
|
return HStack(spacing: 14) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.fill(Theme.accentSoft)
|
|
.frame(width: 50, height: 50)
|
|
Image(systemName: "storefront.fill")
|
|
.font(.title3)
|
|
.foregroundStyle(Theme.accent)
|
|
}
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(store.name)
|
|
.font(.system(.headline, design: .rounded))
|
|
.foregroundStyle(Theme.textPrimary)
|
|
Text(countLine(counts))
|
|
.font(.caption)
|
|
.foregroundStyle(Theme.textSecondary)
|
|
}
|
|
Spacer()
|
|
Image(systemName: "chevron.right")
|
|
.font(.footnote.weight(.semibold))
|
|
.foregroundStyle(Theme.textSecondary)
|
|
}
|
|
.padding(16)
|
|
.cardStyle()
|
|
}
|
|
|
|
private func countLine(_ counts: (total: Int, thisMonth: Int)) -> String {
|
|
counts.total == 0
|
|
? "아직 기록이 없어요"
|
|
: "전체 \(counts.total)개 · 이번 달 \(counts.thisMonth)개"
|
|
}
|
|
|
|
// MARK: - 빈 상태
|
|
|
|
private var emptyState: some View {
|
|
VStack(spacing: 14) {
|
|
Image(systemName: "storefront")
|
|
.font(.system(size: 44))
|
|
.foregroundStyle(Theme.accent.opacity(0.7))
|
|
Text("등록된 매장이 없어요")
|
|
.font(.system(.headline, design: .rounded))
|
|
.foregroundStyle(Theme.textPrimary)
|
|
Text("오른쪽 위 + 버튼으로\n첫 매장을 추가해 보세요.")
|
|
.font(.subheadline)
|
|
.foregroundStyle(Theme.textSecondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 70)
|
|
.cardStyle()
|
|
}
|
|
|
|
// MARK: - 동작
|
|
|
|
private func recordCounts(for store: Store) -> (total: Int, thisMonth: Int) {
|
|
let records = allRecords.filter { $0.store === store }
|
|
let now = KoreanCalendar.calendar.dateComponents([.year, .month], from: .now)
|
|
let thisMonth = records.filter {
|
|
let c = KoreanCalendar.calendar.dateComponents([.year, .month], from: $0.expiryDate)
|
|
return c.year == now.year && c.month == now.month
|
|
}
|
|
return (records.count, thisMonth.count)
|
|
}
|
|
|
|
private func addStore() {
|
|
let name = newName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
newName = ""
|
|
guard !name.isEmpty else { return }
|
|
context.insert(Store(name: name))
|
|
try? context.save()
|
|
Haptics.success()
|
|
}
|
|
|
|
private func renameStore() {
|
|
guard let store = renameTarget else { return }
|
|
let name = renameText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
renameTarget = nil
|
|
guard !name.isEmpty else { return }
|
|
store.name = name
|
|
try? context.save()
|
|
Haptics.tap()
|
|
}
|
|
|
|
private func deleteStore() {
|
|
guard let store = deleteTarget else { return }
|
|
deleteTarget = nil
|
|
context.delete(store)
|
|
try? context.save()
|
|
Haptics.warning()
|
|
}
|
|
|
|
private var isRenaming: Binding<Bool> {
|
|
Binding(
|
|
get: { renameTarget != nil },
|
|
set: { if !$0 { renameTarget = nil } }
|
|
)
|
|
}
|
|
|
|
private var isDeleting: Binding<Bool> {
|
|
Binding(
|
|
get: { deleteTarget != nil },
|
|
set: { if !$0 { deleteTarget = nil } }
|
|
)
|
|
}
|
|
|
|
private var deleteMessage: String {
|
|
guard let store = deleteTarget else { return "" }
|
|
let count = allRecords.filter { $0.store === store }.count
|
|
return count > 0
|
|
? "'\(store.name)'과 기록 \(count)개를 모두 삭제할까요?"
|
|
: "'\(store.name)'을(를) 삭제할까요?"
|
|
}
|
|
}
|