diff --git a/Noostak_iOS/Noostak_iOS/Global/Components/SchedulePicker/SchedulePicker.swift b/Noostak_iOS/Noostak_iOS/Global/Components/SchedulePicker/SchedulePicker.swift index db99f9f..a25a70b 100644 --- a/Noostak_iOS/Noostak_iOS/Global/Components/SchedulePicker/SchedulePicker.swift +++ b/Noostak_iOS/Noostak_iOS/Global/Components/SchedulePicker/SchedulePicker.swift @@ -42,18 +42,6 @@ final class SchedulePicker: UICollectionView { } // MARK: Public Methods - func addSelectedCell(at indexPath: IndexPath) { - guard mode == .editMode, - let cell = cellForItem(at: indexPath) as? SchedulePickerCell - else { return } - cell.isSelectedCell.toggle() - if cell.isSelectedCell { - selectedCells.insert(indexPath) - } else { - selectedCells.remove(indexPath) - } - } - func configureCellBackground(_ cell: SchedulePickerCell, for indexPath: IndexPath, participants: Int) { guard mode == .readMode else { return } let count = cellAvailability[indexPath, default: 0] diff --git a/Noostak_iOS/Noostak_iOS/Presentation/AvailabilityCreate/Reactor/AvailabilityCreateReactor.swift b/Noostak_iOS/Noostak_iOS/Presentation/AvailabilityCreate/Reactor/AvailabilityCreateReactor.swift new file mode 100644 index 0000000..39fb2f8 --- /dev/null +++ b/Noostak_iOS/Noostak_iOS/Presentation/AvailabilityCreate/Reactor/AvailabilityCreateReactor.swift @@ -0,0 +1,68 @@ +// +// AvailabilityCreateReactor.swift +// Noostak_iOS +// +// Created by 오연서 on 2/26/25. +// + +import UIKit +import ReactorKit + +final class AvailabilityCreateReactor: Reactor { + enum Action { + case selectCell(IndexPath) + case tapConfirmAvailableTimes + } + + enum Mutation { + case toggleCell(IndexPath) + case setAvailableTimes([AvailableTime]) + } + + struct State { + var selectedCells: Set = [] + var availableTimes: [AvailableTime] = [] + } + + let initialState = State() + + func mutate(action: Action) -> Observable { + switch action { + case .selectCell(let indexPath): + return .just(.toggleCell(indexPath)) + + case .tapConfirmAvailableTimes: + let selectedCells = currentState.selectedCells + let selectedDateTimes = NSTDateUtility.selectedCellsToDateTime( + selectedCells: selectedCells, + dateHeaders: NSTDateUtility.dateList(mockDateList), + timeHeaders: NSTDateUtility.timeList(mockStartTime, mockEndTime), + originalDateList: mockDateList + ) + print(selectedDateTimes) + + let availableTimes = selectedDateTimes.map { dateTime -> AvailableTime in + return AvailableTime(date: dateTime, startTime: dateTime, endTime: dateTime) + } + return .just(.setAvailableTimes(availableTimes)) + } + } + + func reduce(state: State, mutation: Mutation) -> State { + var newState = state + switch mutation { + case .toggleCell(let indexPath): + newState.selectedCells = newState.selectedCells.symmetricDifference([indexPath]) + + case .setAvailableTimes(let availableTimes): + newState.availableTimes = availableTimes + } + return newState + } +} + +struct AvailableTime: Codable { + let date: String + let startTime: String + let endTime: String +} diff --git a/Noostak_iOS/Noostak_iOS/Presentation/AvailabilityCreate/View/AvailabilityCreateView.swift b/Noostak_iOS/Noostak_iOS/Presentation/AvailabilityCreate/View/AvailabilityCreateView.swift new file mode 100644 index 0000000..43ff08c --- /dev/null +++ b/Noostak_iOS/Noostak_iOS/Presentation/AvailabilityCreate/View/AvailabilityCreateView.swift @@ -0,0 +1,181 @@ +// +// AvailabilityCreateView.swift +// Noostak_iOS +// +// Created by 오연서 on 2/26/25. +// + +import UIKit +import Then +import SnapKit +import RxSwift +import RxCocoa + +let mockDateList: [String] = [ + "2024-09-05T10:00:00", + "2024-09-06T10:00:00", + "2024-09-09T10:00:00", + "2024-09-10T10:00:00", + "2024-09-11T10:00:00", + "2024-09-12T10:00:00" +] +let mockStartTime: String = "2024-09-05T08:00:00" +let mockEndTime: String = "2024-09-05T23:00:00" +let totalRows = timeHeaders.count + 1 +let totalColumns = dateHeaders.count + 1 +let dateHeaders: [String] = NSTDateUtility.dateList(mockDateList) +let timeHeaders: [String] = NSTDateUtility.timeList(mockStartTime, mockEndTime) + +final class AvailabilityCreateView: UIView { + // MARK: Properties + private let disposeBag = DisposeBag() + + // MARK: Views + private let scrollView = UIScrollView() + private let contentView = UIView() + private let availbilityLabel = UILabel() + let schedulePickerView = SchedulePicker(timeHeaders: timeHeaders, dateHeaders: dateHeaders, mode: .readMode) + let confirmButton = AppThemeButton(theme: .grayScale, title: "확인") + + // MARK: Init + override init(frame: CGRect) { + super.init(frame: frame) + setUpFoundation() + setUpHierarchy() + setUpUI() + setUpLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: setUpHierarchy + private func setUpHierarchy() { + [scrollView, confirmButton].forEach { + self.addSubview($0) + } + scrollView.addSubview(contentView) + [availbilityLabel, schedulePickerView].forEach { + contentView.addSubview($0) + } + } + + private func setUpFoundation() { + self.backgroundColor = .appWhite + } + + // MARK: setUpUI + private func setUpUI() { + availbilityLabel.do { + $0.text = "가능한 시간을\n모두 선택해주세요" + $0.numberOfLines = 2 + $0.font = .PretendardStyle.h4_b.font + } + + schedulePickerView.do { + $0.showsVerticalScrollIndicator = false + } + + confirmButton.do { + $0.backgroundColor = .appGray900 + } + } + + // MARK: setUpLayout + private func setUpLayout() { + scrollView.snp.makeConstraints { + $0.top.bottom.equalTo(self.safeAreaLayoutGuide) + $0.horizontalEdges.equalToSuperview() + } + + contentView.snp.makeConstraints { + $0.edges.equalTo(scrollView.contentLayoutGuide) + $0.width.equalToSuperview() + $0.bottom.equalTo(schedulePickerView.snp.bottom) + } + + availbilityLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(12) + $0.leading.equalToSuperview().offset(16) + } + + confirmButton.snp.makeConstraints { + $0.horizontalEdges.equalToSuperview().inset(16) + $0.bottom.equalTo(self.safeAreaLayoutGuide) + $0.height.equalTo(54) + } + + schedulePickerView.snp.makeConstraints { + $0.top.equalTo(availbilityLabel.snp.bottom).offset(8) + $0.horizontalEdges.equalToSuperview().inset(16) + $0.bottom.equalTo(self.safeAreaLayoutGuide).inset(62) + } + } +} + +extension NSTDateUtility { + ///타임테이블 뷰 : "요일 월/일" + static func dateList(_ dateStrings: [String]) -> [String] { + let formatter = NSTDateUtility(format: .yyyyMMddTHHmmss) // ISO 8601 형식 + let displayFormatter = NSTDateUtility(format: .EEMMdd) // 출력 형식 + + return dateStrings.compactMap { dateString in + switch formatter.date(from: dateString) { + case .success(let date): + return displayFormatter.string(from: date) + case .failure(let error): + print("Failed to parse date \(dateString): \(error.localizedDescription)") + return nil + } + } + } + + ///타임테이블 뷰 : "00시" + static func timeList(_ startTime: String, _ endTime: String) -> [String] { + let formatter = NSTDateUtility(format: .yyyyMMddTHHmmss) // ISO 8601 형식 + var result: [String] = [] + + switch (formatter.date(from: startTime), formatter.date(from: endTime)) { + case (.success(let start), .success(let end)): + let calendar = Calendar.current + var current = start + + while current <= end { + result.append(NSTDateUtility(format: .HH).string(from: current)) // 출력 형식 + if let nextHour = calendar.date(byAdding: .hour, value: 1, to: current) { + current = nextHour + } else { + break + } + } + default: + print("Failed to parse start or end time.") + return [] + } + return result + } + + static func selectedCellsToDateTime(selectedCells: Set, dateHeaders: [String], timeHeaders: [String], originalDateList: [String]) -> [String] { + let formatter = NSTDateUtility(format: .EEMMdd) // dateList 출력형식 + let originalFormatter = NSTDateUtility(format: .yyyyMMddTHHmmss) // ISO 8601 원본 포맷 + + return selectedCells.compactMap { indexPath in + let column = indexPath.item % dateHeaders.count + let row = indexPath.item / dateHeaders.count + let selectedDateHeader = dateHeaders[column] + let selectedTimeHeader = timeHeaders[row] + + guard let originalDate = originalDateList.first(where: { + switch originalFormatter.date(from: $0) { + case .success(let parsedDate): + return formatter.string(from: parsedDate) == selectedDateHeader + case .failure: + return false + } + }) else { return nil } + + return "\(originalDate.prefix(10))T\(selectedTimeHeader):00:00" + } + } +} diff --git a/Noostak_iOS/Noostak_iOS/Presentation/AvailabilityCreate/ViewController/AvailabilityCreateViewController.swift b/Noostak_iOS/Noostak_iOS/Presentation/AvailabilityCreate/ViewController/AvailabilityCreateViewController.swift new file mode 100644 index 0000000..fa57fa3 --- /dev/null +++ b/Noostak_iOS/Noostak_iOS/Presentation/AvailabilityCreate/ViewController/AvailabilityCreateViewController.swift @@ -0,0 +1,101 @@ +// +// AvailabilityCreateViewController.swift +// Noostak_iOS +// +// Created by 오연서 on 2/26/25. +// + +import UIKit +import ReactorKit +import RxSwift +import RxCocoa +import RxDataSources + +final class AvailabilityCreateViewController: UIViewController, View { + // MARK: - Properties + var disposeBag = DisposeBag() + private let rootView = AvailabilityCreateView() + + // MARK: - Init + init(reactor: AvailabilityCreateReactor) { + super.init(nibName: nil, bundle: nil) + self.reactor = reactor + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = rootView + } + + override func viewDidLoad() { + super.viewDidLoad() + setUpFoundation() + bindCollectionView() + } + + private func setUpFoundation() { + self.view.backgroundColor = .white + } + + private func bindCollectionView() { + let items = Observable.just([ + SectionModel(model: "Section 1", items: Array(repeating: "", count: totalRows * totalColumns)) + ]) + + let dataSource = RxCollectionViewSectionedReloadDataSource>( + configureCell: { _, collectionView, indexPath, _ in + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: SchedulePickerCell.identifier, for: indexPath + ) as? SchedulePickerCell else { + return UICollectionViewCell() + } + cell.configureHeader(for: indexPath, dateHeaders: dateHeaders, timeHeaders: timeHeaders) + cell.configureTableRoundness(for: indexPath, dateHeaders: dateHeaders, timeHeaders: timeHeaders) + return cell + } + ) + items + .bind(to: rootView.schedulePickerView.rx.items(dataSource: dataSource)) + .disposed(by: disposeBag) + } + + func bind(reactor: AvailabilityCreateReactor) { + rootView.confirmButton.bind(state: Observable.just(.able)) + + rootView.schedulePickerView.rx.itemSelected + .compactMap { [weak self] indexPath -> AvailabilityCreateReactor.Action? in + guard let self = self else { return nil } + let row = indexPath.item / totalColumns + let column = indexPath.item % totalColumns + // 헤더 행과 열 선택 비활성화 + guard row > 0, column > 0 else { + self.rootView.schedulePickerView.deselectItem(at: indexPath, animated: true) + return nil + } + return AvailabilityCreateReactor.Action.selectCell(indexPath) + } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + rootView.confirmButton.rx.tap + .map { AvailabilityCreateReactor.Action.tapConfirmAvailableTimes } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + reactor.state + .map { $0.selectedCells } + .distinctUntilChanged() + .subscribe(onNext: { [weak self] selectedCells in + guard let self = self else { return } + self.rootView.schedulePickerView.visibleCells.forEach { cell in + guard let indexPath = self.rootView.schedulePickerView.indexPath(for: cell) else { return } + let isSelected = selectedCells.contains(indexPath) + cell.backgroundColor = isSelected ? .appBlue400 : .clear + } + }) + .disposed(by: disposeBag) + } +}