- 설정 화면 신규(첫 화면 헤더 톱니 진입): 화면 모드(시스템/라이트/다크) 선택, '기간 지난 데이터 삭제'로 만료된 기록 일괄 정리(매장 무관, 확인 후 실행) - 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
247 lines
8.5 KiB
Swift
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)'을(를) 삭제할까요?"
|
|
}
|
|
}
|