diff --git a/Projects/DataSource/Sources/Repository/LocationRepository.swift b/Projects/DataSource/Sources/Repository/LocationRepository.swift index 17fc547..f8695a8 100644 --- a/Projects/DataSource/Sources/Repository/LocationRepository.swift +++ b/Projects/DataSource/Sources/Repository/LocationRepository.swift @@ -37,7 +37,12 @@ final class LocationRepository: NSObject, LocationRepositoryProtocol { } func fetchAddress(coordinate: LocationEntity) async throws -> LocationEntity? { - let endpoint = LocationEndpoint.fetchAddress(longitude: coordinate.longitude, latitude: coordinate.latitude) + guard + let longitude = coordinate.longitude, + let latitude = coordinate.latitude + else { return nil } + + let endpoint = LocationEndpoint.fetchAddress(longitude: longitude, latitude: latitude) guard let response = try await networkService.request(endpoint: endpoint, type: KakaoLocationResponseDTO.self) else { return nil } diff --git a/Projects/Domain/Sources/Entity/Enum/ReportProgress.swift b/Projects/Domain/Sources/Entity/Enum/ReportProgress.swift new file mode 100644 index 0000000..9c39190 --- /dev/null +++ b/Projects/Domain/Sources/Entity/Enum/ReportProgress.swift @@ -0,0 +1,26 @@ +// +// ReportProgress.swift +// Domain +// +// Created by 이동현 on 11/15/25. +// + +public enum ReportProgress: CaseIterable { + case entire + case received + case inProgress + case completed + + public var description: String { + switch self { + case .entire: + "전체" + case .received: + "제보 완료" + case .inProgress: + "처리 중" + case .completed: + "처리 완료" + } + } +} diff --git a/Projects/Domain/Sources/Entity/LocationEntity.swift b/Projects/Domain/Sources/Entity/LocationEntity.swift index 6ea3053..158dc59 100644 --- a/Projects/Domain/Sources/Entity/LocationEntity.swift +++ b/Projects/Domain/Sources/Entity/LocationEntity.swift @@ -6,13 +6,13 @@ // public struct LocationEntity { - public let longitude: Double - public let latitude: Double + public let longitude: Double? + public let latitude: Double? public let address: String? public init( - longitude: Double, - latitude: Double, + longitude: Double?, + latitude: Double?, address: String? ) { self.longitude = longitude diff --git a/Projects/Domain/Sources/Entity/ReportEntity.swift b/Projects/Domain/Sources/Entity/ReportEntity.swift index 2469899..18b9c4c 100644 --- a/Projects/Domain/Sources/Entity/ReportEntity.swift +++ b/Projects/Domain/Sources/Entity/ReportEntity.swift @@ -6,5 +6,33 @@ // public struct ReportEntity { + public let id: Int + public let title: String + public let date: String? + public let type: ReportType + public let progress: ReportProgress + public let content: String? + public let location: LocationEntity + public let photoUrls: [String] + + public init( + id: Int, + title: String, + date: String?, + type: ReportType, + progress: ReportProgress, + content: String?, + location: LocationEntity, + photoUrls: [String] + ) { + self.id = id + self.title = title + self.date = date + self.type = type + self.progress = progress + self.content = content + self.location = location + self.photoUrls = photoUrls + } } diff --git a/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift b/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift index 449a454..69267ef 100644 --- a/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift +++ b/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift @@ -128,6 +128,11 @@ public struct PresentationDependencyAssembler: DependencyAssemblerProtocol { return ReportViewModel(reportUseCase: reportUseCase) } + DIContainer.shared + .register(type: ReportListHistoryViewModel.self) { container in + return ReportListHistoryViewModel() + } + DIContainer.shared.register(type: ReportDetailViewModel.self) { container in return ReportDetailViewModel() } diff --git a/Projects/Presentation/Sources/Report/Model/PhotoItem.swift b/Projects/Presentation/Sources/Report/Model/PhotoItem.swift index 140d487..15a6e2a 100644 --- a/Projects/Presentation/Sources/Report/Model/PhotoItem.swift +++ b/Projects/Presentation/Sources/Report/Model/PhotoItem.swift @@ -7,11 +7,11 @@ import Foundation -public struct PhotoItem: Hashable { - public let id: UUID - public let data: Data +struct PhotoItem: Hashable { + let id: UUID + let data: Data - public init(id: UUID = .init(), data: Data) { + init(id: UUID = .init(), data: Data) { self.id = id self.data = data } diff --git a/Projects/Presentation/Sources/Report/Model/ReportHistoryItem.swift b/Projects/Presentation/Sources/Report/Model/ReportHistoryItem.swift new file mode 100644 index 0000000..8c6b079 --- /dev/null +++ b/Projects/Presentation/Sources/Report/Model/ReportHistoryItem.swift @@ -0,0 +1,118 @@ +// +// ReportHistoryItem.swift +// Presentation +// +// Created by 이동현 on 11/19/25. +// + +import Domain + +struct ReportHistoryItem: Hashable { + let id: Int + let title: String + let thumbnailUrl: String + let date: String + let type: ReportType + let progress: ReportProgress + let location: String +} + +extension ReportHistoryItem { + static var dummyData: [ReportHistoryItem] { + let types = ReportType.allCases + let progresses = ReportProgress.allCases.filter { $0 != .entire } + + func makeItem( + id: Int, + title: String, + date: String, + location: String, + typeIndex: Int, + progressIndex: Int + ) -> ReportHistoryItem { + let type = types[typeIndex % types.count] + let progress = progresses[progressIndex % progresses.count] + + return ReportHistoryItem( + id: id, + title: title, + thumbnailUrl: "https://picsum.photos/200", + date: date, + type: type, + progress: progress, + location: location + ) + } + + return [ + makeItem( + id: 1, + title: "횡단보도 앞 가로등 불이 꺼져 있어요", + date: "2025-11-19월", + location: "서울시 강남구 삼성동", + typeIndex: 0, + progressIndex: 0 + ), + makeItem( + id: 2, + title: "지하철 역사 계단 난간이 파손되었어요", + date: "2025-11-19월", + location: "서울시 송파구 잠실동", + typeIndex: 1, + progressIndex: 1 + ), + + // 다른 날짜 + makeItem( + id: 3, + title: "공원 놀이터 바닥이 파여 있어 위험해요", + date: "2025-11-18월", + location: "서울시 마포구 상암동", + typeIndex: 2, + progressIndex: 2 + ), + makeItem( + id: 4, + title: "버스 정류장 안내판 조명이 나갔습니다", + date: "2025-11-18월", + location: "서울시 서초구 서초동", + typeIndex: 3, + progressIndex: 0 + ), + + makeItem( + id: 5, + title: "자전거 도로에 불법 주차된 차량이 있어요", + date: "2025-11-17월", + location: "서울시 노원구 공릉동", + typeIndex: 1, + progressIndex: 1 + ), + makeItem( + id: 6, + title: "보도블럭이 들떠서 걸려 넘어질 위험이 있어요", + date: "2025-11-16월", + location: "서울시 종로구 종로1가", + typeIndex: 0, + progressIndex: 2 + ), + + makeItem( + id: 7, + title: "교차로 신호등이 고장난 것 같습니다", + date: "2025-11-16월", + location: "서울시 동작구 사당동", + typeIndex: 2, + progressIndex: 0 + ), + makeItem( + id: 8, + title: "지하차도에 물이 고여 있어요", + date: "2025-11-15월", + location: "서울시 영등포구 영등포동", + typeIndex: 3, + progressIndex: 1 + ) + ] + } +} diff --git a/Projects/Presentation/Sources/Report/Model/ReportProgressItem.swift b/Projects/Presentation/Sources/Report/Model/ReportProgressItem.swift new file mode 100644 index 0000000..6e429d3 --- /dev/null +++ b/Projects/Presentation/Sources/Report/Model/ReportProgressItem.swift @@ -0,0 +1,16 @@ +// +// ReportProgressItem.swift +// Presentation +// +// Created by 이동현 on 11/17/25. +// + +import Domain +import Foundation + +struct ReportProgressItem: Hashable { + let uuid: UUID + let progress: ReportProgress + let count: Int + var isSelected: Bool +} diff --git a/Projects/Presentation/Sources/Report/View/Component/ReportCategoryTableViewCell.swift b/Projects/Presentation/Sources/Report/View/Component/ReportCategoryTableViewCell.swift index 700bcd7..bfe0e3f 100644 --- a/Projects/Presentation/Sources/Report/View/Component/ReportCategoryTableViewCell.swift +++ b/Projects/Presentation/Sources/Report/View/Component/ReportCategoryTableViewCell.swift @@ -106,13 +106,13 @@ extension ReportType { var iconImage: UIImage? { switch self { case .transportation: - BitnagilIcon.restIcon + BitnagilIcon.carIcon case .lamp: - BitnagilIcon.outsideIcon + BitnagilIcon.lightIcon case .water: - BitnagilIcon.wakeupIcon + BitnagilIcon.waterIcon case .convenience: - BitnagilIcon.growIcon + BitnagilIcon.hammerIcon } } } diff --git a/Projects/Presentation/Sources/Report/View/Component/ReportHistoryTableHeaderView.swift b/Projects/Presentation/Sources/Report/View/Component/ReportHistoryTableHeaderView.swift new file mode 100644 index 0000000..aeba94c --- /dev/null +++ b/Projects/Presentation/Sources/Report/View/Component/ReportHistoryTableHeaderView.swift @@ -0,0 +1,73 @@ +// +// ReportHistoryTableHeaderView.swift +// Presentation +// +// Created by 이동현 on 11/15/25. +// + +import SnapKit +import UIKit + +final class ReportHistoryTableHeaderView: UITableViewHeaderFooterView { + private enum Layout { + static let labelHeight: CGFloat = 20 + static let weekLabelLeadingSpacing: CGFloat = 2 + } + + private let dateLabel = UILabel() + private let weekLabel = UILabel() + + override init(reuseIdentifier: String?) { + super.init(reuseIdentifier: reuseIdentifier) + + configureAttribute() + configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureAttribute() { + backgroundColor = .clear + + dateLabel.textColor = BitnagilColor.gray10 + dateLabel.font = BitnagilFont.init(style: .body2, weight: .semiBold).font + + weekLabel.textColor = BitnagilColor.gray40 + weekLabel.font = BitnagilFont.init(style: .body2, weight: .medium).font + } + + private func configureLayout() { + contentView.addSubview(dateLabel) + contentView.addSubview(weekLabel) + + dateLabel.snp.makeConstraints { make in + make.leading.equalToSuperview() + make.top.equalToSuperview() + make.height.equalTo(Layout.labelHeight) + } + + weekLabel.snp.makeConstraints { make in + make.top.equalToSuperview() + + make.leading + .equalTo(dateLabel.snp.trailing) + .offset(Layout.weekLabelLeadingSpacing) + + make.height.equalTo(Layout.labelHeight) + } + } + + func configure(with dateString: String) { + guard let weekCharacter = dateString.last else { + dateLabel.text = dateString + weekLabel.text = nil + return + } + + let datePart = String(dateString.dropLast()) + dateLabel.text = datePart + weekLabel.text = String(weekCharacter) + } +} diff --git a/Projects/Presentation/Sources/Report/View/Component/ReportHistoryTableViewCell.swift b/Projects/Presentation/Sources/Report/View/Component/ReportHistoryTableViewCell.swift new file mode 100644 index 0000000..e3b6fa4 --- /dev/null +++ b/Projects/Presentation/Sources/Report/View/Component/ReportHistoryTableViewCell.swift @@ -0,0 +1,230 @@ +// +// ReportHistoryTableViewCell.swift +// Presentation +// +// Created by 이동현 on 11/15/25. +// + +import Domain +import SnapKit +import UIKit + +final class ReportHistoryTableViewCell: UITableViewCell { + private enum Layout { + static let horizontalSpacing: CGFloat = 16 + static let verticalSpacing: CGFloat = 14 + static let containerViewBottomSpacing: CGFloat = 10 + static let photoSize: CGFloat = 74 + static let progressViewHeight: CGFloat = 26 + static let progressLabelHeight: CGFloat = 18 + static let progressLabelHorizontalSpacing: CGFloat = 10 + static let progressLabelVerticalSpacing: CGFloat = 4 + static let titleLabelMaxHeight: CGFloat = 40 + static let titleLabelTopSpacing: CGFloat = 8 + static let titleLabelTrailingSpacing: CGFloat = 14 + static let categoryLabelWidth: CGFloat = 48 + static let categoryLabelTopSpacing: CGFloat = 12 + static let dotViewHorizontalSpacing: CGFloat = 6 + static let dotViewSize: CGFloat = 4 + static let containerViewCornerRadius: CGFloat = 12 + } + + private let containerView = UIView() + private let progressView = UIView() + private let progressLabel = UILabel() + private let titleLabel = UILabel() + private let categoryLabel = UILabel() + private let dotView = UIView() + private let addressLabel = UILabel() + private let photoImageView = UIImageView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + configureLayout() + configureAttribute() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + private func configureAttribute() { + backgroundColor = .clear + containerView.backgroundColor = .white + containerView.layer.cornerRadius = Layout.containerViewCornerRadius + containerView.layer.masksToBounds = true + + progressView.layer.cornerRadius = 6 + progressView.layer.masksToBounds = true + + photoImageView.layer.cornerRadius = 9.25 + photoImageView.layer.masksToBounds = true + + titleLabel.textColor = BitnagilColor.gray10 + titleLabel.font = BitnagilFont.init(style: .body2, weight: .semiBold).font + titleLabel.textAlignment = .left + + categoryLabel.textColor = BitnagilColor.gray50 + categoryLabel.font = BitnagilFont.init(style: .caption1, weight: .semiBold).font + + addressLabel.textColor = BitnagilColor.gray50 + addressLabel.font = BitnagilFont.init(style: .caption1, weight: .medium).font + + dotView.backgroundColor = BitnagilColor.gray90 + dotView.layer.cornerRadius = Layout.dotViewSize / 2 + dotView.layer.masksToBounds = true + } + + private func configureLayout() { + addSubview(containerView) + containerView.addSubview(progressView) + containerView.addSubview(progressLabel) + containerView.addSubview(photoImageView) + containerView.addSubview(titleLabel) + containerView.addSubview(categoryLabel) + containerView.addSubview(dotView) + containerView.addSubview(addressLabel) + + containerView.snp.makeConstraints { make in + make.top.horizontalEdges.equalToSuperview() + + make.bottom + .equalToSuperview() + .offset(-Layout.containerViewBottomSpacing) + } + + progressView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Layout.horizontalSpacing) + make.top.equalToSuperview().offset(Layout.verticalSpacing) + make.height.equalTo(Layout.progressViewHeight) + } + + progressLabel.snp.makeConstraints { make in + make.height.equalTo(Layout.progressLabelHeight) + + make.verticalEdges + .equalTo(progressView) + .inset(Layout.progressLabelVerticalSpacing) + + make.horizontalEdges + .equalTo(progressView) + .inset(Layout.progressLabelHorizontalSpacing) + } + + photoImageView.snp.makeConstraints { make in + make.verticalEdges + .equalToSuperview() + .inset(Layout.verticalSpacing) + + make.trailing + .equalToSuperview() + .offset(-Layout.horizontalSpacing) + + make.size.equalTo(Layout.photoSize) + } + + titleLabel.snp.makeConstraints { make in + make.top + .equalTo(progressView.snp.bottom) + .offset(Layout.titleLabelTopSpacing) + + make.leading + .equalToSuperview() + .offset(Layout.horizontalSpacing) + + make.trailing + .equalTo(photoImageView.snp.leading) + .offset(-Layout.titleLabelTrailingSpacing) + } + + categoryLabel.snp.makeConstraints { make in + make.leading + .equalToSuperview() + .offset(Layout.horizontalSpacing) + + make.top + .equalTo(titleLabel.snp.bottom) + .offset(Layout.categoryLabelTopSpacing) + + make.bottom + .equalToSuperview() + .offset(-Layout.verticalSpacing) + + make.width + .equalTo(Layout.categoryLabelWidth) + .priority(.medium) + } + + dotView.snp.makeConstraints { make in + make.size.equalTo(Layout.dotViewSize) + + make.centerY.equalTo(categoryLabel) + + make.leading + .equalTo(categoryLabel.snp.trailing) + .offset(Layout.dotViewHorizontalSpacing) + } + + addressLabel.snp.makeConstraints { make in + make.centerY.equalTo(categoryLabel) + + make.leading + .equalTo(dotView.snp.trailing) + .offset(Layout.dotViewHorizontalSpacing) + + make.trailing + .equalTo(titleLabel.snp.trailing) + } + } + + func configure(with item: ReportHistoryItem) { + progressView.backgroundColor = item.progress.backgroundColor + + progressLabel.textColor = item.progress.titleColor + progressLabel.text = item.progress.description + progressLabel.font = BitnagilFont.init(style: .caption1, weight: .semiBold).font + + titleLabel.text = item.title + + categoryLabel.text = item.type.name + + + addressLabel.text = item.location + + guard let imageURL = URL(string: item.thumbnailUrl) else { + return + } + + photoImageView.kf.setImage(with: imageURL) + } +} + +extension ReportProgress { + var backgroundColor: UIColor? { + switch self { + case .received: + BitnagilColor.green10 + case .inProgress: + BitnagilColor.skyblue10 + case .completed: + BitnagilColor.gray95 + case .entire: + nil + } + } + + var titleColor: UIColor? { + switch self { + case .received: + BitnagilColor.green500 + case .inProgress: + BitnagilColor.lightBlue300 + case .completed: + BitnagilColor.gray40 + case .entire: + nil + } + } +} diff --git a/Projects/Presentation/Sources/Report/View/Component/ReportPhotoCollectionViewCell.swift b/Projects/Presentation/Sources/Report/View/Component/ReportPhotoCollectionViewCell.swift index 2cfbb05..3aa1252 100644 --- a/Projects/Presentation/Sources/Report/View/Component/ReportPhotoCollectionViewCell.swift +++ b/Projects/Presentation/Sources/Report/View/Component/ReportPhotoCollectionViewCell.swift @@ -82,7 +82,7 @@ final class ReportPhotoCollectionViewCell: UICollectionViewCell { } } - func configure(item: PhotoItem) { + func configure(with item: PhotoItem) { imageView.image = UIImage(data: item.data) uuid = item.id } diff --git a/Projects/Presentation/Sources/Report/View/Component/ReportProgressCollectionViewCell.swift b/Projects/Presentation/Sources/Report/View/Component/ReportProgressCollectionViewCell.swift new file mode 100644 index 0000000..296badc --- /dev/null +++ b/Projects/Presentation/Sources/Report/View/Component/ReportProgressCollectionViewCell.swift @@ -0,0 +1,70 @@ +// +// ReportHistoryCategoryCollectionViewCell.swift +// Presentation +// +// Created by 이동현 on 11/15/25. +// + +import Domain +import SnapKit +import UIKit + +final class ReportProgressCollectionViewCell: UICollectionViewCell { + private enum Layout { + static let labelHorizontalSpacing: CGFloat = 14 + static let labelVerticalSpacing: CGFloat = 9 + static let cornerRadius: CGFloat = 18 + } + + private let titleLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + + configureAttribute() + configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureAttribute() { + titleLabel.font = BitnagilFont.init( + style: .caption1, + weight: .semiBold + ).font + + contentView.layer.cornerRadius = Layout.cornerRadius + contentView.layer.masksToBounds = true + } + + private func configureLayout() { + contentView.addSubview(titleLabel) + + titleLabel.snp.makeConstraints { make in + make.horizontalEdges + .equalToSuperview() + .inset(Layout.labelHorizontalSpacing) + + make.verticalEdges + .equalToSuperview() + .inset(Layout.labelVerticalSpacing) + } + } + + func configure(with item: ReportProgressItem) { + let countText: String + + if item.count != 0 { + countText = " \(item.count)" + } else { + countText = "" + } + + titleLabel.text = "\(item.progress.description)\(countText)" + + contentView.backgroundColor = item.isSelected ? BitnagilColor.gray10 : .white + titleLabel.textColor = item.isSelected ? .white : BitnagilColor.gray60 + } +} diff --git a/Projects/Presentation/Sources/Report/View/ReportListHistoryViewController.swift b/Projects/Presentation/Sources/Report/View/ReportListHistoryViewController.swift new file mode 100644 index 0000000..7cf5202 --- /dev/null +++ b/Projects/Presentation/Sources/Report/View/ReportListHistoryViewController.swift @@ -0,0 +1,292 @@ +// +// ReportHistoryViewController.swift +// Presentation +// +// Created by 이동현 on 11/15/25. +// + +import Combine +import SnapKit +import UIKit + +final class ReportListHistoryViewController: BaseViewController { + private enum Layout { + static let horizontalSpacing: CGFloat = 20 + static let progressCollectionViewTopSpacing: CGFloat = 80 + static let progressCollectionViewHeight: CGFloat = 36 + static let progressCollectionViewWidth: CGFloat = 48 + static let progressCellSpacing: CGFloat = 8 + static let historyTableViewTopSpacing: CGFloat = 34 + static let historyTableFooterHeight: CGFloat = 24 + static let historyTableHeaderHeight: CGFloat = 30 + static let historyTableViewCellHeight: CGFloat = 122 + static let historyCellSpacing: CGFloat = 10 + static let categoryButtonLabelHeight: CGFloat = 20 + static let categoryButtonLabelWidth: CGFloat = 52 + static let categoryButtonTopSpacing: CGFloat = 2 + static let categoryButtonImageSize: CGFloat = 16 + static let categoryButtonImageLeadingSpacing: CGFloat = 5 + static let cetegoryButtonHeight: CGFloat = 40 + } + + private enum ProgressSection { + case main + } + + private let progressCollectionView = UICollectionView(frame: .zero, collectionViewLayout: .init()) + private let categoryLabel = UILabel() + private let categoryButtonImage = UIImageView() + private let categoryButton = UIButton() + private let historyTableView = UITableView(frame: .zero, style: .grouped) + private var progressDataSource: UICollectionViewDiffableDataSource? + private var historyDataSource: UITableViewDiffableDataSource? + private var cancellables: Set = [] + + override func viewDidLoad() { + super.viewDidLoad() + viewModel.action(input: .fetchReports) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + configureCustomNavigationBar(navigationBarStyle: .withBackButton(title: "내 제보 기록")) + } + + override func configureAttribute() { + view.backgroundColor = BitnagilColor.gray99 + + categoryLabel.textColor = BitnagilColor.gray40 + categoryLabel.font = BitnagilFont.init(style: .body2, weight: .medium).font + + categoryButton.backgroundColor = .clear + categoryButtonImage.image = BitnagilIcon + .chevronIcon(direction: .down)? + .withRenderingMode(.alwaysTemplate) + categoryButtonImage.tintColor = BitnagilColor.gray40 + + configureProgressCollectionView() + configureHistoryTableView() + } + + override func configureLayout() { + let safeArea = view.safeAreaLayoutGuide + + view.addSubview(progressCollectionView) + view.addSubview(historyTableView) + view.addSubview(categoryLabel) + view.addSubview(categoryButtonImage) + view.addSubview(categoryButton) + + progressCollectionView.snp.makeConstraints { make in + make.top + .equalTo(safeArea.snp.top) + .offset(Layout.progressCollectionViewTopSpacing) + + make.horizontalEdges + .equalToSuperview() + .inset(Layout.horizontalSpacing) + + make.height.equalTo(Layout.progressCollectionViewHeight) + } + + historyTableView.snp.makeConstraints { make in + make.horizontalEdges + .equalToSuperview() + .inset(Layout.horizontalSpacing) + + make.top + .equalTo(progressCollectionView.snp.bottom) + .offset(Layout.historyTableViewTopSpacing) + + make.bottom.equalToSuperview() + } + + categoryButtonImage.snp.makeConstraints { make in + make.top + .equalTo(historyTableView.snp.top) + .offset(Layout.categoryButtonTopSpacing) + + make.trailing + .equalToSuperview() + .offset(-Layout.horizontalSpacing) + + make.size.equalTo(Layout.categoryButtonImageSize) + } + + categoryLabel.snp.makeConstraints { make in + make.trailing + .equalTo(categoryButtonImage.snp.leading) + .offset(-Layout.categoryButtonImageLeadingSpacing) + + make.centerY.equalTo(categoryButtonImage) + } + + categoryButton.snp.makeConstraints { make in + make.centerY.equalTo(categoryButtonImage) + + make.leading.equalTo(categoryLabel.snp.leading) + + make.trailing + .equalToSuperview() + .offset(-Layout.horizontalSpacing) + + make.height.equalTo(Layout.cetegoryButtonHeight) + } + } + + override func bind() { + viewModel.output.categoryPublisher + .receive(on: DispatchQueue.main) + .sink(receiveValue: { reportTypes in + + }) + .store(in: &cancellables) + + viewModel.output.selectedCategoryPublisher + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] selectedCategory in + if let selectedCategory { + self?.categoryLabel.text = selectedCategory.name + } else { + self?.categoryLabel.text = "카테고리" + } + }) + .store(in: &cancellables) + + viewModel.output.progressPublisher + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] progresses in + self?.applyProgressSnapshot(items: progresses) + }) + .store(in: &cancellables) + + viewModel.output.reportsPublisher + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] reports in + self?.applyHistorySnapshot(reports: reports) + }) + .store(in: &cancellables) + + viewModel.output.selectedReportPublisher + .receive(on: DispatchQueue.main) + .sink(receiveValue: { selectedReport in + + }) + .store(in: &cancellables) + } + + private func configureProgressCollectionView() { + progressCollectionView.backgroundColor = .clear + progressCollectionView.bounces = false + progressCollectionView.showsHorizontalScrollIndicator = false + + progressCollectionView.setCollectionViewLayout(createProgressLayout(), animated: false) + + progressCollectionView.register(ReportProgressCollectionViewCell.self, forCellWithReuseIdentifier: ReportProgressCollectionViewCell.className) + + progressDataSource = UICollectionViewDiffableDataSource(collectionView: progressCollectionView) { collectionView, indexPath, item in + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ReportProgressCollectionViewCell.className, for: indexPath) as? ReportProgressCollectionViewCell else { return UICollectionViewCell() } + + cell.configure(with: item) + return cell + } + + progressCollectionView.delegate = self + } + + private func createProgressLayout() -> UICollectionViewLayout { + let itemSize = NSCollectionLayoutSize( + widthDimension: .estimated(Layout.progressCollectionViewWidth), + heightDimension: .fractionalHeight(1.0) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let groupSize = NSCollectionLayoutSize(widthDimension: .estimated(Layout.progressCollectionViewWidth), heightDimension: .fractionalHeight(1.0)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + group.interItemSpacing = .fixed(Layout.progressCellSpacing) + + let section = NSCollectionLayoutSection(group: group) + section.orthogonalScrollingBehavior = .continuous + section.interGroupSpacing = Layout.progressCellSpacing + section.contentInsets = .zero + + return UICollectionViewCompositionalLayout(section: section) + } + + private func configureHistoryTableView() { + historyTableView.backgroundColor = .clear + historyTableView.separatorStyle = .none + historyTableView.showsVerticalScrollIndicator = false + historyTableView.rowHeight = UITableView.automaticDimension + historyTableView.estimatedRowHeight = Layout.historyTableViewCellHeight + historyTableView.sectionHeaderTopPadding = CGFloat.zero + historyTableView.sectionHeaderHeight = Layout.historyTableHeaderHeight + historyTableView.sectionFooterHeight = Layout.historyTableFooterHeight + + + historyTableView.register(ReportHistoryTableViewCell.self, forCellReuseIdentifier: ReportHistoryTableViewCell.className) + historyTableView.register(ReportHistoryTableHeaderView.self, forHeaderFooterViewReuseIdentifier: ReportHistoryTableHeaderView.className) + + historyDataSource = UITableViewDiffableDataSource(tableView: historyTableView) { tableView, indexPath, item in + guard let cell = tableView.dequeueReusableCell(withIdentifier: ReportHistoryTableViewCell.className, for: indexPath) as? ReportHistoryTableViewCell else { return UITableViewCell() } + + cell.configure(with: item) + return cell + } + + historyTableView.dataSource = historyDataSource + historyTableView.delegate = self + } + + private func applyProgressSnapshot(items: [ReportProgressItem]) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(items, toSection: .main) + print(items) + progressDataSource?.apply(snapshot, animatingDifferences: true) + } + + private func applyHistorySnapshot(reports: [ReportHistoryItem]) { + let reportsDictionary = Dictionary(grouping: reports) { $0.date ?? "" } + let sortedDates = reportsDictionary.keys.sorted(by: >) + + var snapshot = NSDiffableDataSourceSnapshot() + + for dateKey in sortedDates { + snapshot.appendSections([dateKey]) + if let items = reportsDictionary[dateKey] { + snapshot.appendItems(items, toSection: dateKey) + } + } + + historyDataSource?.apply(snapshot, animatingDifferences: true) + } +} + +extension ReportListHistoryViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard + let snapshot = progressDataSource?.snapshot(), + let item = snapshot.itemIdentifiers[indexPath.item] as? ReportProgressItem + else { return } + + viewModel.action(input: .filterProgress(progress: item.progress)) + } + +} + +extension ReportListHistoryViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard + let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: ReportHistoryTableHeaderView.className) as? ReportHistoryTableHeaderView, + let snapshot = historyDataSource?.snapshot() + else { return nil } + + guard section < snapshot.sectionIdentifiers.count else { return nil } + + let dateString = snapshot.sectionIdentifiers[section] + + header.configure(with: dateString) + + return header + } +} diff --git a/Projects/Presentation/Sources/Report/View/ReportViewController.swift b/Projects/Presentation/Sources/Report/View/ReportViewController.swift index 221d35f..60d2914 100644 --- a/Projects/Presentation/Sources/Report/View/ReportViewController.swift +++ b/Projects/Presentation/Sources/Report/View/ReportViewController.swift @@ -372,7 +372,7 @@ final class ReportViewController: BaseViewController { private func configureCollectionViewDataSource() { let registration = UICollectionView.CellRegistration { cell, _, item in - cell.configure(item: item) + cell.configure(with: item) cell.delegate = self } diff --git a/Projects/Presentation/Sources/Report/ViewModel/ReportListHistoryViewModel.swift b/Projects/Presentation/Sources/Report/ViewModel/ReportListHistoryViewModel.swift new file mode 100644 index 0000000..488dced --- /dev/null +++ b/Projects/Presentation/Sources/Report/ViewModel/ReportListHistoryViewModel.swift @@ -0,0 +1,112 @@ +// +// ReportHistoryViewModel.swift +// Presentation +// +// Created by 이동현 on 11/15/25. +// + +import Combine +import Domain +import Foundation + +final class ReportListHistoryViewModel: ViewModel { + enum Input { + case fetchReports + case fetchReport(index: Int) + case filterCategory(type: ReportType) + case filterProgress(progress: ReportProgress) + } + + struct Output { + let progressPublisher: AnyPublisher<[ReportProgressItem], Never> + let categoryPublisher: AnyPublisher<[ReportType], Never> + let selectedCategoryPublisher: AnyPublisher + let reportsPublisher: AnyPublisher<[ReportHistoryItem], Never> + let selectedReportPublisher: AnyPublisher + } + + private(set) var output: Output + private let progressSubject = CurrentValueSubject<[ReportProgressItem], Never>([]) + private let categorySubject = CurrentValueSubject<[ReportType], Never>([]) + private let selectedCategorySubject = CurrentValueSubject(nil) + private let reportSubject = CurrentValueSubject<[ReportHistoryItem], Never>([]) + private let selectedReportSubject = PassthroughSubject() + private var reports: [ReportHistoryItem] = [] + + init() { + progressSubject + .send( + ReportProgress.allCases.map { ReportProgressItem( + uuid: UUID(), + progress: $0, + count: 0, + isSelected: $0 == .entire)}) + categorySubject.send(ReportType.allCases) + + output = Output( + progressPublisher: progressSubject.eraseToAnyPublisher(), + categoryPublisher: categorySubject.eraseToAnyPublisher(), + selectedCategoryPublisher: selectedCategorySubject.eraseToAnyPublisher(), + reportsPublisher: reportSubject.eraseToAnyPublisher(), + selectedReportPublisher: selectedReportSubject.eraseToAnyPublisher()) + } + + func action(input: Input) { + switch input { + case .fetchReports: + fetchReports() + case .fetchReport(let index): + fetchReport(index: index) + case .filterCategory(let type): + filterCategory(reportType: type) + case .filterProgress(let progress): + filterProgress(progress: progress) + } + } + + private func filterCategory(reportType: ReportType) { + let currentType: ReportType? + + if + let previousType = selectedCategorySubject.value, + previousType == reportType + { + currentType = nil + } else { + currentType = reportType + } + + selectedCategorySubject.send(currentType) + filterReports() + } + + private func filterProgress(progress: ReportProgress) { + var progressItems = progressSubject.value + + for i in 0..