mycode/myApp/Expiranner/IOS/StoreListView.swift
songyc macbook 6c4fe817bd feat(expiranner): 라이트/다크 모드 · 시간 포함 유통기한 · 지난 데이터 정리
- 설정 화면 신규(첫 화면 헤더 톱니 진입): 화면 모드(시스템/라이트/다크) 선택,
  '기간 지난 데이터 삭제'로 만료된 기록 일괄 정리(매장 무관, 확인 후 실행)
- Theme 색상을 라이트/다크 자동 전환 동적 색으로 전환, 기본값은 기존과 동일한 다크
- 유통기한에 시각(시:분) 선택 입력: 날짜 선택 후 '시간 추가' 버튼으로만 노출돼
  기본 사용성(사진→날짜→저장)은 그대로 유지, 유제품 등 필요 시에만 사용
- ProductRecord.hasTime 플래그 추가(기본 false, 라이트웨이트 마이그레이션),
  시각 포함 기록은 날짜 상세 그리드에 시각 배지 표시
- .gitignore에 Xcode 빌드 산출물/파생 데이터/SwiftPM 상태 추가해 상태 오염 방지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J2HQuA6RPtkh45QTMN83i2
2026-07-23 17:46:12 +09:00

247 lines
8.5 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?
@State private var showingSettings = false
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 }
}
.sheet(isPresented: $showingSettings) {
SettingsView()
}
}
// 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()
HStack(spacing: 10) {
Button {
showingSettings = true
Haptics.tap()
} label: {
Image(systemName: "gearshape")
.font(.title3.weight(.semibold))
.foregroundStyle(Theme.textSecondary)
.frame(width: 44, height: 44)
.background(Circle().fill(Theme.surface))
.overlay(Circle().strokeBorder(Theme.stroke, lineWidth: 1))
}
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)'을(를) 삭제할까요?"
}
}