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

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)'을(를) 삭제할까요?"
}
}