diff --git a/Projects/Feature/Calendar/Sources/CalendarFeature.swift b/Projects/Feature/Calendar/Sources/CalendarFeature.swift index f22a64e..a7940ee 100644 --- a/Projects/Feature/Calendar/Sources/CalendarFeature.swift +++ b/Projects/Feature/Calendar/Sources/CalendarFeature.swift @@ -25,6 +25,8 @@ public struct CalendarFeature { public var selectedDay: CalendarDay? public var dayDoris: [CalendarDori] = [] public var errorMessage: String? + public var isMonthPickerPresented: Bool = false + public var pickerDate: Date public var totalAmount: Int { switch selectedType { @@ -37,6 +39,7 @@ public struct CalendarFeature { public init(currentMonth: Date = Date()) { self.currentMonth = currentMonth + self.pickerDate = currentMonth self.calendarData = .empty(for: currentMonth) } } @@ -52,6 +55,10 @@ public struct CalendarFeature { case calendarDataFailed(String) case dayTapped(CalendarDay) case sheetDismissed + case monthLabelTapped + case pickerDateChanged(Date) + case monthPickerConfirmed + case monthPickerDismissed } @Dependency(\.calendarClient) var calendarClient @@ -132,6 +139,26 @@ public struct CalendarFeature { state.selectedDay = nil state.dayDoris = [] return .none + + case .monthLabelTapped: + state.pickerDate = state.currentMonth + state.isMonthPickerPresented = true + return .none + + case let .pickerDateChanged(date): + state.pickerDate = date + return .none + + case .monthPickerConfirmed: + state.currentMonth = state.pickerDate.startOfMonth + state.isMonthPickerPresented = false + state.selectedDay = nil + state.dayDoris = [] + return fetchMonthlyData(month: state.currentMonth, type: state.selectedType) + + case .monthPickerDismissed: + state.isMonthPickerPresented = false + return .none } } .ifLet(\.$addDori, action: \.addDori) { diff --git a/Projects/Feature/Calendar/Sources/CalendarView.swift b/Projects/Feature/Calendar/Sources/CalendarView.swift index 3364158..4112b9f 100644 --- a/Projects/Feature/Calendar/Sources/CalendarView.swift +++ b/Projects/Feature/Calendar/Sources/CalendarView.swift @@ -29,7 +29,8 @@ public struct CalendarView: View { MonthSelectorView( currentMonth: store.currentMonth, onPrevious: { store.send(.goToPreviousMonth) }, - onNext: { store.send(.goToNextMonth) } + onNext: { store.send(.goToNextMonth) }, + onMonthTapped: { store.send(.monthLabelTapped) } ) Spacer() @@ -91,6 +92,18 @@ public struct CalendarView: View { .presentationDetents([.medium, .large]) } } + .sheet( + isPresented: Binding( + get: { store.isMonthPickerPresented }, + set: { if !$0 { store.send(.monthPickerDismissed) } } + ) + ) { + MonthPickerBottomSheet( + selectedDate: store.pickerDate, + onDateChanged: { store.send(.pickerDateChanged($0)) }, + onConfirm: { store.send(.monthPickerConfirmed) } + ) + } } } } diff --git a/Projects/Feature/Calendar/Sources/Components/MonthPickerBottomSheet.swift b/Projects/Feature/Calendar/Sources/Components/MonthPickerBottomSheet.swift new file mode 100644 index 0000000..38877f2 --- /dev/null +++ b/Projects/Feature/Calendar/Sources/Components/MonthPickerBottomSheet.swift @@ -0,0 +1,93 @@ +import SwiftUI +import DoriDesignSystem +import DoriCore + +public struct MonthPickerBottomSheet: View { + let selectedDate: Date + let onDateChanged: (Date) -> Void + let onConfirm: () -> Void + + @State private var selectedYear: Int + @State private var selectedMonth: Int + + private let years: [Int] + private let months: [Int] = Array(1...12) + + public init( + selectedDate: Date, + onDateChanged: @escaping (Date) -> Void, + onConfirm: @escaping () -> Void + ) { + self.selectedDate = selectedDate + self.onDateChanged = onDateChanged + self.onConfirm = onConfirm + + let currentYear = Calendar.current.component(.year, from: Date()) + _selectedYear = State(initialValue: selectedDate.year) + _selectedMonth = State(initialValue: selectedDate.month) + years = Array((currentYear - 5)...(currentYear + 5)) + } + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + Capsule() + .fill(Color.gray.opacity(0.3)) + .frame(width: 36, height: 4) + .frame(maxWidth: .infinity) + .padding(.bottom, 20) + + Text("날짜를 선택하세요") + .pretendard(.subtitle(.sb1)) + .foregroundStyle(.textPrimary) + .padding(.horizontal, 20) + .padding(.bottom, 32) + + HStack(spacing: 0) { + Picker("년도", selection: $selectedYear) { + ForEach(years, id: \.self) { year in + Text(verbatim: "\(year)년").tag(year) + } + } + .pickerStyle(.wheel) + .frame(maxWidth: .infinity) + + Picker("월", selection: $selectedMonth) { + ForEach(months, id: \.self) { month in + Text(verbatim: "\(month)월").tag(month) + } + } + .pickerStyle(.wheel) + .frame(maxWidth: .infinity) + } + .frame(height: 100) + .padding(.bottom, 32) + .onChange(of: selectedYear) { _, newYear in + notifyDateChange(year: newYear, month: selectedMonth) + } + .onChange(of: selectedMonth) { _, newMonth in + notifyDateChange(year: selectedYear, month: newMonth) + } + + PrimaryButton(title: "완료") { + onConfirm() + } + .padding(.horizontal, 16) + } + .background(.bgPrimary) + .ignoresSafeArea(.container, edges: .bottom) + .presentationDetents([.height(310)]) + .presentationBackground(UIAsset.Colors.bgPrimary.color) + .presentationDragIndicator(.hidden) + + } + + private func notifyDateChange(year: Int, month: Int) { + var components = DateComponents() + components.year = year + components.month = month + components.day = 1 + if let date = Calendar.current.date(from: components) { + onDateChanged(date) + } + } +} diff --git a/Projects/Feature/Calendar/Sources/Components/MonthSelectorView.swift b/Projects/Feature/Calendar/Sources/Components/MonthSelectorView.swift index 5785478..f7718df 100644 --- a/Projects/Feature/Calendar/Sources/Components/MonthSelectorView.swift +++ b/Projects/Feature/Calendar/Sources/Components/MonthSelectorView.swift @@ -6,11 +6,18 @@ public struct MonthSelectorView: View { let currentMonth: Date let onPrevious: () -> Void let onNext: () -> Void + let onMonthTapped: () -> Void - public init(currentMonth: Date, onPrevious: @escaping () -> Void, onNext: @escaping () -> Void) { + public init( + currentMonth: Date, + onPrevious: @escaping () -> Void, + onNext: @escaping () -> Void, + onMonthTapped: @escaping () -> Void + ) { self.currentMonth = currentMonth self.onPrevious = onPrevious self.onNext = onNext + self.onMonthTapped = onMonthTapped } public var body: some View { @@ -23,9 +30,13 @@ public struct MonthSelectorView: View { .foregroundStyle(.textPrimary) } - Text(currentMonth.koreanMonth) - .pretendard(.body(.sb2)) - .foregroundStyle(.textPrimary) + Button { + onMonthTapped() + } label: { + Text(currentMonth.koreanMonth) + .pretendard(.body(.sb2)) + .foregroundStyle(.textPrimary) + } Button { onNext() @@ -44,6 +55,7 @@ public struct MonthSelectorView: View { MonthSelectorView( currentMonth: Date(), onPrevious: {}, - onNext: {} + onNext: {}, + onMonthTapped: {} ) } diff --git a/Projects/Feature/Calendar/Tests/Snapshot/Helpers/SnapshotPair.swift b/Projects/Feature/Calendar/Tests/Snapshot/Helpers/SnapshotPair.swift new file mode 100644 index 0000000..27a63e3 --- /dev/null +++ b/Projects/Feature/Calendar/Tests/Snapshot/Helpers/SnapshotPair.swift @@ -0,0 +1,51 @@ +import SnapshotTesting +import SwiftUI +import XCTest + +/// Assert a SwiftUI view's snapshot in both light and dark interface styles. +/// +/// 한 호출로 light/dark 페어 baseline 을 생성한다. swift-snapshot-testing 의 `named:` 인자가 +/// 동일 `testName` 안에서 light/dark PNG 를 다른 이름으로 보존하므로 파일명 충돌이 없다. +/// +/// 다크모드를 그리는 View 스냅샷은 본 헬퍼를 통과시켜 페어 누락을 구조적으로 방지한다. +@MainActor +func assertSnapshotPair( + of view: @autoclosure () -> V, + layout: SwiftUISnapshotLayout = .sizeThatFits, + record: Bool? = nil, + fileID: StaticString = #fileID, + file: StaticString = #filePath, + testName: String = #function, + line: UInt = #line, + column: UInt = #column +) { + let rendered = view() + assertSnapshot( + of: rendered, + as: .image( + layout: layout, + traits: UITraitCollection(userInterfaceStyle: .light) + ), + named: "light", + record: record, + fileID: fileID, + file: file, + testName: testName, + line: line, + column: column + ) + assertSnapshot( + of: rendered, + as: .image( + layout: layout, + traits: UITraitCollection(userInterfaceStyle: .dark) + ), + named: "dark", + record: record, + fileID: fileID, + file: file, + testName: testName, + line: line, + column: column + ) +} diff --git a/Projects/Feature/Calendar/Tests/Snapshot/MonthPickerBottomSheetSnapshotTests.swift b/Projects/Feature/Calendar/Tests/Snapshot/MonthPickerBottomSheetSnapshotTests.swift new file mode 100644 index 0000000..8606516 --- /dev/null +++ b/Projects/Feature/Calendar/Tests/Snapshot/MonthPickerBottomSheetSnapshotTests.swift @@ -0,0 +1,34 @@ +import SnapshotTesting +import SwiftUI +import XCTest + +@testable import FeatureCalendar + +@MainActor +final class MonthPickerBottomSheetSnapshotTests: XCTestCase { + /// 2026-03-01 00:00 UTC — fixed date so baseline is stable across runs + private static let fixedDate: Date = { + var components = DateComponents() + components.year = 2026 + components.month = 3 + components.day = 1 + components.hour = 0 + components.minute = 0 + components.second = 0 + components.timeZone = TimeZone(identifier: "UTC") + return Calendar.current.date(from: components)! + }() + + private func makeView() -> some View { + MonthPickerBottomSheet( + selectedDate: Self.fixedDate, + onDateChanged: { _ in }, + onConfirm: {} + ) + .frame(width: 393) + } + + func test_monthPickerBottomSheet_default() { + assertSnapshotPair(of: makeView(), layout: .sizeThatFits) + } +} diff --git a/Projects/Feature/Calendar/Tests/Snapshot/__Snapshots__/MonthPickerBottomSheetSnapshotTests/test_monthPickerBottomSheet_default.dark.png b/Projects/Feature/Calendar/Tests/Snapshot/__Snapshots__/MonthPickerBottomSheetSnapshotTests/test_monthPickerBottomSheet_default.dark.png new file mode 100644 index 0000000..2dec0a4 Binary files /dev/null and b/Projects/Feature/Calendar/Tests/Snapshot/__Snapshots__/MonthPickerBottomSheetSnapshotTests/test_monthPickerBottomSheet_default.dark.png differ diff --git a/Projects/Feature/Calendar/Tests/Snapshot/__Snapshots__/MonthPickerBottomSheetSnapshotTests/test_monthPickerBottomSheet_default.light.png b/Projects/Feature/Calendar/Tests/Snapshot/__Snapshots__/MonthPickerBottomSheetSnapshotTests/test_monthPickerBottomSheet_default.light.png new file mode 100644 index 0000000..3c841e3 Binary files /dev/null and b/Projects/Feature/Calendar/Tests/Snapshot/__Snapshots__/MonthPickerBottomSheetSnapshotTests/test_monthPickerBottomSheet_default.light.png differ