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

195 lines
7.0 KiB
Swift

import SwiftUI
import SwiftData
/// : + .
struct SettingsView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var context
@Query private var allRecords: [ProductRecord]
@AppStorage(appearanceStorageKey) private var appearanceRaw = AppearanceMode.dark.rawValue
@State private var confirmDeleteExpired = false
@State private var toastText: String?
@State private var toastTask: Task<Void, Never>?
/// ( ).
private var expiredRecords: [ProductRecord] {
allRecords.filter { KoreanCalendar.daysFromToday(to: $0.expiryDate) < 0 }
}
var body: some View {
NavigationStack {
ZStack {
Theme.bg.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: 26) {
appearanceSection
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: $confirmDeleteExpired,
titleVisibility: .visible
) {
Button("삭제", role: .destructive) { deleteExpired() }
Button("취소", role: .cancel) {}
} message: {
Text("매장과 상관없이 유통기한이 지난 기록이 모두 삭제됩니다. 되돌릴 수 없어요.")
}
.onDisappear { toastTask?.cancel() }
}
// 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 dataSection: some View {
let count = expiredRecords.count
return section(title: "데이터 관리", subtitle: "유통기한이 지난 기록을 한 번에 정리합니다.") {
Button {
confirmDeleteExpired = true
} label: {
HStack(spacing: 12) {
Image(systemName: "trash")
.font(.body.weight(.semibold))
VStack(alignment: .leading, spacing: 2) {
Text("기간 지난 데이터 삭제")
.font(.system(.body, design: .rounded).weight(.semibold))
Text(count > 0 ? "지난 기록 \(count)" : "지난 기록이 없어요")
.font(.caption)
.foregroundStyle(count > 0 ? Theme.danger.opacity(0.8) : Theme.textSecondary)
}
Spacer()
if count > 0 {
Image(systemName: "chevron.right")
.font(.footnote.weight(.semibold))
.foregroundStyle(Theme.textSecondary)
}
}
.foregroundStyle(count > 0 ? Theme.danger : Theme.textSecondary)
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.cardStyle()
}
.buttonStyle(.plain)
.disabled(count == 0)
}
}
private var deleteDialogTitle: String {
"지난 기록 \(expiredRecords.count)개를 삭제할까요?"
}
private func deleteExpired() {
let targets = expiredRecords
guard !targets.isEmpty else { return }
for record in targets {
context.delete(record)
}
try? context.save()
Haptics.warning()
showToast("지난 기록 \(targets.count)개를 삭제했어요")
}
// 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))
}
}