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? /// 유통기한이 이미 지난 기록. 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 { 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( 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)) } }