Skip to content

Commit ef1fed3

Browse files
authored
Feat: 제보 상세화면 API 연동 (#T3-202)
* Feat: Report 관련 네트워크 구성요소 구현 (#T3-202) - ReportEndpoint, ReportDTO, ReportRepository, ReportRepositoryProtocol - ReportDetail 불러오기 기능 구현 * Feat: 제보 상세 화면 Domain - Presentation 연결 (#T3-202)
1 parent 31a247d commit ef1fed3

File tree

11 files changed

+174
-43
lines changed

11 files changed

+174
-43
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
//
2+
// ReportDTO.swift
3+
// DataSource
4+
//
5+
// Created by 최정인 on 11/20/25.
6+
//
7+
8+
import Domain
9+
10+
struct ReportDTO: Decodable {
11+
let reportId: Int?
12+
let reportDate: String?
13+
let reportTitle: String
14+
let reportContent: String?
15+
let reportLocation: String
16+
let reportStatus: String
17+
let reportCategory: String
18+
let reportImageUrl: String?
19+
let reportImageUrls: [String]?
20+
let latitude: Double?
21+
let longitude: Double?
22+
23+
func toReportEntity() throws -> ReportEntity {
24+
guard let reportId else { throw NetworkError.decodingError }
25+
return ReportEntity(
26+
id: reportId,
27+
title: reportTitle,
28+
date: reportDate,
29+
type: ReportType(rawValue: reportCategory) ?? .transportation,
30+
progress: ReportProgress(rawValue: reportStatus) ?? .received,
31+
content: reportContent,
32+
location: LocationEntity(
33+
longitude: longitude,
34+
latitude: latitude,
35+
address: reportLocation),
36+
photoUrls: reportImageUrls ?? [])
37+
}
38+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
//
2+
// ReportEndpoint.swift
3+
// DataSource
4+
//
5+
// Created by 최정인 on 11/20/25.
6+
//
7+
8+
enum ReportEndpoint {
9+
case fetchReportDetail(reportId: Int)
10+
}
11+
12+
extension ReportEndpoint: Endpoint {
13+
var baseURL: String {
14+
switch self {
15+
case .fetchReportDetail:
16+
return AppProperties.baseURL + "/api/v2/reports"
17+
}
18+
}
19+
20+
var path: String {
21+
switch self {
22+
case .fetchReportDetail(let reportId):
23+
"\(baseURL)/\(reportId)"
24+
}
25+
}
26+
27+
var method: HTTPMethod {
28+
switch self {
29+
case .fetchReportDetail:
30+
.get
31+
}
32+
}
33+
34+
var headers: [String : String] {
35+
let headers: [String: String] = [
36+
"Content-Type": "application/json",
37+
"accept": "*/*"
38+
]
39+
return headers
40+
}
41+
42+
var queryParameters: [String : String] {
43+
return [:]
44+
}
45+
46+
var bodyParameters: [String : Any] {
47+
return [:]
48+
}
49+
50+
var isAuthorized: Bool {
51+
return true
52+
}
53+
}

Projects/DataSource/Sources/Repository/ReportRepository.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@
88
import Domain
99

1010
final class ReportRepository: ReportRepositoryProtocol {
11-
func report(reportEntity: Domain.ReportEntity) async {
11+
private let networkService = NetworkService.shared
1212

13+
func report(reportEntity: ReportEntity) async {
14+
15+
}
16+
17+
func fetchReportDetail(reportId: Int) async throws -> ReportEntity? {
18+
let endpoint = ReportEndpoint.fetchReportDetail(reportId: reportId)
19+
guard let response = try await networkService.request(endpoint: endpoint, type: ReportDTO.self) else { return nil }
20+
return try response.toReportEntity()
1321
}
1422
}

Projects/Domain/Sources/Entity/Enum/ReportProgress.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@
55
// Created by 이동현 on 11/15/25.
66
//
77

8-
public enum ReportProgress: CaseIterable {
9-
case entire
10-
case received
11-
case inProgress
12-
case completed
8+
public enum ReportProgress: String, CaseIterable {
9+
case entire = "ENTIRE"
10+
case received = "PENDING"
11+
case inProgress = "IN_PROGRESS"
12+
case completed = "COMPLETED"
1313

1414
public var description: String {
1515
switch self {

Projects/Domain/Sources/Protocol/Repository/ReportRepositoryProtocol.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,9 @@
77

88
public protocol ReportRepositoryProtocol {
99
func report(reportEntity: ReportEntity) async
10+
11+
/// 제보 상세 기록을 조회합니다.
12+
/// - Parameter reportId: 조회할 제보의 ID
13+
/// - Returns: 조회된 제보
14+
func fetchReportDetail(reportId: Int) async throws -> ReportEntity?
1015
}

Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,10 @@ public struct PresentationDependencyAssembler: DependencyAssemblerProtocol {
134134
}
135135

136136
DIContainer.shared.register(type: ReportDetailViewModel.self) { container in
137-
return ReportDetailViewModel()
137+
guard let reportRepository = container.resolve(type: ReportRepositoryProtocol.self)
138+
else { fatalError("reportRepository 의존성이 등록되지 않았습니다.") }
139+
140+
return ReportDetailViewModel(reportRepository: reportRepository)
138141
}
139142
}
140143
}

Projects/Presentation/Sources/Home/View/HomeViewController.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -670,7 +670,7 @@ extension HomeViewController: FloatingMenuViewDelegate {
670670
guard let reportDetailViewModel = DIContainer.shared.resolve(type: ReportDetailViewModel.self)
671671
else { fatalError("reportDetailViewModel 의존성이 등록되지 않았습니다.") }
672672

673-
let reportDetailViewController = ReportDetailViewController(viewModel: reportDetailViewModel)
673+
let reportDetailViewController = ReportDetailViewController(viewModel: reportDetailViewModel, reportId: 1)
674674
reportDetailViewController.hidesBottomBarWhenPushed = true
675675

676676
self.navigationController?.pushViewController(reportDetailViewController, animated: true)

Projects/Presentation/Sources/Report/Model/ReportDetail.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import Domain
1010
struct ReportDetail {
1111
let date: String
1212
let title: String
13+
let status: ReportProgress
1314
let category: ReportType
1415
let description: String
1516
let location: String
17+
let photoUrls: [String]
1618
}

Projects/Presentation/Sources/Report/View/ReportDetailViewController.swift

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//
77

88
import Combine
9+
import Kingfisher
910
import SnapKit
1011
import UIKit
1112

@@ -20,8 +21,6 @@ class ReportDetailViewController: BaseViewController<ReportDetailViewModel> {
2021
static let contentStackViewTopSpacing: CGFloat = 23
2122
static let contentStackViewBottomSpacing: CGFloat = 40
2223
static let reportStatusViewTopSpacing: CGFloat = 20
23-
static let reportStatusViewWidth: CGFloat = 65
24-
static let reportStatusViewHeight: CGFloat = 26
2524
static let dateLabelTopSpacing: CGFloat = 6
2625
static let reportContentBackgroudViewTopSpacing: CGFloat = 8
2726
static let reportContentDescriptionVerticalMargin: CGFloat = 16
@@ -42,14 +41,14 @@ class ReportDetailViewController: BaseViewController<ReportDetailViewModel> {
4241
case .description:
4342
return "상세 제보 내용"
4443
case .location:
45-
return " 위치"
44+
return "신고 위치"
4645
}
4746
}
4847
}
4948

5049
private let scrollView = UIScrollView()
5150
private let contentView = UIView()
52-
private let reportStatusView = UIView()
51+
private let reportStatusView = ReportProgressView()
5352
private let dateLabel = UILabel()
5453
private let photoStackView = UIStackView()
5554
private let contentStackView = UIStackView()
@@ -58,21 +57,27 @@ class ReportDetailViewController: BaseViewController<ReportDetailViewModel> {
5857
private let detailDescriptionLabel = UILabel()
5958
private let locationLabel = UILabel()
6059
private var cancellables: Set<AnyCancellable> = []
60+
private let reportId: Int
61+
62+
init(viewModel: ReportDetailViewModel, reportId: Int) {
63+
self.reportId = reportId
64+
super.init(viewModel: viewModel)
65+
}
66+
67+
required init?(coder: NSCoder) {
68+
fatalError("init(coder:) has not been implemented")
69+
}
6170

6271
override func viewDidLoad() {
6372
super.viewDidLoad()
64-
viewModel.action(input: .fetchReportDetail)
73+
viewModel.action(input: .fetchReportDetail(reportId: reportId))
6574
}
6675

6776
override func configureAttribute() {
6877
view.backgroundColor = .white
6978
configureCustomNavigationBar(navigationBarStyle: .withBackButton(title: "제보하기"))
7079

7180
scrollView.showsVerticalScrollIndicator = false
72-
// TODO: 추후 공통 component로 교체
73-
reportStatusView.layer.masksToBounds = true
74-
reportStatusView.layer.cornerRadius = 6
75-
reportStatusView.backgroundColor = BitnagilColor.green10
7681

7782
dateLabel.font = BitnagilFont(style: .body1, weight: .semiBold).font
7883
dateLabel.textColor = BitnagilColor.gray10
@@ -110,8 +115,6 @@ class ReportDetailViewController: BaseViewController<ReportDetailViewModel> {
110115

111116
reportStatusView.snp.makeConstraints { make in
112117
make.top.equalTo(contentView).offset(Layout.reportStatusViewTopSpacing)
113-
make.width.equalTo(Layout.reportStatusViewWidth)
114-
make.height.equalTo(Layout.reportStatusViewHeight)
115118
make.leading.equalTo(contentView).offset(Layout.horizontalMargin)
116119
}
117120

@@ -196,19 +199,26 @@ class ReportDetailViewController: BaseViewController<ReportDetailViewModel> {
196199
private func fillReportContent(reportDetail: ReportDetail?) {
197200
guard let reportDetail else { return }
198201

199-
let photoView = UIView()
200-
photoView.backgroundColor = BitnagilColor.gray30
201-
photoView.layer.masksToBounds = true
202-
photoView.layer.cornerRadius = 9.25
203-
photoView.snp.makeConstraints { make in
204-
make.size.equalTo(Layout.photoSize)
205-
}
206-
photoStackView.addArrangedSubview(photoView)
207-
202+
reportStatusView.configure(with: reportDetail.status)
208203
dateLabel.text = reportDetail.date
209204
titleContentLabel.text = reportDetail.title
210205
categoryLabel.text = reportDetail.category.name
211-
detailDescriptionLabel.attributedText = BitnagilFont(style: .body2, weight: .medium).attributedString(text: reportDetail.description)
206+
detailDescriptionLabel.attributedText = BitnagilFont(style: .body2, weight: .medium)
207+
.attributedString(text: reportDetail.description)
212208
locationLabel.text = reportDetail.location
209+
210+
reportDetail.photoUrls.forEach { photoUrl in
211+
let photoView = UIImageView()
212+
213+
if let url = URL(string: photoUrl) {
214+
photoView.kf.setImage(with: url)
215+
}
216+
photoView.layer.masksToBounds = true
217+
photoView.layer.cornerRadius = 9.25
218+
photoView.snp.makeConstraints { make in
219+
make.size.equalTo(Layout.photoSize)
220+
}
221+
photoStackView.addArrangedSubview(photoView)
222+
}
213223
}
214224
}

Projects/Presentation/Sources/Report/ViewModel/ReportDetailViewModel.swift

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import Domain
1010

1111
final class ReportDetailViewModel: ViewModel {
1212
enum Input {
13-
case fetchReportDetail
13+
case fetchReportDetail(reportId: Int)
1414
}
1515

1616
struct Output {
@@ -19,28 +19,40 @@ final class ReportDetailViewModel: ViewModel {
1919

2020
private(set) var output: Output
2121
private let reportDetailSubject = CurrentValueSubject<ReportDetail?, Never>(nil)
22+
private let reportRepository: ReportRepositoryProtocol
2223

23-
init() {
24+
init(reportRepository: ReportRepositoryProtocol) {
25+
self.reportRepository = reportRepository
2426
self.output = Output(
2527
reportDetailPublisher: reportDetailSubject.eraseToAnyPublisher()
2628
)
2729
}
2830

2931
func action(input: Input) {
3032
switch input {
31-
case .fetchReportDetail:
32-
fetchReportDetail()
33+
case .fetchReportDetail(let reportId):
34+
fetchReportDetail(reportId: reportId)
3335
}
3436
}
3537

36-
private func fetchReportDetail() {
37-
let report = ReportDetail(
38-
date: "2025.11.03 (금)",
39-
title: "가로등이 깜빡거려요.",
40-
category: .water,
41-
description: "가로등이 깜박거리고 치지직 거려서 곧 터질 것 같아요... 햇살이 유리창 너머로 스며들며 방 안을 부드럽게 채운다. 커피 향이 퍼지고, 어제의 고민이 조금은 멀게 느껴진다. 오늘은 완벽하지 않아도 괜찮다. 천천히 숨을 고르고, 다시 한 걸음 내딛으면 된다. 이게 뭐람.",
42-
location: "서울특별시 강남구 삼성동")
43-
44-
reportDetailSubject.send(report)
38+
private func fetchReportDetail(reportId: Int) {
39+
Task {
40+
do {
41+
if let reportEntity = try await reportRepository.fetchReportDetail(reportId: reportId) {
42+
let reportDetail = ReportDetail(
43+
date: reportEntity.date ?? "",
44+
title: reportEntity.title,
45+
status: reportEntity.progress,
46+
category: reportEntity.type,
47+
description: reportEntity.content ?? "",
48+
location: reportEntity.location.address ?? "",
49+
photoUrls: reportEntity.photoUrls)
50+
reportDetailSubject.send(reportDetail)
51+
}
52+
reportDetailSubject.send(nil)
53+
} catch {
54+
reportDetailSubject.send(nil)
55+
}
56+
}
4557
}
4658
}

0 commit comments

Comments
 (0)