diff --git a/Projects/DataSource/Sources/DTO/ReportDTO.swift b/Projects/DataSource/Sources/DTO/ReportDTO.swift new file mode 100644 index 0000000..99c3287 --- /dev/null +++ b/Projects/DataSource/Sources/DTO/ReportDTO.swift @@ -0,0 +1,38 @@ +// +// ReportDTO.swift +// DataSource +// +// Created by 최정인 on 11/20/25. +// + +import Domain + +struct ReportDTO: Decodable { + let reportId: Int? + let reportDate: String? + let reportTitle: String + let reportContent: String? + let reportLocation: String + let reportStatus: String + let reportCategory: String + let reportImageUrl: String? + let reportImageUrls: [String]? + let latitude: Double? + let longitude: Double? + + func toReportEntity() throws -> ReportEntity { + guard let reportId else { throw NetworkError.decodingError } + return ReportEntity( + id: reportId, + title: reportTitle, + date: reportDate, + type: ReportType(rawValue: reportCategory) ?? .transportation, + progress: ReportProgress(rawValue: reportStatus) ?? .received, + content: reportContent, + location: LocationEntity( + longitude: longitude, + latitude: latitude, + address: reportLocation), + photoUrls: reportImageUrls ?? []) + } +} diff --git a/Projects/DataSource/Sources/Endpoint/ReportEndpoint.swift b/Projects/DataSource/Sources/Endpoint/ReportEndpoint.swift new file mode 100644 index 0000000..489339d --- /dev/null +++ b/Projects/DataSource/Sources/Endpoint/ReportEndpoint.swift @@ -0,0 +1,53 @@ +// +// ReportEndpoint.swift +// DataSource +// +// Created by 최정인 on 11/20/25. +// + +enum ReportEndpoint { + case fetchReportDetail(reportId: Int) +} + +extension ReportEndpoint: Endpoint { + var baseURL: String { + switch self { + case .fetchReportDetail: + return AppProperties.baseURL + "/api/v2/reports" + } + } + + var path: String { + switch self { + case .fetchReportDetail(let reportId): + "\(baseURL)/\(reportId)" + } + } + + var method: HTTPMethod { + switch self { + case .fetchReportDetail: + .get + } + } + + var headers: [String : String] { + let headers: [String: String] = [ + "Content-Type": "application/json", + "accept": "*/*" + ] + return headers + } + + var queryParameters: [String : String] { + return [:] + } + + var bodyParameters: [String : Any] { + return [:] + } + + var isAuthorized: Bool { + return true + } +} diff --git a/Projects/DataSource/Sources/Repository/ReportRepository.swift b/Projects/DataSource/Sources/Repository/ReportRepository.swift index fc43c41..c8a57f8 100644 --- a/Projects/DataSource/Sources/Repository/ReportRepository.swift +++ b/Projects/DataSource/Sources/Repository/ReportRepository.swift @@ -8,7 +8,15 @@ import Domain final class ReportRepository: ReportRepositoryProtocol { - func report(reportEntity: Domain.ReportEntity) async { + private let networkService = NetworkService.shared + func report(reportEntity: ReportEntity) async { + + } + + func fetchReportDetail(reportId: Int) async throws -> ReportEntity? { + let endpoint = ReportEndpoint.fetchReportDetail(reportId: reportId) + guard let response = try await networkService.request(endpoint: endpoint, type: ReportDTO.self) else { return nil } + return try response.toReportEntity() } } diff --git a/Projects/Domain/Sources/Entity/Enum/ReportProgress.swift b/Projects/Domain/Sources/Entity/Enum/ReportProgress.swift index 9c39190..a620c1a 100644 --- a/Projects/Domain/Sources/Entity/Enum/ReportProgress.swift +++ b/Projects/Domain/Sources/Entity/Enum/ReportProgress.swift @@ -5,11 +5,11 @@ // Created by 이동현 on 11/15/25. // -public enum ReportProgress: CaseIterable { - case entire - case received - case inProgress - case completed +public enum ReportProgress: String, CaseIterable { + case entire = "ENTIRE" + case received = "PENDING" + case inProgress = "IN_PROGRESS" + case completed = "COMPLETED" public var description: String { switch self { diff --git a/Projects/Domain/Sources/Protocol/Repository/ReportRepositoryProtocol.swift b/Projects/Domain/Sources/Protocol/Repository/ReportRepositoryProtocol.swift index f4f4d11..9d0789b 100644 --- a/Projects/Domain/Sources/Protocol/Repository/ReportRepositoryProtocol.swift +++ b/Projects/Domain/Sources/Protocol/Repository/ReportRepositoryProtocol.swift @@ -7,4 +7,9 @@ public protocol ReportRepositoryProtocol { func report(reportEntity: ReportEntity) async + + /// 제보 상세 기록을 조회합니다. + /// - Parameter reportId: 조회할 제보의 ID + /// - Returns: 조회된 제보 + func fetchReportDetail(reportId: Int) async throws -> ReportEntity? } diff --git a/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift b/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift index 296529a..a7f33f6 100644 --- a/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift +++ b/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift @@ -134,7 +134,10 @@ public struct PresentationDependencyAssembler: DependencyAssemblerProtocol { } DIContainer.shared.register(type: ReportDetailViewModel.self) { container in - return ReportDetailViewModel() + guard let reportRepository = container.resolve(type: ReportRepositoryProtocol.self) + else { fatalError("reportRepository 의존성이 등록되지 않았습니다.") } + + return ReportDetailViewModel(reportRepository: reportRepository) } } } diff --git a/Projects/Presentation/Sources/Home/View/HomeViewController.swift b/Projects/Presentation/Sources/Home/View/HomeViewController.swift index 1d586b0..6fcc022 100644 --- a/Projects/Presentation/Sources/Home/View/HomeViewController.swift +++ b/Projects/Presentation/Sources/Home/View/HomeViewController.swift @@ -670,7 +670,7 @@ extension HomeViewController: FloatingMenuViewDelegate { guard let reportDetailViewModel = DIContainer.shared.resolve(type: ReportDetailViewModel.self) else { fatalError("reportDetailViewModel 의존성이 등록되지 않았습니다.") } - let reportDetailViewController = ReportDetailViewController(viewModel: reportDetailViewModel) + let reportDetailViewController = ReportDetailViewController(viewModel: reportDetailViewModel, reportId: 1) reportDetailViewController.hidesBottomBarWhenPushed = true self.navigationController?.pushViewController(reportDetailViewController, animated: true) diff --git a/Projects/Presentation/Sources/Report/Model/ReportDetail.swift b/Projects/Presentation/Sources/Report/Model/ReportDetail.swift index be6c149..336c48d 100644 --- a/Projects/Presentation/Sources/Report/Model/ReportDetail.swift +++ b/Projects/Presentation/Sources/Report/Model/ReportDetail.swift @@ -10,7 +10,9 @@ import Domain struct ReportDetail { let date: String let title: String + let status: ReportProgress let category: ReportType let description: String let location: String + let photoUrls: [String] } diff --git a/Projects/Presentation/Sources/Report/View/ReportDetailViewController.swift b/Projects/Presentation/Sources/Report/View/ReportDetailViewController.swift index 22d73ca..80ed652 100644 --- a/Projects/Presentation/Sources/Report/View/ReportDetailViewController.swift +++ b/Projects/Presentation/Sources/Report/View/ReportDetailViewController.swift @@ -6,6 +6,7 @@ // import Combine +import Kingfisher import SnapKit import UIKit @@ -20,8 +21,6 @@ class ReportDetailViewController: BaseViewController { static let contentStackViewTopSpacing: CGFloat = 23 static let contentStackViewBottomSpacing: CGFloat = 40 static let reportStatusViewTopSpacing: CGFloat = 20 - static let reportStatusViewWidth: CGFloat = 65 - static let reportStatusViewHeight: CGFloat = 26 static let dateLabelTopSpacing: CGFloat = 6 static let reportContentBackgroudViewTopSpacing: CGFloat = 8 static let reportContentDescriptionVerticalMargin: CGFloat = 16 @@ -42,14 +41,14 @@ class ReportDetailViewController: BaseViewController { case .description: return "상세 제보 내용" case .location: - return "내 위치" + return "신고 위치" } } } private let scrollView = UIScrollView() private let contentView = UIView() - private let reportStatusView = UIView() + private let reportStatusView = ReportProgressView() private let dateLabel = UILabel() private let photoStackView = UIStackView() private let contentStackView = UIStackView() @@ -58,10 +57,20 @@ class ReportDetailViewController: BaseViewController { private let detailDescriptionLabel = UILabel() private let locationLabel = UILabel() private var cancellables: Set = [] + private let reportId: Int + + init(viewModel: ReportDetailViewModel, reportId: Int) { + self.reportId = reportId + super.init(viewModel: viewModel) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } override func viewDidLoad() { super.viewDidLoad() - viewModel.action(input: .fetchReportDetail) + viewModel.action(input: .fetchReportDetail(reportId: reportId)) } override func configureAttribute() { @@ -69,10 +78,6 @@ class ReportDetailViewController: BaseViewController { configureCustomNavigationBar(navigationBarStyle: .withBackButton(title: "제보하기")) scrollView.showsVerticalScrollIndicator = false - // TODO: 추후 공통 component로 교체 - reportStatusView.layer.masksToBounds = true - reportStatusView.layer.cornerRadius = 6 - reportStatusView.backgroundColor = BitnagilColor.green10 dateLabel.font = BitnagilFont(style: .body1, weight: .semiBold).font dateLabel.textColor = BitnagilColor.gray10 @@ -110,8 +115,6 @@ class ReportDetailViewController: BaseViewController { reportStatusView.snp.makeConstraints { make in make.top.equalTo(contentView).offset(Layout.reportStatusViewTopSpacing) - make.width.equalTo(Layout.reportStatusViewWidth) - make.height.equalTo(Layout.reportStatusViewHeight) make.leading.equalTo(contentView).offset(Layout.horizontalMargin) } @@ -196,19 +199,26 @@ class ReportDetailViewController: BaseViewController { private func fillReportContent(reportDetail: ReportDetail?) { guard let reportDetail else { return } - let photoView = UIView() - photoView.backgroundColor = BitnagilColor.gray30 - photoView.layer.masksToBounds = true - photoView.layer.cornerRadius = 9.25 - photoView.snp.makeConstraints { make in - make.size.equalTo(Layout.photoSize) - } - photoStackView.addArrangedSubview(photoView) - + reportStatusView.configure(with: reportDetail.status) dateLabel.text = reportDetail.date titleContentLabel.text = reportDetail.title categoryLabel.text = reportDetail.category.name - detailDescriptionLabel.attributedText = BitnagilFont(style: .body2, weight: .medium).attributedString(text: reportDetail.description) + detailDescriptionLabel.attributedText = BitnagilFont(style: .body2, weight: .medium) + .attributedString(text: reportDetail.description) locationLabel.text = reportDetail.location + + reportDetail.photoUrls.forEach { photoUrl in + let photoView = UIImageView() + + if let url = URL(string: photoUrl) { + photoView.kf.setImage(with: url) + } + photoView.layer.masksToBounds = true + photoView.layer.cornerRadius = 9.25 + photoView.snp.makeConstraints { make in + make.size.equalTo(Layout.photoSize) + } + photoStackView.addArrangedSubview(photoView) + } } } diff --git a/Projects/Presentation/Sources/Report/ViewModel/ReportDetailViewModel.swift b/Projects/Presentation/Sources/Report/ViewModel/ReportDetailViewModel.swift index 5d61fee..be661fd 100644 --- a/Projects/Presentation/Sources/Report/ViewModel/ReportDetailViewModel.swift +++ b/Projects/Presentation/Sources/Report/ViewModel/ReportDetailViewModel.swift @@ -10,7 +10,7 @@ import Domain final class ReportDetailViewModel: ViewModel { enum Input { - case fetchReportDetail + case fetchReportDetail(reportId: Int) } struct Output { @@ -19,8 +19,10 @@ final class ReportDetailViewModel: ViewModel { private(set) var output: Output private let reportDetailSubject = CurrentValueSubject(nil) + private let reportRepository: ReportRepositoryProtocol - init() { + init(reportRepository: ReportRepositoryProtocol) { + self.reportRepository = reportRepository self.output = Output( reportDetailPublisher: reportDetailSubject.eraseToAnyPublisher() ) @@ -28,19 +30,29 @@ final class ReportDetailViewModel: ViewModel { func action(input: Input) { switch input { - case .fetchReportDetail: - fetchReportDetail() + case .fetchReportDetail(let reportId): + fetchReportDetail(reportId: reportId) } } - private func fetchReportDetail() { - let report = ReportDetail( - date: "2025.11.03 (금)", - title: "가로등이 깜빡거려요.", - category: .water, - description: "가로등이 깜박거리고 치지직 거려서 곧 터질 것 같아요... 햇살이 유리창 너머로 스며들며 방 안을 부드럽게 채운다. 커피 향이 퍼지고, 어제의 고민이 조금은 멀게 느껴진다. 오늘은 완벽하지 않아도 괜찮다. 천천히 숨을 고르고, 다시 한 걸음 내딛으면 된다. 이게 뭐람.", - location: "서울특별시 강남구 삼성동") - - reportDetailSubject.send(report) + private func fetchReportDetail(reportId: Int) { + Task { + do { + if let reportEntity = try await reportRepository.fetchReportDetail(reportId: reportId) { + let reportDetail = ReportDetail( + date: reportEntity.date ?? "", + title: reportEntity.title, + status: reportEntity.progress, + category: reportEntity.type, + description: reportEntity.content ?? "", + location: reportEntity.location.address ?? "", + photoUrls: reportEntity.photoUrls) + reportDetailSubject.send(reportDetail) + } + reportDetailSubject.send(nil) + } catch { + reportDetailSubject.send(nil) + } + } } } diff --git a/Tuist.swift b/Tuist.swift index f6b0a35..c3ea6af 100644 --- a/Tuist.swift +++ b/Tuist.swift @@ -2,6 +2,6 @@ import ProjectDescription let tuist = Tuist( project: .tuist( - compatibleXcodeVersions: .upToNextMajor("16.0") + compatibleXcodeVersions: .all ) )