diff --git a/Noostak_iOS/Noostak_iOS/Domain/Entity/Schedule.swift b/Noostak_iOS/Noostak_iOS/Domain/Entity/Schedule.swift index fed3f40..f786419 100644 --- a/Noostak_iOS/Noostak_iOS/Domain/Entity/Schedule.swift +++ b/Noostak_iOS/Noostak_iOS/Domain/Entity/Schedule.swift @@ -22,19 +22,19 @@ struct Schedule { ///약속 카테고리 let category: ScheduleCategory ///약속 생성기간 - let dates: [Date] + let selectionDates: [Date] ///약속생성 시작일 - var startDate: Date? { - return dates.sorted().first + var selectionStartDate: Date? { + return selectionDates.sorted().first } ///약속생성 종료일 - var endDate: Date? { - return dates.sorted().last + var selectionEndDate: Date? { + return selectionDates.sorted().last } - ///약속 시작시각 - let startTime: Date - ///약속 종료시각 - let endTime: Date + ///약속생성 시작시각 + let selectionStartTime: Date + ///약속생성 종료시각 + let selectionEndTime: Date ///소요시간 var duration: Int? } @@ -42,10 +42,20 @@ struct Schedule { struct ExtendedSchedule { ///스케쥴 let schedule: Schedule + ///약속 날짜(1순위, 확정) + let date: String + ///약속 시작시각(1순위, 확정) + let startTime: String + ///약속 종료시각(1순위, 확정) + let endTime: String ///가능한 친구 let availableMembers: [User] ///불가능한 친구 let unavailableMembers: [User] + ///그룹 총인원 + var groupMemberCount: Int + ///가능한 인원 + var availableMemberCount: Int } extension ScheduleCategory { diff --git a/Noostak_iOS/Noostak_iOS/Global/Extension/UIFont+.swift b/Noostak_iOS/Noostak_iOS/Global/Extension/UIFont+.swift index bf4488f..82cadf0 100644 --- a/Noostak_iOS/Noostak_iOS/Global/Extension/UIFont+.swift +++ b/Noostak_iOS/Noostak_iOS/Global/Extension/UIFont+.swift @@ -46,6 +46,7 @@ extension UIFont { case h1_sb case h2_b case h3_sb + case h3_22_SB case h4_b case h4_sb case h5_b @@ -63,14 +64,15 @@ extension UIFont { case c2_sb case c3_r case c4_r - + var font: UIFont { switch self { - case .h1_b: return UIFont.pretendard(.bold, size: 56) - case .h1_sb: return UIFont.pretendard(.semibold, size: 56) - case .h2_b: return UIFont.pretendard(.bold, size: 27) + case .h1_b: return UIFont.pretendard(.bold, size: 27) + case .h1_sb: return UIFont.pretendard(.semibold, size: 27) + case .h2_b: return UIFont.pretendard(.bold, size: 24) case .h3_sb: return UIFont.pretendard(.semibold, size: 24) - case .h4_b: return UIFont.pretendard(.bold, size: 24) + case .h3_22_SB: return UIFont.pretendard(.semibold, size: 22) + case .h4_b: return UIFont.pretendard(.bold, size: 20) case .h4_sb: return UIFont.pretendard(.semibold, size: 20) case .h5_b: return UIFont.pretendard(.bold, size: 18) case .t1_sb: return UIFont.pretendard(.semibold, size: 18) @@ -89,13 +91,13 @@ extension UIFont { case .c4_r: return UIFont.pretendard(.regular, size: 11) } } - + // Line Height (LHLHUnit) var lineHeightUnit: CGFloat { switch self { - case .h1_b, .h1_sb, .h2_b, .h3_sb, .h4_b, .h4_sb, .h5_b, - .t1_sb, .t2_r, .t3_b, .t4_b, .b1_sb, .b2_r, .b4_sb, - .b4_sb_1percent, .b4_r, .b5_r, .c1_b, .c2_sb, .c3_r, .c4_r: + case .h1_b, .h1_sb, .h2_b, .h3_sb, .h3_22_SB, .h4_b, .h4_sb, .h5_b, + .t1_sb, .t2_r, .t3_b, .t4_b, .b1_sb, .b2_r, .b4_sb, + .b4_sb_1percent, .b4_r, .b5_r, .c1_b, .c2_sb, .c3_r, .c4_r: return 140 } } diff --git a/Noostak_iOS/Noostak_iOS/Global/Resources/Assets.xcassets/ic_arrow_right.imageset/Contents.json b/Noostak_iOS/Noostak_iOS/Global/Resources/Assets.xcassets/ic_arrow_right.imageset/Contents.json new file mode 100644 index 0000000..ba0cba8 --- /dev/null +++ b/Noostak_iOS/Noostak_iOS/Global/Resources/Assets.xcassets/ic_arrow_right.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Vector.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Noostak_iOS/Noostak_iOS/Global/Resources/Assets.xcassets/ic_arrow_right.imageset/Vector.svg b/Noostak_iOS/Noostak_iOS/Global/Resources/Assets.xcassets/ic_arrow_right.imageset/Vector.svg new file mode 100644 index 0000000..9901a0e --- /dev/null +++ b/Noostak_iOS/Noostak_iOS/Global/Resources/Assets.xcassets/ic_arrow_right.imageset/Vector.svg @@ -0,0 +1,3 @@ + + + diff --git a/Noostak_iOS/Noostak_iOS/Global/Resources/Assets.xcassets/ic_group_default.imageset/Contents.json b/Noostak_iOS/Noostak_iOS/Global/Resources/Assets.xcassets/ic_group_default.imageset/Contents.json new file mode 100644 index 0000000..0c69458 --- /dev/null +++ b/Noostak_iOS/Noostak_iOS/Global/Resources/Assets.xcassets/ic_group_default.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Supervisor account.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Noostak_iOS/Noostak_iOS/Global/Resources/Assets.xcassets/ic_group_default.imageset/Supervisor account.svg b/Noostak_iOS/Noostak_iOS/Global/Resources/Assets.xcassets/ic_group_default.imageset/Supervisor account.svg new file mode 100644 index 0000000..31acc5c --- /dev/null +++ b/Noostak_iOS/Noostak_iOS/Global/Resources/Assets.xcassets/ic_group_default.imageset/Supervisor account.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Noostak_iOS/Noostak_iOS/Global/Resources/Assets.xcassets/ic_share.imageset/Contents.json b/Noostak_iOS/Noostak_iOS/Global/Resources/Assets.xcassets/ic_share.imageset/Contents.json new file mode 100644 index 0000000..b2d0c9d --- /dev/null +++ b/Noostak_iOS/Noostak_iOS/Global/Resources/Assets.xcassets/ic_share.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "icon_share.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Noostak_iOS/Noostak_iOS/Global/Resources/Assets.xcassets/ic_share.imageset/icon_share.svg b/Noostak_iOS/Noostak_iOS/Global/Resources/Assets.xcassets/ic_share.imageset/icon_share.svg new file mode 100644 index 0000000..ffeb759 --- /dev/null +++ b/Noostak_iOS/Noostak_iOS/Global/Resources/Assets.xcassets/ic_share.imageset/icon_share.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Noostak_iOS/Noostak_iOS/Global/Utils/NSTDateUtility.swift b/Noostak_iOS/Noostak_iOS/Global/Utils/NSTDateUtility.swift index e3e029d..2797acb 100644 --- a/Noostak_iOS/Noostak_iOS/Global/Utils/NSTDateUtility.swift +++ b/Noostak_iOS/Noostak_iOS/Global/Utils/NSTDateUtility.swift @@ -53,6 +53,8 @@ public extension NSTDateUtility { case yyyyMM case EE case HH + case HHmm + case EEMMdd case MMddEE var format: String { @@ -69,8 +71,12 @@ public extension NSTDateUtility { return "EE" case .HH: return "HH" - case .MMddEE: + case .HHmm: + return "HH:mm" + case .EEMMdd: return "EE\nMM/dd" + case .MMddEE: + return "M월 d일 (EE)" } } } @@ -88,9 +94,10 @@ public extension NSTDateUtility { } extension NSTDateUtility { + ///타임테이블 뷰 : "요일 월/일" static func dateList(_ dateStrings: [String]) -> [String] { let formatter = NSTDateUtility(format: .yyyyMMddTHHmmss) // ISO 8601 형식 - let displayFormatter = NSTDateUtility(format: .MMddEE) // 출력 형식 + let displayFormatter = NSTDateUtility(format: .EEMMdd) // 출력 형식 return dateStrings.compactMap { dateString in switch formatter.date(from: dateString) { @@ -102,7 +109,8 @@ extension NSTDateUtility { } } } - + + ///타임테이블 뷰 : "00시" static func timeList(_ startTime: String, _ endTime: String) -> [String] { let formatter = NSTDateUtility(format: .yyyyMMddTHHmmss) // ISO 8601 형식 var result: [String] = [] @@ -126,4 +134,20 @@ extension NSTDateUtility { } return result } + + static func durationList(_ startTime: String, _ endTime: String) -> String { + let formatter = NSTDateUtility(format: .yyyyMMddTHHmmss) // ISO 8601 형식 + let dateFormatter = NSTDateUtility(format: .MMddEE) // "9월 7일 (일)" + let timeFormatter = NSTDateUtility(format: .HHmm) // "10:00" + + let startDateResult = formatter.date(from: startTime) + let endDateResult = formatter.date(from: endTime) + + guard case .success(let startDate) = startDateResult, + case .success(let endDate) = endDateResult else { + return "Invalid date format" + } + return "\(dateFormatter.string(from: startDate)) \(timeFormatter.string(from: startDate))~\(timeFormatter.string(from: endDate))" + } + } diff --git a/Noostak_iOS/Noostak_iOS/Presentation/Dummy/ViewController.swift b/Noostak_iOS/Noostak_iOS/Presentation/Dummy/ViewController.swift index 8ad6f11..601af3c 100644 --- a/Noostak_iOS/Noostak_iOS/Presentation/Dummy/ViewController.swift +++ b/Noostak_iOS/Noostak_iOS/Presentation/Dummy/ViewController.swift @@ -7,11 +7,9 @@ import UIKit -class ViewController: UIViewController { - +final class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. } - } diff --git a/Noostak_iOS/Noostak_iOS/Presentation/GroupDetail/Reactor/ConfirmedCellReactor.swift b/Noostak_iOS/Noostak_iOS/Presentation/GroupDetail/Reactor/ConfirmedCellReactor.swift new file mode 100644 index 0000000..09a4a95 --- /dev/null +++ b/Noostak_iOS/Noostak_iOS/Presentation/GroupDetail/Reactor/ConfirmedCellReactor.swift @@ -0,0 +1,22 @@ +// +// ConfirmedCellReactor.swift +// Noostak_iOS +// +// Created by 오연서 on 2/3/25. +// + +import ReactorKit +import RxSwift + +final class ConfirmedCellReactor: Reactor { + typealias Action = NoAction + struct State { + let schedule: ExtendedSchedule + } + + let initialState: State + + init(schedule: ExtendedSchedule) { + self.initialState = State(schedule: schedule) + } +} diff --git a/Noostak_iOS/Noostak_iOS/Presentation/GroupDetail/Reactor/GroupDetailReactor.swift b/Noostak_iOS/Noostak_iOS/Presentation/GroupDetail/Reactor/GroupDetailReactor.swift new file mode 100644 index 0000000..f8d2093 --- /dev/null +++ b/Noostak_iOS/Noostak_iOS/Presentation/GroupDetail/Reactor/GroupDetailReactor.swift @@ -0,0 +1,134 @@ +// +// GroupDetailReactor.swift +// Noostak_iOS +// +// Created by 오연서 on 1/31/25. +// + +import ReactorKit +import RxSwift +import UIKit + +final class GroupDetailReactor: Reactor { + enum Action { + case selectSegment(Int) // 세그먼트 선택 + case loadInProgressData + case loadConfirmedData + } + enum Mutation { + case setSelectedSegment(Int) + case setInProgressData([InProgressCellReactor]) + case setConfirmedData([ConfirmedCellReactor]) + } + struct State { + var selectedSegmentIndex: Int = 0 + var inProgressCellReactors: [InProgressCellReactor] = [] + var confirmedCellReactors: [ConfirmedCellReactor] = [] + } + + let initialState = State( + selectedSegmentIndex: 0, + inProgressCellReactors: mockInProgressData.map { InProgressCellReactor(schedule: $0) }, + confirmedCellReactors: mockConfirmedData.map { ConfirmedCellReactor(schedule: $0) } + ) + + func mutate(action: Action) -> Observable { + switch action { + case .selectSegment(let index): + return Observable.just(.setSelectedSegment(index)) + + case .loadInProgressData: + let reactors = mockInProgressData.map { InProgressCellReactor(schedule: $0) } + return Observable.just(.setInProgressData(reactors)) + + case .loadConfirmedData: + let reactors = mockConfirmedData.map { ConfirmedCellReactor(schedule: $0) } + return Observable.just(.setConfirmedData(reactors)) + } + } + + func reduce(state: State, mutation: Mutation) -> State { + var newState = state + switch mutation { + case .setSelectedSegment(let index): + newState.selectedSegmentIndex = index + + case .setInProgressData(let reactors): + newState.inProgressCellReactors = reactors + + case .setConfirmedData(let reactors): + newState.confirmedCellReactors = reactors + } + return newState + } +} + +let mockInProgressData: [ExtendedSchedule] = [ + ExtendedSchedule(schedule: Schedule(id: 1, + name: "뉴스탹진행중", + category: .hobby, + selectionDates: [], + selectionStartTime: Date(), + selectionEndTime: Date()), + date: "2024-09-05T10:00:00", + startTime: "2024-09-05T10:00:00", + endTime: "2024-09-05T18:00:00", + availableMembers: [], + unavailableMembers: [], + groupMemberCount: 24, + availableMemberCount: 11), + ExtendedSchedule(schedule: Schedule(id: 2, + name: "뉴스탹2", + category: .important, + selectionDates: [], + selectionStartTime: Date(), + selectionEndTime: Date()), + date: "2024-09-05T10:00:00", + startTime: "2024-09-05T10:00:00", + endTime: "2024-09-05T18:00:00", + availableMembers: [], + unavailableMembers: [], + groupMemberCount: 23, + availableMemberCount: 12), + ExtendedSchedule(schedule: Schedule(id: 1, + name: "뉴스탹3", + category: .hobby, + selectionDates: [], + selectionStartTime: Date(), + selectionEndTime: Date()), + date: "2024-09-05T10:00:00", + startTime: "2024-09-05T10:00:00", + endTime: "2024-09-05T18:00:00", + availableMembers: [], + unavailableMembers: [], + groupMemberCount: 24, + availableMemberCount: 11) +] +let mockConfirmedData: [ExtendedSchedule] = [ + ExtendedSchedule(schedule: Schedule(id: 1, + name: "뉴스탹유ㅏㄴ", + category: .hobby, + selectionDates: [], + selectionStartTime: Date(), + selectionEndTime: Date()), + date: "2024-09-05T10:00:00", + startTime: "2024-09-05T10:00:00", + endTime: "2024-09-05T18:00:00", + availableMembers: [], + unavailableMembers: [], + groupMemberCount: 24, + availableMemberCount: 11), + ExtendedSchedule(schedule: Schedule(id: 2, + name: "뉴스탹2", + category: .important, + selectionDates: [], + selectionStartTime: Date(), + selectionEndTime: Date()), + date: "2024-09-05T10:00:00", + startTime: "2024-09-05T10:00:00", + endTime: "2024-09-05T18:00:00", + availableMembers: [], + unavailableMembers: [], + groupMemberCount: 23, + availableMemberCount: 12) +] diff --git a/Noostak_iOS/Noostak_iOS/Presentation/GroupDetail/Reactor/InProgressCellReactor.swift b/Noostak_iOS/Noostak_iOS/Presentation/GroupDetail/Reactor/InProgressCellReactor.swift new file mode 100644 index 0000000..434f07e --- /dev/null +++ b/Noostak_iOS/Noostak_iOS/Presentation/GroupDetail/Reactor/InProgressCellReactor.swift @@ -0,0 +1,22 @@ +// +// InProgressCellReactor.swift +// Noostak_iOS +// +// Created by 오연서 on 2/3/25. +// + +import ReactorKit +import RxSwift + +final class InProgressCellReactor: Reactor { + typealias Action = NoAction + struct State { + let schedule: ExtendedSchedule + } + + let initialState: State + + init(schedule: ExtendedSchedule) { + self.initialState = State(schedule: schedule) + } +} diff --git a/Noostak_iOS/Noostak_iOS/Presentation/GroupDetail/View/Cell/ConfirmedCVC.swift b/Noostak_iOS/Noostak_iOS/Presentation/GroupDetail/View/Cell/ConfirmedCVC.swift new file mode 100644 index 0000000..b4166b4 --- /dev/null +++ b/Noostak_iOS/Noostak_iOS/Presentation/GroupDetail/View/Cell/ConfirmedCVC.swift @@ -0,0 +1,98 @@ +// +// ConfirmedCVC.swift +// Noostak_iOS +// +// Created by 오연서 on 2/1/25. +// + +import UIKit +import SnapKit +import Then +import ReactorKit + +final class ConfirmedCVC: UICollectionViewCell, View { + + // MARK: Properties + static let identifier = "ConfirmedCVC" + var disposeBag = DisposeBag() + + // MARK: Views + private let chip = UIView() + private let scheduleTitleLabel = UILabel() + private let timeLabel = UILabel() + private let divider = UIView() + + // MARK: Init + override init(frame: CGRect) { + super.init(frame: frame) + setUpHierarchy() + setUpUI() + setUpLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: setUpHierarchy + private func setUpHierarchy() { + [chip, scheduleTitleLabel, timeLabel, divider].forEach { + self.addSubview($0) + } + } + + // MARK: setUpUI + private func setUpUI() { + chip.do { + $0.layer.cornerRadius = 6.5 + } + + scheduleTitleLabel.do { + $0.font = .PretendardStyle.b1_sb.font + $0.textColor = .appGray900 + } + + timeLabel.do { + $0.font = .PretendardStyle.c3_r.font + $0.textColor = .appGray700 + } + + divider.do { + $0.backgroundColor = .appGray200 + } + } + + // MARK: setUpLayout + private func setUpLayout() { + chip.snp.makeConstraints { + $0.top.equalToSuperview().offset(15) + $0.leading.equalToSuperview().offset(6) + $0.size.equalTo(13) + } + + scheduleTitleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(10) + $0.leading.equalTo(chip.snp.trailing).offset(9) + } + + timeLabel.snp.makeConstraints { + $0.top.equalTo(scheduleTitleLabel.snp.bottom).offset(4) + $0.leading.equalTo(scheduleTitleLabel) + } + + divider.snp.makeConstraints { + $0.bottom.equalToSuperview() + $0.horizontalEdges.equalToSuperview() + $0.height.equalTo(1) + } + } +} + +extension ConfirmedCVC { + func bind(reactor: ConfirmedCellReactor) { + let schedule = reactor.currentState.schedule + chip.backgroundColor = schedule.schedule.category.displayColor + scheduleTitleLabel.text = schedule.schedule.name + timeLabel.text = NSTDateUtility.durationList(schedule.startTime, schedule.endTime) + } +} diff --git a/Noostak_iOS/Noostak_iOS/Presentation/GroupDetail/View/Cell/InProgressCVC.swift b/Noostak_iOS/Noostak_iOS/Presentation/GroupDetail/View/Cell/InProgressCVC.swift new file mode 100644 index 0000000..6f7acc7 --- /dev/null +++ b/Noostak_iOS/Noostak_iOS/Presentation/GroupDetail/View/Cell/InProgressCVC.swift @@ -0,0 +1,141 @@ +// +// InProgressCVC.swift +// Noostak_iOS +// +// Created by 오연서 on 2/1/25. +// + +import UIKit +import SnapKit +import Then +import ReactorKit + +final class InProgressCVC: UICollectionViewCell, View { + + // MARK: Properties + static let identifier = "InProgressCVC" + var disposeBag = DisposeBag() + + // MARK: Views + private let content = UIView() + private let scheduleTitleLabel = UILabel() + private let moveButton = UIButton() + private let timeLabel = UILabel() + private let availabilityLabel = UILabel() + private let backgroundProgressBar = UIView() + private let progressBar = UIView() + + // MARK: Init + override init(frame: CGRect) { + super.init(frame: frame) + setUpHierarchy() + setUpUI() + setUpLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: setUpHierarchy + private func setUpHierarchy() { + self.addSubview(content) + [scheduleTitleLabel, scheduleTitleLabel, moveButton, timeLabel, availabilityLabel, backgroundProgressBar, progressBar].forEach { + content.addSubview($0) + } + } + + // MARK: setUpUI + private func setUpUI() { + content.do { + $0.backgroundColor = .appWhite + $0.layer.borderColor = UIColor.appGray200.cgColor + $0.layer.cornerRadius = 15 + $0.layer.borderWidth = 1 + } + + scheduleTitleLabel.do { + $0.font = .PretendardStyle.b4_sb.font + $0.textColor = .appGray900 + } + + moveButton.do { + $0.setImage(.icArrowRight, for: .normal) + } + + timeLabel.do { + $0.font = .PretendardStyle.b5_r.font + $0.textColor = .appGray800 + } + + availabilityLabel.do { + $0.font = .PretendardStyle.b5_r.font + $0.textColor = .appGray700 + } + + backgroundProgressBar.do { + $0.backgroundColor = .appGray200 + $0.layer.cornerRadius = 2 + } + + progressBar.do { + $0.backgroundColor = .appBlue300 + $0.layer.cornerRadius = 2 + } + } + + // MARK: setUpLayout + private func setUpLayout() { + content.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + scheduleTitleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(19) + $0.leading.equalToSuperview().offset(16) + } + + moveButton.snp.makeConstraints { + $0.centerY.equalTo(scheduleTitleLabel) + $0.trailing.equalToSuperview().inset(22) + $0.size.equalTo(18) + } + + timeLabel.snp.makeConstraints { + $0.top.equalTo(scheduleTitleLabel.snp.bottom).offset(13) + $0.leading.equalTo(scheduleTitleLabel) + } + + availabilityLabel.snp.makeConstraints { + $0.centerY.equalTo(timeLabel) + $0.trailing.equalToSuperview().inset(16) + } + + backgroundProgressBar.snp.makeConstraints { + $0.top.equalTo(timeLabel.snp.bottom).offset(10) + $0.horizontalEdges.equalToSuperview().inset(16) + $0.height.equalTo(4) + } + + progressBar.snp.makeConstraints { + $0.top.equalTo(timeLabel.snp.bottom).offset(10) + $0.leading.equalTo(backgroundProgressBar) + $0.height.equalTo(4) + } + } +} + +extension InProgressCVC { + func bind(reactor: InProgressCellReactor) { + let schedule = reactor.currentState.schedule + scheduleTitleLabel.text = schedule.schedule.name + timeLabel.text = NSTDateUtility.durationList(schedule.startTime, schedule.endTime) + availabilityLabel.text = "\(schedule.availableMemberCount)명/\(schedule.groupMemberCount)명" + progressBar.snp.makeConstraints { + $0.top.equalTo(timeLabel.snp.bottom).offset(10) + $0.leading.equalTo(backgroundProgressBar) + $0.width.equalTo((Int(self.frame.width) - 32) * schedule.availableMemberCount/schedule.groupMemberCount) + $0.height.equalTo(4) + } + } +} diff --git a/Noostak_iOS/Noostak_iOS/Presentation/GroupDetail/View/GroupDetailView.swift b/Noostak_iOS/Noostak_iOS/Presentation/GroupDetail/View/GroupDetailView.swift new file mode 100644 index 0000000..478f568 --- /dev/null +++ b/Noostak_iOS/Noostak_iOS/Presentation/GroupDetail/View/GroupDetailView.swift @@ -0,0 +1,214 @@ +// +// GroupDetailView.swift +// Noostak_iOS +// +// Created by 오연서 on 1/31/25. +// + +import UIKit +import Then +import SnapKit +import RxSwift +import RxCocoa + +final class GroupDetailView: UIView { + + // MARK: Properties + private let disposeBag = DisposeBag() + + // MARK: Views + private let profileImageView = UIImageView() + private let groupNameLabel = UILabel() + private lazy var shareButton = UIButton() + private lazy var groupMemberButton = UIButton() + private let scheduleListLabel = UILabel() + let segmentedControl = UISegmentedControl(items: ["진행 중", "확정"]) + private let underlineView = UIView() + private let selectedUnderlineView = UIView() + let defaultLabel = UILabel() + var inProgressCollectionView: UICollectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + var confirmedCollectionView: UICollectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + + // MARK: Init + override init(frame: CGRect) { + super.init(frame: frame) + setUpFoundation() + setUpHierarchy() + setUpUI() + setUpLayout() + bindSegmentControl() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: setUpHierarchy + private func setUpHierarchy() { + [profileImageView, groupNameLabel, shareButton, groupMemberButton, scheduleListLabel, + segmentedControl, underlineView, selectedUnderlineView, + inProgressCollectionView, confirmedCollectionView, defaultLabel].forEach { + self.addSubview($0) + } + } + + private func setUpFoundation() { + self.backgroundColor = .appWhite + } + // MARK: setUpUI + private func setUpUI() { + profileImageView.do { + $0.layer.cornerRadius = 7.14 + $0.image = .icGroupDefault + } + + groupNameLabel.do { + $0.font = .PretendardStyle.h1_b.font + $0.textColor = .appGray900 + $0.text = "누스탁" + } + + shareButton.do { + $0.setImage(.icShare, for: .normal) + } + + groupMemberButton.do { + var config = UIButton.Configuration.filled() + config.baseBackgroundColor = .clear + config.baseForegroundColor = .appGray800 + config.attributedTitle = AttributedString("그룹 멤버 8", + attributes: AttributeContainer([.font: UIFont.PretendardStyle.b2_r.font])) + config.image = .icArrowRight + config.imagePlacement = .trailing + config.imagePadding = 5 + config.contentInsets = .zero + $0.configuration = config + + } + + scheduleListLabel.do { + $0.font = .PretendardStyle.b1_sb.font + $0.textColor = .appGray800 + $0.text = "약속 리스트" + } + + segmentedControl.do { + $0.selectedSegmentIndex = 0 + $0.selectedSegmentTintColor = .clear + $0.setBackgroundImage(UIImage(), for: .normal, barMetrics: .default) + $0.setDividerImage(UIImage(), forLeftSegmentState: .normal, rightSegmentState: .normal, barMetrics: .default) + $0.setTitleTextAttributes([NSAttributedString.Key.foregroundColor: UIColor.appGray900, + NSAttributedString.Key.font: UIFont.PretendardStyle.b1_sb.font], for: .selected) + $0.setTitleTextAttributes([NSAttributedString.Key.foregroundColor: UIColor.appGray500, + NSAttributedString.Key.font: UIFont.PretendardStyle.b1_sb.font], for: .normal) + } + + underlineView.do { + $0.backgroundColor = .appGray200 + $0.layer.cornerRadius = 2 + } + + selectedUnderlineView.do { + $0.backgroundColor = .appGray900 + $0.layer.cornerRadius = 2 + } + + defaultLabel.do { + $0.font = .PretendardStyle.b2_r.font + $0.textColor = .appGray900 + $0.isHidden = true + } + + inProgressCollectionView.do { + $0.isHidden = false + } + + confirmedCollectionView.do { + $0.isHidden = true + } + } + + // MARK: setUpLayout + private func setUpLayout() { + profileImageView.snp.makeConstraints { + $0.top.equalTo(self.safeAreaLayoutGuide).offset(21) + $0.leading.equalToSuperview().inset(16) + $0.size.equalTo(40) + } + + groupNameLabel.snp.makeConstraints { + $0.centerY.equalTo(profileImageView) + $0.leading.equalTo(profileImageView.snp.trailing).offset(8) + } + + shareButton.snp.makeConstraints { + $0.top.equalTo(profileImageView) + $0.trailing.equalToSuperview().inset(16) + $0.size.equalTo(24) + } + + groupMemberButton.snp.makeConstraints { + $0.top.equalTo(profileImageView.snp.bottom).offset(9) + $0.leading.equalTo(profileImageView) + } + + scheduleListLabel.snp.makeConstraints { + $0.top.equalTo(groupMemberButton.snp.bottom).offset(24) + $0.leading.equalToSuperview().inset(17) + } + + segmentedControl.snp.makeConstraints { + $0.top.equalTo(scheduleListLabel.snp.bottom).offset(25) + $0.horizontalEdges.equalToSuperview().inset(16) + $0.height.equalTo(22) + } + + underlineView.snp.makeConstraints { + $0.top.equalTo(segmentedControl.snp.bottom).offset(16) + $0.horizontalEdges.equalToSuperview().inset(16) + $0.height.equalTo(4) + } + + selectedUnderlineView.snp.makeConstraints { + $0.top.equalTo(segmentedControl.snp.bottom).offset(16) + $0.leading.equalTo(segmentedControl.snp.leading) + $0.width.equalTo(segmentedControl.snp.width).dividedBy(2) + $0.height.equalTo(4) + } + + defaultLabel.snp.makeConstraints { + $0.top.equalTo(selectedUnderlineView.snp.bottom).offset(142) + $0.centerX.equalToSuperview() + } + + inProgressCollectionView.snp.makeConstraints { + $0.top.equalTo(selectedUnderlineView.snp.bottom).offset(19) + $0.horizontalEdges.bottom.equalToSuperview() + $0.bottom.equalTo(self.safeAreaLayoutGuide) + } + + confirmedCollectionView.snp.makeConstraints { + $0.top.equalTo(selectedUnderlineView.snp.bottom).offset(13) + $0.horizontalEdges.equalToSuperview() + $0.bottom.equalTo(self.safeAreaLayoutGuide) + } + } + + // MARK: - Bind Methods + private func bindSegmentControl() { + segmentedControl.rx.selectedSegmentIndex + .subscribe(onNext: { [weak self] index in + guard let self = self else { return } + let segmentWidth = self.segmentedControl.frame.width / 2 + let leadingDistance = CGFloat(index) * segmentWidth + + UIView.animate(withDuration: 0.2) { + self.selectedUnderlineView.snp.updateConstraints { + $0.leading.equalTo(self.segmentedControl.snp.leading).offset(leadingDistance) + } + self.layoutIfNeeded() + } + }) + .disposed(by: disposeBag) + } +} diff --git a/Noostak_iOS/Noostak_iOS/Presentation/GroupDetail/ViewController/GroupDetailViewController.swift b/Noostak_iOS/Noostak_iOS/Presentation/GroupDetail/ViewController/GroupDetailViewController.swift new file mode 100644 index 0000000..bb6478e --- /dev/null +++ b/Noostak_iOS/Noostak_iOS/Presentation/GroupDetail/ViewController/GroupDetailViewController.swift @@ -0,0 +1,121 @@ +// +// GroupDetailViewController.swift +// Noostak_iOS +// +// Created by 오연서 on 1/31/25. +// + +import UIKit +import ReactorKit +import RxSwift +import RxCocoa +import RxDataSources + +final class GroupDetailViewController: UIViewController, View { + // MARK: - Properties + var disposeBag = DisposeBag() + private let rootView = GroupDetailView() + + // MARK: - Init + init(reactor: GroupDetailReactor) { + 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() + setUpCollectionView() + } + + private func setUpFoundation() { + self.view.backgroundColor = .white + } + + private func setUpCollectionView() { + rootView.inProgressCollectionView.register(InProgressCVC.self, forCellWithReuseIdentifier: InProgressCVC.identifier) + rootView.confirmedCollectionView.register(ConfirmedCVC.self, forCellWithReuseIdentifier: ConfirmedCVC.identifier) + rootView.inProgressCollectionView.rx.setDelegate(self).disposed(by: disposeBag) + rootView.confirmedCollectionView.rx.setDelegate(self).disposed(by: disposeBag) + } + + // MARK: - Bind Reactor + func bind(reactor: GroupDetailReactor) { + rootView.segmentedControl.rx.selectedSegmentIndex + .distinctUntilChanged() + .flatMapLatest { index -> Observable in + Observable.concat([ + .just(.selectSegment(index)), + .just(index == 0 ? .loadInProgressData : .loadConfirmedData) + ]) + } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + reactor.state + .subscribe(onNext: { [weak self] state in + guard let self = self else { return } + + let isProgress = (state.selectedSegmentIndex == 0) + self.rootView.inProgressCollectionView.isHidden = !isProgress + self.rootView.confirmedCollectionView.isHidden = isProgress + + let noData = isProgress ? state.inProgressCellReactors.isEmpty : state.confirmedCellReactors.isEmpty + self.rootView.defaultLabel.text = isProgress ? "현재 진행중인 약속이 없어요" : "아직 확정된 약속이 없어요" + self.rootView.defaultLabel.isHidden = !noData + }) + .disposed(by: disposeBag) + + // 진행 중 바인딩 + reactor.state.map { $0.inProgressCellReactors } + .observe(on: MainScheduler.instance) + .do(onNext: { _ in + self.rootView.inProgressCollectionView.reloadData() + }) + .bind(to: rootView.inProgressCollectionView.rx.items(cellIdentifier: InProgressCVC.identifier, cellType: InProgressCVC.self)) { _, reactor, cell in + cell.reactor = reactor + } + .disposed(by: disposeBag) + + // 확정된 약속 바인딩 + reactor.state.map { $0.confirmedCellReactors } + .do(onNext: { _ in self.rootView.confirmedCollectionView.reloadData() }) + .bind(to: rootView.confirmedCollectionView.rx.items(cellIdentifier: ConfirmedCVC.identifier, cellType: ConfirmedCVC.self)) { _, reactor, cell in + cell.reactor = reactor + } + .disposed(by: disposeBag) + } +} + +// MARK: - CollectionViewDelegateFlowLayout +extension GroupDetailViewController: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + switch collectionView { + case rootView.inProgressCollectionView: + return CGSize(width: UIScreen.main.bounds.width - 28, height: 105) + case rootView.confirmedCollectionView: + return CGSize(width: UIScreen.main.bounds.width - 32, height: 72) + default: + return CGSize() + } + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { + switch collectionView { + case rootView.inProgressCollectionView: + return 11 + case rootView.confirmedCollectionView: + return 2 + default: + return 0 + } + } +}