mycode/myApp/Expiranner/IOS/SettingsView.swift
songyc macbook ceb0ab8e16 feat(expiranner): 기록 날짜·시간 수정 + 매장별 지난 데이터 정리 + 주 시작 요일 설정
- RecordEditView 신규: 저장된 기록의 유통기한을 그래픽 달력 + '시간 추가'로 수정.
  날짜 상세 그리드의 길게 누르기 메뉴와 전체화면 뷰어 상단 버튼 양쪽에서 진입
- 설정 '데이터 관리'를 매장별로 분리: 지난 기록이 있는 매장만 행으로 나열해
  개별 삭제, 2개 매장 이상일 때만 '모든 매장에서 삭제' 행 노출(각각 확인 대화상자)
- 주 시작 요일 설정(일요일/월요일): 월 캘린더와 촬영 플로우 미니 달력의
  그리드·요일 기호·주말 색이 모두 따라감 (기본은 기존과 같은 일요일 시작)
- CLAUDE.md를 현재 동작(설정 화면, hasTime, 동적 색)에 맞게 갱신

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J2HQuA6RPtkh45QTMN83i2
2026-07-28 02:29:36 +09:00

335 lines
12 KiB
Swift

import SwiftUI
import SwiftData
/// : · · ().
struct SettingsView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var context
@Query(sort: \Store.createdAt) private var stores: [Store]
@Query private var allRecords: [ProductRecord]
@AppStorage(appearanceStorageKey) private var appearanceRaw = AppearanceMode.dark.rawValue
@AppStorage(weekStartStorageKey) private var weekStartsMonday = false
/// : .
private enum ExpiredScope {
case store(Store)
case all
}
@State private var deleteScope: ExpiredScope?
@State private var toastText: String?
@State private var toastTask: Task<Void, Never>?
/// . store nil .
private func expiredRecords(in store: Store?) -> [ProductRecord] {
allRecords.filter { record in
guard KoreanCalendar.daysFromToday(to: record.expiryDate) < 0 else { return false }
guard let store else { return true }
return record.store === store
}
}
/// ( ).
private var storesWithExpired: [(store: Store, count: Int)] {
stores.compactMap { store in
let count = expiredRecords(in: store).count
return count > 0 ? (store, count) : nil
}
}
var body: some View {
NavigationStack {
ZStack {
Theme.bg.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: 26) {
appearanceSection
weekStartSection
dataSection
}
.padding(20)
}
if let toastText {
VStack {
Spacer()
toast(toastText)
.padding(.bottom, 30)
}
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.navigationTitle("설정")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("완료") { dismiss() }
.font(.body.weight(.semibold))
}
}
.animation(.snappy, value: toastText)
}
.confirmationDialog(
deleteDialogTitle,
isPresented: isConfirmingDelete,
titleVisibility: .visible
) {
Button("삭제", role: .destructive) { deleteExpired() }
Button("취소", role: .cancel) { deleteScope = nil }
} message: {
Text(deleteDialogMessage)
}
.onDisappear { toastTask?.cancel() }
}
private var isConfirmingDelete: Binding<Bool> {
Binding(
get: { deleteScope != nil },
set: { if !$0 { deleteScope = nil } }
)
}
// MARK: -
private var appearanceSection: some View {
section(title: "화면 모드", subtitle: "앱 전체의 밝기 테마를 선택하세요.") {
HStack(spacing: 10) {
ForEach(AppearanceMode.allCases) { mode in
appearanceChip(mode)
}
}
}
}
private func appearanceChip(_ mode: AppearanceMode) -> some View {
let isSelected = appearanceRaw == mode.rawValue
return Button {
guard !isSelected else { return }
appearanceRaw = mode.rawValue
Haptics.tap()
} label: {
VStack(spacing: 8) {
Image(systemName: mode.icon)
.font(.title3)
.foregroundStyle(isSelected ? Theme.onAccent : Theme.textSecondary)
Text(mode.title)
.font(.system(.subheadline, design: .rounded).weight(.semibold))
.foregroundStyle(isSelected ? Theme.onAccent : Theme.textPrimary)
}
.frame(maxWidth: .infinity)
.frame(height: 74)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(isSelected ? AnyShapeStyle(Theme.accentGradient) : AnyShapeStyle(Theme.surfaceHi))
)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(isSelected ? .clear : Theme.stroke, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
// MARK: -
private var weekStartSection: some View {
section(title: "주 시작 요일", subtitle: "달력의 첫 번째 요일을 선택하세요.") {
HStack(spacing: 10) {
weekStartChip(title: "일요일 시작", monday: false)
weekStartChip(title: "월요일 시작", monday: true)
}
}
}
private func weekStartChip(title: String, monday: Bool) -> some View {
let isSelected = weekStartsMonday == monday
return Button {
guard !isSelected else { return }
weekStartsMonday = monday
Haptics.tap()
} label: {
Text(title)
.font(.system(.subheadline, design: .rounded).weight(.semibold))
.foregroundStyle(isSelected ? Theme.onAccent : Theme.textPrimary)
.frame(maxWidth: .infinity)
.frame(height: 48)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(isSelected ? AnyShapeStyle(Theme.accentGradient) : AnyShapeStyle(Theme.surfaceHi))
)
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(isSelected ? .clear : Theme.stroke, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
// MARK: - ()
private var dataSection: some View {
let expired = storesWithExpired
return section(title: "데이터 관리", subtitle: "유통기한이 지난 기록을 매장별로 정리합니다.") {
VStack(spacing: 10) {
if expired.isEmpty {
HStack(spacing: 12) {
Image(systemName: "checkmark.circle")
.font(.body.weight(.semibold))
.foregroundStyle(Theme.accent)
Text("지난 기록이 없어요")
.font(.system(.subheadline, design: .rounded))
.foregroundStyle(Theme.textSecondary)
Spacer()
}
.padding(16)
.cardStyle()
} else {
ForEach(expired, id: \.store.persistentModelID) { item in
expiredStoreRow(store: item.store, count: item.count)
}
if expired.count >= 2 {
deleteAllRow(totalCount: expired.reduce(0) { $0 + $1.count })
}
}
}
}
}
private func expiredStoreRow(store: Store, count: Int) -> some View {
Button {
deleteScope = .store(store)
} label: {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Theme.accentSoft)
.frame(width: 38, height: 38)
Image(systemName: "storefront.fill")
.font(.subheadline)
.foregroundStyle(Theme.accent)
}
VStack(alignment: .leading, spacing: 2) {
Text(store.name)
.font(.system(.subheadline, design: .rounded).weight(.semibold))
.foregroundStyle(Theme.textPrimary)
Text("지난 기록 \(count)")
.font(.caption)
.foregroundStyle(Theme.danger.opacity(0.85))
}
Spacer()
Image(systemName: "trash")
.font(.body.weight(.semibold))
.foregroundStyle(Theme.danger)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.cardStyle()
}
.buttonStyle(.plain)
}
private func deleteAllRow(totalCount: Int) -> some View {
Button {
deleteScope = .all
} label: {
HStack(spacing: 10) {
Image(systemName: "trash.fill")
.font(.footnote.weight(.semibold))
Text("모든 매장의 지난 기록 삭제 (\(totalCount)개)")
.font(.system(.footnote, design: .rounded).weight(.semibold))
}
.foregroundStyle(Theme.danger)
.frame(maxWidth: .infinity)
.frame(height: 44)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Theme.danger.opacity(0.12))
)
}
.buttonStyle(.plain)
}
private var deleteDialogTitle: String {
switch deleteScope {
case .store(let store):
return "'\(store.name)'의 지난 기록 \(expiredRecords(in: store).count)개를 삭제할까요?"
case .all:
return "모든 매장의 지난 기록 \(expiredRecords(in: nil).count)개를 삭제할까요?"
case nil:
return ""
}
}
private var deleteDialogMessage: String {
switch deleteScope {
case .store:
return "이 매장의 지난 기록만 삭제되고, 다른 매장은 영향을 받지 않아요. 되돌릴 수 없어요."
case .all, nil:
return "모든 매장의 지난 기록이 삭제됩니다. 되돌릴 수 없어요."
}
}
private func deleteExpired() {
let targets: [ProductRecord]
let toastLabel: String
switch deleteScope {
case .store(let store):
targets = expiredRecords(in: store)
toastLabel = "'\(store.name)'의 지난 기록 \(targets.count)개를 삭제했어요"
case .all:
targets = expiredRecords(in: nil)
toastLabel = "지난 기록 \(targets.count)개를 삭제했어요"
case nil:
return
}
deleteScope = nil
guard !targets.isEmpty else { return }
for record in targets {
context.delete(record)
}
try? context.save()
Haptics.warning()
showToast(toastLabel)
}
// MARK: -
private func section<Content: View>(
title: String,
subtitle: String,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.system(.headline, design: .rounded))
.foregroundStyle(Theme.textPrimary)
Text(subtitle)
.font(.caption)
.foregroundStyle(Theme.textSecondary)
}
content()
}
}
private func showToast(_ text: String) {
toastTask?.cancel()
toastText = text
toastTask = Task {
try? await Task.sleep(for: .seconds(1.8))
guard !Task.isCancelled else { return }
toastText = nil
}
}
private func toast(_ text: String) -> some View {
Label(text, systemImage: "checkmark.circle.fill")
.font(.system(.subheadline, design: .rounded).weight(.semibold))
.foregroundStyle(Theme.onAccent)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Capsule().fill(Theme.accentGradient))
}
}