Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions Projects/Feature/Calendar/Sources/CalendarFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -37,6 +39,7 @@ public struct CalendarFeature {

public init(currentMonth: Date = Date()) {
self.currentMonth = currentMonth
self.pickerDate = currentMonth
self.calendarData = .empty(for: currentMonth)
}
}
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
15 changes: 14 additions & 1 deletion Projects/Feature/Calendar/Sources/CalendarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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) }
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
Expand All @@ -44,6 +55,7 @@ public struct MonthSelectorView: View {
MonthSelectorView(
currentMonth: Date(),
onPrevious: {},
onNext: {}
onNext: {},
onMonthTapped: {}
)
}
Original file line number Diff line number Diff line change
@@ -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<V: View>(
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
)
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading