diff --git a/myApp/Expiranner/CLAUDE.md b/myApp/Expiranner/CLAUDE.md index 5b8374b..d482c57 100644 --- a/myApp/Expiranner/CLAUDE.md +++ b/myApp/Expiranner/CLAUDE.md @@ -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년" 버그). diff --git a/myApp/Expiranner/IOS/CaptureFlowView.swift b/myApp/Expiranner/IOS/CaptureFlowView.swift index b3ad018..3b4b075 100644 --- a/myApp/Expiranner/IOS/CaptureFlowView.swift +++ b/myApp/Expiranner/IOS/CaptureFlowView.swift @@ -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) } } diff --git a/myApp/Expiranner/IOS/DayDetailView.swift b/myApp/Expiranner/IOS/DayDetailView.swift index aa8cbdf..25b012d 100644 --- a/myApp/Expiranner/IOS/DayDetailView.swift +++ b/myApp/Expiranner/IOS/DayDetailView.swift @@ -14,6 +14,7 @@ struct DayDetailView: View { @State private var isSelecting = false @State private var selectedIDs = Set() @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() diff --git a/myApp/Expiranner/IOS/MonthCalendarView.swift b/myApp/Expiranner/IOS/MonthCalendarView.swift index 8918498..c78ea6c 100644 --- a/myApp/Expiranner/IOS/MonthCalendarView.swift +++ b/myApp/Expiranner/IOS/MonthCalendarView.swift @@ -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: - 스캔 버튼 diff --git a/myApp/Expiranner/IOS/RecordEditView.swift b/myApp/Expiranner/IOS/RecordEditView.swift new file mode 100644 index 0000000..662fa82 --- /dev/null +++ b/myApp/Expiranner/IOS/RecordEditView.swift @@ -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() + } +} diff --git a/myApp/Expiranner/IOS/SettingsView.swift b/myApp/Expiranner/IOS/SettingsView.swift index ad1bba5..3cf04c9 100644 --- a/myApp/Expiranner/IOS/SettingsView.swift +++ b/myApp/Expiranner/IOS/SettingsView.swift @@ -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? - /// 유통기한이 이미 지난 기록(매장 무관 전체). - 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 { + 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: - 공용 diff --git a/myApp/Expiranner/IOS/Theme.swift b/myApp/Expiranner/IOS/Theme.swift index dc15d3e..d1353ea 100644 --- a/myApp/Expiranner/IOS/Theme.swift +++ b/myApp/Expiranner/IOS/Theme.swift @@ -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