feat(expiranner): 기록 날짜·시간 수정 + 매장별 지난 데이터 정리 + 주 시작 요일 설정
- RecordEditView 신규: 저장된 기록의 유통기한을 그래픽 달력 + '시간 추가'로 수정. 날짜 상세 그리드의 길게 누르기 메뉴와 전체화면 뷰어 상단 버튼 양쪽에서 진입 - 설정 '데이터 관리'를 매장별로 분리: 지난 기록이 있는 매장만 행으로 나열해 개별 삭제, 2개 매장 이상일 때만 '모든 매장에서 삭제' 행 노출(각각 확인 대화상자) - 주 시작 요일 설정(일요일/월요일): 월 캘린더와 촬영 플로우 미니 달력의 그리드·요일 기호·주말 색이 모두 따라감 (기본은 기존과 같은 일요일 시작) - CLAUDE.md를 현재 동작(설정 화면, hasTime, 동적 색)에 맞게 갱신 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J2HQuA6RPtkh45QTMN83i2
This commit is contained in:
parent
2aa24d1df2
commit
ceb0ab8e16
@ -7,16 +7,17 @@
|
||||
- 프레임워크: SwiftUI + SwiftData (Core Data 금지)
|
||||
- 타깃: iOS 17+, iPhone 중심
|
||||
- 언어: 모든 UI 텍스트와 로그는 한국어
|
||||
- 외관: **다크 모드 전용** (`.preferredColorScheme(.dark)`), 라이트 모드 대응 안 함
|
||||
- 디자인 토큰: `Theme.swift` (배경 `#0B0E13`, 카드 `#161B25`, 민트 액센트 `#3BE39F`, rounded 폰트 디자인)
|
||||
- 외관: **기본 다크**, 설정에서 시스템/라이트/다크 선택(`AppearanceMode`, `@AppStorage(appearanceStorageKey)`). Theme 색은 전부 `Color.dynamic(light:dark:)` 동적 색.
|
||||
- 디자인 토큰: `Theme.swift` (다크 기준 배경 `#0B0E13`, 카드 `#161B25`, 민트 액센트 `#3BE39F`, rounded 폰트 디자인)
|
||||
|
||||
## 2. 핵심 플로우 (이 동작이 앱의 전부)
|
||||
1. **매장 선택** (`StoreListView`): 앱 첫 화면. 매장 목록 표시, `+`로 추가, 컨텍스트 메뉴로 이름 변경/삭제(기록 연쇄 삭제).
|
||||
2. **연/월 선택** (`YearMonthView`): 연도 좌우 이동 + 1~12월 그리드, 월별 기록 개수 배지.
|
||||
3. **월 캘린더** (`MonthCalendarView`): 날짜 셀마다 그 날짜가 유통기한인 제품 사진 썸네일 + 개수 배지. 좌우 화살표로 월 이동. 오늘 날짜 민트 테두리.
|
||||
4. **날짜 상세** (`DayDetailView`): 시트로 그 날짜의 사진 그리드, D-day 칩(D-n / 오늘까지 / n일 지남), 탭하면 전체화면 뷰어(`PhotoViewerView`, 스와이프 + 삭제). [선택] 버튼으로 다중 선택 모드 → 전체 선택/해제 + 일괄 삭제(확인 대화상자).
|
||||
5. **스캔 플로우** (`CaptureFlowView`, 전체화면): FAB(카메라 버튼) → 촬영 → [재촬영]/[사용하기] → **현재 보고 있는 달의 날짜만** 선택 가능한 미니 달력 → [저장하기] → 성공 햅틱 + 토스트 → 자동으로 카메라 복귀. `X`로 캘린더 복귀. 손전등 토글 지원.
|
||||
4. **날짜 상세** (`DayDetailView`): 시트로 그 날짜의 사진 그리드, D-day 칩(D-n / 오늘까지 / n일 지남), 시각 포함 기록엔 시각 배지. 탭하면 전체화면 뷰어(`PhotoViewerView`, 스와이프 + 날짜·시간 수정 + 삭제). 사진 길게 눌러 컨텍스트 메뉴로도 날짜·시간 수정(`RecordEditView`) 가능. [선택] 버튼으로 다중 선택 모드 → 전체 선택/해제 + 일괄 삭제(확인 대화상자).
|
||||
5. **스캔 플로우** (`CaptureFlowView`, 전체화면): FAB(카메라 버튼) → 촬영 → [재촬영]/[사용하기] → **현재 보고 있는 달의 날짜만** 선택 가능한 미니 달력 → [저장하기] → 성공 햅틱 + 토스트 → 자동으로 카메라 복귀. `X`로 캘린더 복귀. 손전등 토글 지원. 날짜 선택 후 나타나는 '시간 추가' 버튼으로 시각(시:분)까지 선택 저장 가능(기본은 날짜만 — 이 흐름을 침해하지 말 것).
|
||||
6. **로딩 화면**: 시스템 런치 스크린(루트 `Info.plist`의 `UILaunchScreen` — `LaunchBackground` 색 + `LaunchIcon` 이미지) → 인앱 스플래시(`ExpirannerApp.swift`의 `SplashView`, 약 1초 후 페이드아웃)로 이어짐. 런치 스크린과 스플래시의 글리프 크기(110pt)를 맞춰 끊김이 없게 유지할 것.
|
||||
7. **설정** (`SettingsView`, 첫 화면 헤더 톱니로 진입): 화면 모드(시스템/라이트/다크), 주 시작 요일(일/월, `@AppStorage(weekStartStorageKey)` — 달력 그리드 전체가 따름), 데이터 관리(**매장별** '기간 지난 데이터 삭제' + 2개 매장 이상일 때만 전체 삭제 행).
|
||||
|
||||
## 3. 데이터 모델 (`Models.swift`)
|
||||
```swift
|
||||
@ -28,7 +29,8 @@
|
||||
}
|
||||
|
||||
@Model final class ProductRecord {
|
||||
var expiryDate: Date // 자정으로 정규화
|
||||
var expiryDate: Date // hasTime=false면 자정 정규화, true면 시각 포함
|
||||
var hasTime: Bool = false // 유통기한에 시각(시:분) 포함 여부
|
||||
var createdAt: Date
|
||||
@Attribute(.externalStorage) var imageData: Data // 최대 1600px JPEG
|
||||
var thumbnailData: Data // 최대 360px JPEG (캘린더/그리드용)
|
||||
@ -43,8 +45,10 @@
|
||||
- `Models.swift` — Store, ProductRecord
|
||||
- `Theme.swift` — 색상/카드 스타일/햅틱(`Haptics`)/달력 유틸(`KoreanCalendar`)/이미지 다운스케일
|
||||
- `StoreListView.swift`, `YearMonthView.swift`, `MonthCalendarView.swift`, `DayDetailView.swift`
|
||||
- `CaptureFlowView.swift` — 촬영→확인→날짜선택→저장 상태 머신
|
||||
- `CaptureFlowView.swift` — 촬영→확인→날짜선택(+시간 추가)→저장 상태 머신
|
||||
- `CameraService.swift` — AVCaptureSession 래퍼(`@MainActor`) + `CameraPreview`
|
||||
- `SettingsView.swift` — 화면 모드 / 주 시작 요일 / 매장별 지난 데이터 정리
|
||||
- `RecordEditView.swift` — 저장된 기록의 유통기한(날짜·시간) 수정 시트
|
||||
|
||||
## 5. 코딩 규칙
|
||||
- 연도 등 숫자를 Text에 넣을 때 반드시 `Text(verbatim:)` 사용 (천 단위 구분 방지: "2,026년" 버그).
|
||||
|
||||
@ -10,6 +10,7 @@ struct CaptureFlowView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.modelContext) private var context
|
||||
@StateObject private var camera = CameraService()
|
||||
@AppStorage(weekStartStorageKey) private var weekStartsMonday = false
|
||||
|
||||
private enum Phase {
|
||||
case camera
|
||||
@ -213,13 +214,14 @@ struct CaptureFlowView: View {
|
||||
|
||||
private var dayPickerGrid: some View {
|
||||
let columns = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7)
|
||||
let days = KoreanCalendar.gridDays(year: year, month: month)
|
||||
let days = KoreanCalendar.gridDays(year: year, month: month, startsOnMonday: weekStartsMonday)
|
||||
let symbols = KoreanCalendar.weekdaySymbols(startsOnMonday: weekStartsMonday)
|
||||
return VStack(spacing: 8) {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(0..<7, id: \.self) { index in
|
||||
Text(KoreanCalendar.weekdaySymbols[index])
|
||||
Text(symbols[index])
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(KoreanCalendar.weekdayColor(columnIndex: index))
|
||||
.foregroundStyle(KoreanCalendar.weekdayColor(columnIndex: index, startsOnMonday: weekStartsMonday))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ struct DayDetailView: View {
|
||||
@State private var isSelecting = false
|
||||
@State private var selectedIDs = Set<PersistentIdentifier>()
|
||||
@State private var confirmBulkDelete = false
|
||||
@State private var editTarget: ProductRecord?
|
||||
|
||||
private struct ViewerLaunch: Identifiable {
|
||||
let index: Int
|
||||
@ -64,6 +65,9 @@ struct DayDetailView: View {
|
||||
Button("삭제", role: .destructive) { deleteSelected() }
|
||||
Button("취소", role: .cancel) {}
|
||||
}
|
||||
.sheet(item: $editTarget) { record in
|
||||
RecordEditView(record: record)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 헤더
|
||||
@ -218,6 +222,11 @@ struct DayDetailView: View {
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
if !isSelecting {
|
||||
Button {
|
||||
editTarget = record
|
||||
} label: {
|
||||
Label("날짜·시간 수정", systemImage: "calendar.badge.clock")
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
delete(record)
|
||||
} label: {
|
||||
@ -314,6 +323,7 @@ struct PhotoViewerView: View {
|
||||
@Query private var allRecords: [ProductRecord]
|
||||
@State private var index: Int
|
||||
@State private var confirmDelete = false
|
||||
@State private var editTarget: ProductRecord?
|
||||
|
||||
init(store: Store, year: Int, month: Int, day: Int, startIndex: Int) {
|
||||
self.store = store
|
||||
@ -355,6 +365,15 @@ struct PhotoViewerView: View {
|
||||
Button("삭제", role: .destructive) { deleteCurrent() }
|
||||
Button("취소", role: .cancel) {}
|
||||
}
|
||||
.sheet(item: $editTarget) { record in
|
||||
RecordEditView(record: record)
|
||||
}
|
||||
}
|
||||
|
||||
private var currentRecord: ProductRecord? {
|
||||
let current = records
|
||||
guard !current.isEmpty else { return nil }
|
||||
return current[min(index, current.count - 1)]
|
||||
}
|
||||
|
||||
private var pager: some View {
|
||||
@ -386,7 +405,13 @@ struct PhotoViewerView: View {
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
}
|
||||
Spacer()
|
||||
circleButton(systemName: "trash", tint: Theme.danger) { confirmDelete = true }
|
||||
HStack(spacing: 10) {
|
||||
circleButton(systemName: "calendar.badge.clock") {
|
||||
editTarget = currentRecord
|
||||
Haptics.tap()
|
||||
}
|
||||
circleButton(systemName: "trash", tint: Theme.danger) { confirmDelete = true }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 8)
|
||||
@ -403,9 +428,7 @@ struct PhotoViewerView: View {
|
||||
}
|
||||
|
||||
private func deleteCurrent() {
|
||||
let current = records
|
||||
guard !current.isEmpty else { return }
|
||||
let target = current[min(index, current.count - 1)]
|
||||
guard let target = currentRecord else { return }
|
||||
context.delete(target)
|
||||
try? context.save()
|
||||
Haptics.warning()
|
||||
|
||||
@ -6,6 +6,7 @@ struct MonthCalendarView: View {
|
||||
let store: Store
|
||||
|
||||
@Query private var allRecords: [ProductRecord]
|
||||
@AppStorage(weekStartStorageKey) private var weekStartsMonday = false
|
||||
@State private var year: Int
|
||||
@State private var month: Int
|
||||
@State private var selectedDay: DaySelection?
|
||||
@ -108,11 +109,12 @@ struct MonthCalendarView: View {
|
||||
// MARK: - 요일 / 날짜 그리드
|
||||
|
||||
private var weekdayRow: some View {
|
||||
HStack(spacing: 6) {
|
||||
let symbols = KoreanCalendar.weekdaySymbols(startsOnMonday: weekStartsMonday)
|
||||
return HStack(spacing: 6) {
|
||||
ForEach(0..<7, id: \.self) { index in
|
||||
Text(KoreanCalendar.weekdaySymbols[index])
|
||||
Text(symbols[index])
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(KoreanCalendar.weekdayColor(columnIndex: index))
|
||||
.foregroundStyle(KoreanCalendar.weekdayColor(columnIndex: index, startsOnMonday: weekStartsMonday))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
@ -120,7 +122,7 @@ struct MonthCalendarView: View {
|
||||
|
||||
private var calendarGrid: some View {
|
||||
let columns = Array(repeating: GridItem(.flexible(), spacing: 6), count: 7)
|
||||
let days = KoreanCalendar.gridDays(year: year, month: month)
|
||||
let days = KoreanCalendar.gridDays(year: year, month: month, startsOnMonday: weekStartsMonday)
|
||||
return LazyVGrid(columns: columns, spacing: 6) {
|
||||
ForEach(Array(days.enumerated()), id: \.offset) { index, day in
|
||||
if let day {
|
||||
@ -190,7 +192,7 @@ struct MonthCalendarView: View {
|
||||
private func dayNumberColor(columnIndex: Int, hasPhoto: Bool, isToday: Bool) -> Color {
|
||||
if isToday { return Theme.accent }
|
||||
if hasPhoto { return .white }
|
||||
return KoreanCalendar.weekdayColor(columnIndex: columnIndex)
|
||||
return KoreanCalendar.weekdayColor(columnIndex: columnIndex, startsOnMonday: weekStartsMonday)
|
||||
}
|
||||
|
||||
// MARK: - 스캔 버튼
|
||||
|
||||
192
myApp/Expiranner/IOS/RecordEditView.swift
Normal file
192
myApp/Expiranner/IOS/RecordEditView.swift
Normal file
@ -0,0 +1,192 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// 이미 저장된 기록의 유통기한(날짜·시간)을 수정하는 시트.
|
||||
/// 촬영 플로우와 같은 규칙: 기본은 날짜만, 필요할 때만 '시간 추가'.
|
||||
struct RecordEditView: View {
|
||||
let record: ProductRecord
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.modelContext) private var context
|
||||
|
||||
@State private var selectedDate: Date
|
||||
@State private var includeTime: Bool
|
||||
@State private var timeSelection: Date
|
||||
|
||||
init(record: ProductRecord) {
|
||||
self.record = record
|
||||
_selectedDate = State(initialValue: record.expiryDate)
|
||||
_includeTime = State(initialValue: record.hasTime)
|
||||
_timeSelection = State(initialValue: record.hasTime
|
||||
? record.expiryDate
|
||||
: (KoreanCalendar.calendar.date(bySettingHour: 18, minute: 0, second: 0, of: .now) ?? .now))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Theme.bg.ignoresSafeArea()
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
currentHeader
|
||||
datePickerCard
|
||||
timeRow
|
||||
saveButton
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
}
|
||||
.navigationTitle("유통기한 수정")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("취소") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
// MARK: - 현재 값 요약
|
||||
|
||||
private var currentHeader: some View {
|
||||
HStack(spacing: 12) {
|
||||
Group {
|
||||
if let image = UIImage(data: record.thumbnailData) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
Theme.surfaceHi
|
||||
}
|
||||
}
|
||||
.frame(width: 56, height: 56)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Theme.stroke, lineWidth: 1)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("현재 유통기한")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
Text(currentText)
|
||||
.font(.system(.headline, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(14)
|
||||
.cardStyle()
|
||||
}
|
||||
|
||||
private var currentText: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "ko_KR")
|
||||
formatter.dateFormat = record.hasTime ? "M월 d일 (E) a h:mm" : "M월 d일 (E)"
|
||||
return formatter.string(from: record.expiryDate)
|
||||
}
|
||||
|
||||
// MARK: - 날짜 선택
|
||||
|
||||
private var datePickerCard: some View {
|
||||
DatePicker(
|
||||
"유통기한 날짜",
|
||||
selection: $selectedDate,
|
||||
displayedComponents: .date
|
||||
)
|
||||
.datePickerStyle(.graphical)
|
||||
.tint(Theme.accent)
|
||||
.environment(\.locale, Locale(identifier: "ko_KR"))
|
||||
.environment(\.calendar, KoreanCalendar.calendar)
|
||||
.padding(10)
|
||||
.cardStyle()
|
||||
}
|
||||
|
||||
// MARK: - 시간 추가 (선택)
|
||||
|
||||
@ViewBuilder
|
||||
private var timeRow: some View {
|
||||
if includeTime {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "clock.fill")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.accent)
|
||||
Text("유통 시각")
|
||||
.font(.system(.subheadline, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
Spacer()
|
||||
DatePicker(
|
||||
"",
|
||||
selection: $timeSelection,
|
||||
displayedComponents: .hourAndMinute
|
||||
)
|
||||
.labelsHidden()
|
||||
.tint(Theme.accent)
|
||||
Button {
|
||||
withAnimation(.snappy) { includeTime = false }
|
||||
Haptics.tap()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.cardStyle()
|
||||
} else {
|
||||
Button {
|
||||
withAnimation(.snappy) { includeTime = true }
|
||||
Haptics.tap()
|
||||
} label: {
|
||||
Label("시간 추가", systemImage: "clock")
|
||||
.font(.system(.footnote, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(Capsule().fill(Theme.surfaceHi))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 저장
|
||||
|
||||
private var saveButton: some View {
|
||||
Button {
|
||||
save()
|
||||
} label: {
|
||||
Label("저장하기", systemImage: "checkmark.circle.fill")
|
||||
.font(.system(.body, design: .rounded).weight(.bold))
|
||||
.foregroundStyle(Theme.onAccent)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 54)
|
||||
.background(Capsule().fill(Theme.accentGradient))
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let calendar = KoreanCalendar.calendar
|
||||
let base = calendar.startOfDay(for: selectedDate)
|
||||
if includeTime {
|
||||
let t = calendar.dateComponents([.hour, .minute], from: timeSelection)
|
||||
record.expiryDate = calendar.date(
|
||||
bySettingHour: t.hour ?? 0,
|
||||
minute: t.minute ?? 0,
|
||||
second: 0,
|
||||
of: base
|
||||
) ?? base
|
||||
} else {
|
||||
record.expiryDate = base
|
||||
}
|
||||
record.hasTime = includeTime
|
||||
try? context.save()
|
||||
Haptics.success()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@ -1,21 +1,41 @@
|
||||
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
|
||||
|
||||
@State private var confirmDeleteExpired = 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>?
|
||||
|
||||
/// 유통기한이 이미 지난 기록(매장 무관 전체).
|
||||
private var expiredRecords: [ProductRecord] {
|
||||
allRecords.filter { KoreanCalendar.daysFromToday(to: $0.expiryDate) < 0 }
|
||||
/// 유통기한이 이미 지난 기록. 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 {
|
||||
@ -25,6 +45,7 @@ struct SettingsView: View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 26) {
|
||||
appearanceSection
|
||||
weekStartSection
|
||||
dataSection
|
||||
}
|
||||
.padding(20)
|
||||
@ -51,17 +72,24 @@ struct SettingsView: View {
|
||||
}
|
||||
.confirmationDialog(
|
||||
deleteDialogTitle,
|
||||
isPresented: $confirmDeleteExpired,
|
||||
isPresented: isConfirmingDelete,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("삭제", role: .destructive) { deleteExpired() }
|
||||
Button("취소", role: .cancel) {}
|
||||
Button("취소", role: .cancel) { deleteScope = nil }
|
||||
} message: {
|
||||
Text("매장과 상관없이 유통기한이 지난 기록이 모두 삭제됩니다. 되돌릴 수 없어요.")
|
||||
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 {
|
||||
@ -103,54 +131,166 @@ struct SettingsView: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - 데이터 관리
|
||||
// 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()
|
||||
private var weekStartSection: some View {
|
||||
section(title: "주 시작 요일", subtitle: "달력의 첫 번째 요일을 선택하세요.") {
|
||||
HStack(spacing: 10) {
|
||||
weekStartChip(title: "일요일 시작", monday: false)
|
||||
weekStartChip(title: "월요일 시작", monday: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(count == 0)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
"지난 기록 \(expiredRecords.count)개를 삭제할까요?"
|
||||
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 = expiredRecords
|
||||
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("지난 기록 \(targets.count)개를 삭제했어요")
|
||||
showToast(toastLabel)
|
||||
}
|
||||
|
||||
// MARK: - 공용
|
||||
|
||||
@ -91,6 +91,9 @@ enum AppearanceMode: Int, CaseIterable, Identifiable {
|
||||
/// 외관 모드 저장 키. 기본값은 기존 동작 유지를 위해 `.dark`.
|
||||
let appearanceStorageKey = "appearanceMode"
|
||||
|
||||
/// 주 시작 요일 저장 키. false = 일요일 시작(기본), true = 월요일 시작.
|
||||
let weekStartStorageKey = "weekStartsMonday"
|
||||
|
||||
// MARK: - 공용 스타일
|
||||
|
||||
struct CardBackground: ViewModifier {
|
||||
@ -133,15 +136,21 @@ enum KoreanCalendar {
|
||||
return c
|
||||
}()
|
||||
|
||||
static let weekdaySymbols = ["일", "월", "화", "수", "목", "금", "토"]
|
||||
private static let baseSymbols = ["일", "월", "화", "수", "목", "금", "토"]
|
||||
|
||||
/// 주 시작 설정에 맞춰 정렬된 요일 기호. (일요일 시작 / 월요일 시작)
|
||||
static func weekdaySymbols(startsOnMonday: Bool) -> [String] {
|
||||
startsOnMonday ? Array(baseSymbols[1...]) + [baseSymbols[0]] : baseSymbols
|
||||
}
|
||||
|
||||
/// 해당 월의 그리드 셀 배열. 앞쪽 빈칸은 nil, 이후 1...말일.
|
||||
static func gridDays(year: Int, month: Int) -> [Int?] {
|
||||
static func gridDays(year: Int, month: Int, startsOnMonday: Bool) -> [Int?] {
|
||||
let comps = DateComponents(year: year, month: month, day: 1)
|
||||
guard let first = calendar.date(from: comps),
|
||||
let range = calendar.range(of: .day, in: .month, for: first) else { return [] }
|
||||
let firstWeekday = calendar.component(.weekday, from: first)
|
||||
let leading = (firstWeekday - calendar.firstWeekday + 7) % 7
|
||||
let firstWeekday = calendar.component(.weekday, from: first) // 1=일 ... 7=토
|
||||
let gridFirstWeekday = startsOnMonday ? 2 : 1
|
||||
let leading = (firstWeekday - gridFirstWeekday + 7) % 7
|
||||
return Array(repeating: nil, count: leading) + range.map { $0 }
|
||||
}
|
||||
|
||||
@ -161,8 +170,10 @@ enum KoreanCalendar {
|
||||
return calendar.dateComponents([.day], from: today, to: target).day ?? 0
|
||||
}
|
||||
|
||||
static func weekdayColor(columnIndex: Int) -> Color {
|
||||
switch columnIndex {
|
||||
static func weekdayColor(columnIndex: Int, startsOnMonday: Bool) -> Color {
|
||||
// 열 위치를 실제 요일(0=일 ... 6=토)로 환산해 색을 정한다.
|
||||
let weekday = (columnIndex + (startsOnMonday ? 1 : 0)) % 7
|
||||
switch weekday {
|
||||
case 0: return Theme.sunday
|
||||
case 6: return Theme.saturday
|
||||
default: return Theme.textSecondary
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user