Skip to content
44 changes: 7 additions & 37 deletions Koin/Core/View/BottomSheetViewControllerB.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ protocol BottomSheetViewControllerBDelegate: AnyObject {
final class BottomSheetViewControllerB: UIViewController {

// MARK: - Properties
private var contentViewBottomConstraint: Constraint?
private var safeAreaHeightConstraint: Constraint?
private var alpha: CGFloat

Expand Down Expand Up @@ -54,7 +55,6 @@ final class BottomSheetViewControllerB: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
configureView()
addObserver()
setGesture()
hideKeyboardWhenTappedAround()
}
Expand All @@ -69,33 +69,26 @@ final class BottomSheetViewControllerB: UIViewController {
extension BottomSheetViewControllerB: BottomSheetViewControllerBDelegate {

func present() {
contentView.snp.remakeConstraints {
$0.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom)
$0.leading.trailing.equalToSuperview()
}
// view.layoutIfNeeded()
contentViewBottomConstraint?.update(offset: 0)
UIView.animate(withDuration: 0.25) { [weak self] in
guard let self else { return }
dimView.alpha = alpha
view.setNeedsLayout()
view.layoutIfNeeded()
}
}

func dismiss() {
contentView.snp.remakeConstraints {
$0.top.equalTo(view.snp.bottom)
$0.leading.trailing.equalToSuperview()
}
contentViewBottomConstraint?.update(offset: contentView.bounds.height)
UIView.animate(
withDuration: 0.25,
animations: { [weak self] in
guard let self else { return }
dimView.alpha = 0
view.setNeedsLayout()
view.layoutIfNeeded()
},
completion: { [weak self] _ in
self?.dismiss(animated: true)
self?.dismiss(animated: false)
})
}
}
Expand All @@ -107,34 +100,10 @@ extension BottomSheetViewControllerB {
dimView.addGestureRecognizer(tapGesture)
}

private func addObserver() {
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
}

@objc private func dimViewTapped() {
dismissKeyboard()
dismiss()
}

@objc private func keyboardWillShow(_ notification: Notification) {
contentView.snp.remakeConstraints {
$0.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
$0.leading.trailing.equalToSuperview()
}
UIView.animate(withDuration: 0.25) { [weak self] in
self?.view.layoutIfNeeded()
}
}
@objc private func keyboardWillHide(_ notification: Notification) {
contentView.snp.remakeConstraints {
$0.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom)
$0.leading.trailing.equalToSuperview()
}
UIView.animate(withDuration: 0.25) { [weak self] in
self?.view.layoutIfNeeded()
}
}
}

extension BottomSheetViewControllerB {
Expand All @@ -155,7 +124,8 @@ extension BottomSheetViewControllerB {
$0.edges.equalToSuperview()
}
contentView.snp.makeConstraints {
$0.top.equalTo(view.snp.bottom)
contentView.layoutIfNeeded()
contentViewBottomConstraint = $0.bottom.equalTo(view.keyboardLayoutGuide.snp.top).offset(contentView.bounds.height).constraint
Comment on lines +127 to +128
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Initialize the hidden offset after the sheet has a real height.

contentView.bounds.height is often still 0 here because the injected view has not completed an Auto Layout pass yet. In that case the sheet starts on-screen and the first present() animation becomes a no-op. Use an off-screen offset derived after layout, or from the container height instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Koin/Core/View/BottomSheetViewControllerB.swift` around lines 127 - 128, The
bottom constraint uses contentView.bounds.height before the injected view has
completed layout, so initialize the hidden/off-screen offset after a layout pass
instead of using contentView.bounds.height immediately. Move or defer setting
contentViewBottomConstraint (the constraint created against
view.keyboardLayoutGuide.snp.top) until after layout (e.g., in
viewDidLayoutSubviews or after calling view.layoutIfNeeded()) and calculate the
offset from a stable container height (view.bounds.height or the fully laid-out
contentView.frame.height) so present() will animate from off-screen correctly.

$0.leading.trailing.equalToSuperview()
}
safeAreaView.snp.makeConstraints {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,12 @@ import Then
final class KoinPickerDropDownViewDateDelegate {

// MARK: - Properties
let columnWidths: [CGFloat] = [53, 31, 31]
private var dates: [Int: [Int: [Int]]] = [:]
private let columnWidths: [CGFloat] = [53, 31, 31]

private let inputFormatter = DateFormatter().then {
$0.dateFormat = "yyyy-MM-dd"
}

private let outputFormatter = DateFormatter().then {
$0.dateFormat = "yyyy년*M월*d일"
// MARK: - Initialzier
init(range: Range<Int>) {
resetDates(range: range)
Comment on lines +14 to +19
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Refresh the cached dates when the picker resets.

resetDates(range:) now runs only in init(range:). Because Koin/Presentation/CallVan/CallVanPost/Subviews/CallVanPostDateView.swift creates this delegate once at Line 28, leaving the form open across midnight makes offset 0 still point to yesterday and the range never advances until the view is recreated.

💡 Suggested fix
 final class KoinPickerDropDownViewDateDelegate {
     
     // MARK: - Properties
     private var dates: [Int: [Int: [Int]]] = [:]
     private let columnWidths: [CGFloat] = [53, 31, 31]
+    private let range: Range<Int>
 
     // MARK: - Initialzier
     init(range: Range<Int>) {
+        self.range = range
         resetDates(range: range)
     }
 }
 
 extension KoinPickerDropDownViewDateDelegate: KoinPickerDropDownViewDelegate {
@@
     func reset(koinPicker: KoinPickerDropDownView, initialDate: Date) {
+        resetDates(range: range)
         let year = initialDate.year
         let month = initialDate.month
         let day = initialDate.day

Also applies to: 29-37, 72-93

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Koin/Core/View/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewDateDelegate.swift`
around lines 14 - 19, The cached dates dictionary in
KoinPickerDropDownViewDateDelegate is only populated in init(range:) so the
picker never refreshes across midnight; ensure resetDates(range:) is invoked
whenever the picker or its range is reset/updated (for example inside the
delegate's public reset/updateRange method and whenever the selected
offset/visible range changes, including when offset == 0), so the dates cache
(dates) is rebuilt before reloading components (e.g., before calling
reloadAllComponents). Locate resetDates(range:) and init(range:) in
KoinPickerDropDownViewDateDelegate and add calls to resetDates(range:) in the
delegate methods that handle range/offset changes or picker resets so the cached
dates reflect the current day.

}
}

Expand All @@ -29,68 +27,90 @@ extension KoinPickerDropDownViewDateDelegate: KoinPickerDropDownViewDelegate {
}

func reset(koinPicker: KoinPickerDropDownView, initialDate: Date) {
let year = initialDate.year
let month = initialDate.month
let day = initialDate.day

let selectedItem = outputFormatter.string(from: initialDate).components(separatedBy: "*")
let items = getItems(selectedItem: selectedItem)
let items = getItems(year: year, month: month)
let selectedItem: [String] = ["\(year)년", "\(month)월", "\(day)일"]

koinPicker.reset(items: items, selectedItem: selectedItem, columnWidths: [53, 31, 31])
koinPicker.reset(items: items, selectedItem: selectedItem, columnWidths: columnWidths)
}

func selectedItemUpdated(koinPicker: KoinPickerDropDownView, selectedItem: [String]) {

guard let yearInt = Int(selectedItem[0].filter { $0.isNumber }),
let monthInt = Int(selectedItem[1].filter { $0.isNumber }),
var dayInt = Int(selectedItem[2].filter { $0.isNumber }) else {
guard let selectedYear = Int(selectedItem[0].filter { $0.isNumber }),
var selectedMonth = Int(selectedItem[1].filter { $0.isNumber }),
var selectedDay = Int(selectedItem[2].filter { $0.isNumber }) else {
assert(false)
return
}
let maxDay = getAllDays(year: yearInt, month: monthInt).count
if dayInt > maxDay {
dayInt = maxDay
let items = getItems(year: selectedYear, month: selectedMonth)
guard let minMonthString = items[1].first?.filter({ $0.isNumber}),
let minMonth = Int(minMonthString),
let maxMonthString = items[1].last?.filter({ $0.isNumber}),
let maxMonth = Int(maxMonthString) else {
assert(false)
return
}
guard let validDate = inputFormatter.date(from: String(format: "%d-%d-%d", yearInt, monthInt, dayInt)) else {
guard let minDayString = items[2].first?.filter({ $0.isNumber }),
let minDay = Int(minDayString),
let maxDayString = items[2].last?.filter({ $0.isNumber }),
let maxDay = Int(maxDayString) else {
assert(false)
return
}
reset(koinPicker: koinPicker, initialDate: validDate)
selectedMonth = min(max(selectedMonth, minMonth), maxMonth)
selectedDay = min(max(selectedDay, minDay), maxDay)
let selectedItem: [String] = ["\(selectedYear)년", "\(selectedMonth)월", "\(selectedDay)일"]
koinPicker.reset(items: items, selectedItem: selectedItem, columnWidths: columnWidths)
}
}

extension KoinPickerDropDownViewDateDelegate {

private func getItems(selectedItem: [String]) -> [[String]] {

guard let selectedYearInt = Int(selectedItem[0].filter { $0.isNumber }),
let selectedMonthInt = Int(selectedItem[1].filter { $0.isNumber }) else {
assert(false)
return [[],[],[]]
private func resetDates(range: Range<Int>) {
let calendar = Calendar.current
let startDay = calendar.startOfDay(for: Date())
let availableDates = range.compactMap {
calendar.date(byAdding: .day, value: $0, to: startDay)
}

let years: [String] = {
let thisYearInt = Date().year
let lowestYearInt = min(selectedYearInt, thisYearInt)
let heighestYearInt = thisYearInt + 1
return Range(lowestYearInt...heighestYearInt).map { String($0)+"년" }
}()
let months: [String] = Range(1...12).map { String($0)+"월" }
let days: [String] = getAllDays(year: selectedYearInt, month: selectedMonthInt)
var dates: [Int: [Int: [Int]]] = [:]

return [years, months, days]
for date in availableDates {
if dates[date.year] == nil {
dates[date.year] = [:]
}

if dates[date.year]?[date.month] == nil {
dates[date.year]?[date.month] = []
}

dates[date.year]?[date.month]?.append(date.day)
}

self.dates = dates
}

func getAllDays(year: Int, month: Int) -> [String] {

let calendar = Calendar.current
var components = DateComponents()
components.year = year
components.month = month
private func getItems(year: Int, month: Int) -> [[String]] {
guard let minYear: Int = dates.keys.sorted().first,
let maxYear: Int = dates.keys.sorted().last else {
assert(false)
return [[],[],[]]
}
let year = min(max(minYear, year), maxYear)

guard let date = calendar.date(from: components),
let dayRange = calendar.range(of: .day, in: .month, for: date) else {
guard let minMonth: Int = dates[year]?.keys.sorted().first,
let maxMonth: Int = dates[year]?.keys.sorted().last else {
assert(false)
return []
return [[],[],[]]
}
let month = min(max(minMonth, month), maxMonth)

return dayRange.map { String($0) + "일" }
let years: [String] = dates.keys.sorted().map { "\($0)년" }
let months: [String] = dates[year]!.keys.sorted().map { "\($0)월" }
let days: [String] = dates[year]![month]!.sorted().compactMap { "\($0)일" }
return [years, months, days]
}
}
2 changes: 1 addition & 1 deletion Koin/Data/DTOs/Decodable/CallVan/CallVanDataDto.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ extension CallVanDataDto {
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "ko_KR")
if let date = formatter.date(from: departureDate) {
formatter.dateFormat = "MM.dd (a)"
formatter.dateFormat = "MM.dd (E)"
return formatter.string(from: date)
} else {
return departureDate
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,11 @@ extension CallVanChatViewModel {

private func fetchData() {
fetchCallVanDataUseCase.execute(postId: postId).sink(
receiveCompletion: { _ in },
receiveCompletion: { [weak self] comepltion in
if case .failure(let error) = comepltion {
self?.outputSubject.send(.showToast(error.message))
}
},
receiveValue: { [weak self] callVanData in
self?.outputSubject.send(.updateData(callVanData))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ extension CallVanDataViewController {
participantsTableView.configure(participants: callVanData.participants)
case let .updateBell(alert):
configureRightBarButton(alert: alert)
case let .showToast(message):
showToastMessage(message: message)
}
refreshControl.endRefreshing()
}.store(in: &subscriptions)
Expand Down
13 changes: 11 additions & 2 deletions Koin/Presentation/CallVan/CallVanData/CallVanDataViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ final class CallVanDataViewModel: ViewModelProtocol {
enum Output {
case update(CallVanData)
case updateBell(alert: Bool)
case showToast(String)
}
enum Input {
case viewWillAppear
Expand Down Expand Up @@ -64,7 +65,11 @@ extension CallVanDataViewModel {

private func fetchData() {
fetchCallVanDataUseCase.execute(postId: postId).sink(
receiveCompletion: { _ in },
receiveCompletion: { [weak self] completion in
if case .failure(let error) = completion {
self?.outputSubject.send(.showToast(error.message))
}
},
receiveValue: { [weak self] callVanData in
self?.outputSubject.send(.update(callVanData))
}
Expand All @@ -73,7 +78,11 @@ extension CallVanDataViewModel {

private func fetchNotification() {
fetchCallVanNotificationListUseCase.execute().sink(
receiveCompletion: { _ in },
receiveCompletion: { [weak self] completion in
if case .failure(let error) = completion {
self?.outputSubject.send(.showToast(error.message))
}
},
receiveValue: { [weak self] notifications in
let alert = notifications.filter { !$0.isRead }.count != 0
self?.outputSubject.send(.updateBell(alert: alert))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,11 @@ extension CallVanListViewModel {

private func fetchNotification() {
fetchCallVanNotificationListUseCase.execute().sink(
receiveCompletion: { _ in },
receiveCompletion: { [weak self] completion in
if case .failure(let error) = completion {
self?.outputSubject.send(.showToast(error.message))
}
},
receiveValue: { [weak self] notifications in
let alert = notifications.filter { !$0.isRead }.count != 0
self?.outputSubject.send(.updateBell(alert: alert))
Expand Down
43 changes: 36 additions & 7 deletions Koin/Presentation/CallVan/CallVanPost/CallVanPostViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ final class CallVanPostViewModel: ViewModelProtocol {
}
private(set) var request = CallVanPostRequest() {
didSet {
validateRequest()
validate()
}
}

Expand Down Expand Up @@ -98,13 +98,42 @@ extension CallVanPostViewModel {
outputSubject.send(.updateArrival(request.arrivalType, request.arrivalCustomName))
}

private func validateRequest() {
let validation = request.departureType != nil
&& request.arrivalType != nil
&& request.departureDate != nil
&& request.departureTime != nil
private func validate() {
var isValid = true

outputSubject.send(.enablePostButton(validation))
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm"
let tenMinutes: TimeInterval = 1 * 60 * 10

/// 빈 값이 있는지 확인
if request.departureType == nil
|| request.arrivalType == nil
|| request.departureDate?.isEmpty == true
|| request.departureTime?.isEmpty == true {
isValid = false
}

/// 출발지와 도착지가 다른지 확인
switch (request.departureType, request.arrivalType) {
case (.custom, .custom):
if request.departureCustomName == request.arrivalCustomName {
isValid = false
}
default:
if request.departureType == request.arrivalType {
isValid = false
}
}

/// 현재 시각으로부터 최소 10분 이후 일정인지 확인
let date = request.departureDate ?? ""
let time = request.departureTime ?? ""
if let requestDate = formatter.date(from: "\(date) \(time)"),
requestDate.timeIntervalSince(Date()) < tenMinutes {
isValid = false
}

outputSubject.send(.enablePostButton(isValid))
Comment on lines +101 to +136
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

nil date/time values still pass this validation.

departureDate?.isEmpty == true and departureTime?.isEmpty == true only reject empty strings. If either field is still nil, isValid stays true and the parse branch is skipped, so the post button can enable before those fields are populated.

💡 Suggested fix
 private func validate() {
     var isValid = true
     
     let formatter = DateFormatter()
     formatter.dateFormat = "yyyy-MM-dd HH:mm"
-    let tenMinutes: TimeInterval = 1 * 60 * 10
+    let tenMinutes: TimeInterval = 10 * 60
     
-    /// 빈 값이 있는지 확인
-    if request.departureType == nil
-        || request.arrivalType == nil
-        || request.departureDate?.isEmpty == true
-        || request.departureTime?.isEmpty == true {
-        isValid = false
-    }
+    guard request.departureType != nil,
+          request.arrivalType != nil,
+          let date = request.departureDate, !date.isEmpty,
+          let time = request.departureTime, !time.isEmpty,
+          let requestDate = formatter.date(from: "\(date) \(time)") else {
+        outputSubject.send(.enablePostButton(false))
+        return
+    }
     
     /// 출발지와 도착지가 다른지 확인
     switch (request.departureType, request.arrivalType) {
     case (.custom, .custom):
         if request.departureCustomName == request.arrivalCustomName {
             isValid = false
         }
     default:
         if request.departureType == request.arrivalType {
             isValid = false
         }
     }
     
     /// 현재 시각으로부터 최소 10분 이후 일정인지 확인
-    let date = request.departureDate ?? ""
-    let time = request.departureTime ?? ""
-    if let requestDate = formatter.date(from: "\(date) \(time)"),
-       requestDate.timeIntervalSince(Date()) < tenMinutes {
+    if requestDate.timeIntervalSince(Date()) < tenMinutes {
         isValid = false
     }
     
     outputSubject.send(.enablePostButton(isValid))
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Koin/Presentation/CallVan/CallVanPost/CallVanPostViewModel.swift` around
lines 101 - 136, In validate(), nils for departureDate/departureTime are not
treated as invalid; update the empty-value checks to explicitly reject nil or
empty strings for request.departureDate and request.departureTime (e.g., use
optional binding/guards to require non-nil, non-empty values before parsing), so
the parse branch always runs only when both date and time are present, and keep
the existing time-interval check using the bound values; reference validate(),
request.departureDate, request.departureTime, and the formatter/date parsing
logic.

}

private func postData() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ final class CallVanPostDateView: ExtendedTouchAreaView {
private let dateButton = UIButton()
private let dateLabel = UILabel()
private let downArrowImageView = UIImageView()
private let dateDropDownView = KoinPickerDropDownView(delegate: KoinPickerDropDownViewDateDelegate())
private let dateDropDownView = KoinPickerDropDownView(delegate: KoinPickerDropDownViewDateDelegate(range: 0..<365))

// MARK: - Initializer
init() {
Expand Down
Loading