Skip to content

Commit 0103cc5

Browse files
taipaisechoijungp
andauthored
Feat: 제보기능 완료
* feat: 제보완료 VC 뷰 모델 추가 * Feat: 제보하기 로직 구현, 제보 히스토리 로직 구현 (#T3-204) * feat: NetworkService 로직 수정 - request 생성로직 수정. body에 jsonData 뿐 아니라, rawdata도 들어갈 수 있도록 수정 (s3 업로드 위함) - response body가 empty여도 error를 throw 하지 않도록 수정 * feat: 제보하기 로직 구현 * refactor: 제보하기 수정된 디자인 적용 * feat: 제보하기 필터링 로직 구현 * feat: 제보히스토리 -> 제보상세 플로우 미비사항 구현 - 진행상황 collectionView cell에 진행상황 별 갯수 표시 - ReportEntity id 값을 옵셔널로 변경 - ReportDetail 날짜 포멧 변경 * fix: 제보하기 로직 수정 * refactor: 코드래빗 리뷰 반영 * feat: 제보완료 VC 뷰 모델 추가 * Fix: UI 일부 레이아웃 수정 및 서버 Enum 값 수정 * Fix: PhotoURL 딕셔너리 제거 * Fix: Report UI Date 형식 수정 * Feat: 추천 루틴 탭 플로팅 버튼에 '제보하기' 진입점 추가 * Feat: ReportRegistrationViewController 키보드 내려가기 및 이미지 선택 일부 수정 --------- Co-authored-by: 최정인 <[email protected]>
1 parent 885a272 commit 0103cc5

File tree

8 files changed

+132
-57
lines changed

8 files changed

+132
-57
lines changed

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66
//
77

88
public enum ReportType: String, CaseIterable {
9-
case transportation
10-
case lamp
11-
case water
12-
case convenience
9+
case transportation = "TRANSPORTATION"
10+
case lamp = "LIGHTING"
11+
case water = "WATERFACILITY"
12+
case convenience = "AMENITY"
1313
}
1414

1515
extension ReportType: CustomStringConvertible {

Projects/Domain/Sources/UseCase/Report/ReportUseCase.swift

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,19 +53,15 @@ public final class ReportUseCase: ReportUseCaseProtocol {
5353
presignedDict.count == photos.count
5454
else { return nil }
5555

56-
let presignedURLs = fileNames.compactMap { fileName in
57-
presignedDict[fileName]
58-
}
59-
60-
for (url, photo) in zip(presignedURLs, photos) {
56+
for (url, photo) in zip(presignedDict.values, photos) {
6157
do {
6258
try await fileRepository.uploadFile(url: url, data: photo)
6359
} catch {
6460
print(error.localizedDescription)
6561
}
6662
}
6763

68-
let publicImageURLs = presignedURLs.map { url in
64+
let publicImageURLs = presignedDict.values.map { url in
6965
url.split(separator: "?", maxSplits: 1)
7066
.map(String.init)
7167
.first ?? url

Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineViewController.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,14 @@ extension RecommendedRoutineViewController: SelectableItemTableViewDelegate {
337337
extension RecommendedRoutineViewController: FloatingMenuViewDelegate {
338338
func floatingMenuDidTapReportButton(_ sender: FloatingMenuView) {
339339
toggleFloatingButton()
340-
// TODO: 제보하기 뷰로 이동
340+
341+
guard let reportRegistrationViewModel = DIContainer.shared.resolve(type: ReportRegistrationViewModel.self)
342+
else { fatalError("reportRegistrationViewController 의존성이 등록되지 않았습니다.") }
343+
344+
let reportRegistrationViewController = ReportRegistrationViewController(viewModel: reportRegistrationViewModel)
345+
reportRegistrationViewController.hidesBottomBarWhenPushed = true
346+
347+
self.navigationController?.pushViewController(reportRegistrationViewController, animated: true)
341348
}
342349

343350
func floatingMenuDidTapRegisterRoutineButton(_ sender: FloatingMenuView) {

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

Lines changed: 68 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
// Created by 최정인 on 11/19/25.
66
//
77

8+
import Combine
9+
import Shared
810
import SnapKit
911
import UIKit
1012

11-
final class ReportCompleteViewController: UIViewController {
13+
final class ReportCompleteViewController: BaseViewController<ReportDetailViewModel> {
1214
private enum Layout {
1315
static let horizontalMargin: CGFloat = 20
1416
static let completeImageViewTopSpacing: CGFloat = 78
@@ -65,15 +67,27 @@ final class ReportCompleteViewController: UIViewController {
6567
private let descriptionLabel = UILabel()
6668
private let photoStackView = UIStackView()
6769
private let confirmButton = PrimaryButton(buttonState: .default, buttonTitle: "확인")
70+
private let reportId: Int
71+
private var cancellables: Set<AnyCancellable> = []
72+
73+
init(viewModel: ReportDetailViewModel, reportId: Int) {
74+
self.reportId = reportId
75+
76+
super.init(viewModel: viewModel)
77+
}
78+
79+
required init?(coder: NSCoder) {
80+
fatalError("init(coder:) has not been implemented")
81+
}
6882

6983
override func viewDidLoad() {
7084
super.viewDidLoad()
7185
configureAttribute()
7286
configureLayout()
73-
fetchReport()
87+
viewModel.action(input: .fetchReportDetail(reportId: reportId))
7488
}
7589

76-
private func configureAttribute() {
90+
override func configureAttribute() {
7791
view.backgroundColor = .white
7892
scrollView.showsVerticalScrollIndicator = false
7993

@@ -106,12 +120,28 @@ final class ReportCompleteViewController: UIViewController {
106120

107121
confirmButton.addAction(
108122
UIAction { [weak self] _ in
109-
self?.navigationController?.popToRootViewController(animated: true)
123+
if
124+
let self,
125+
let tabBarController = self.tabBarController,
126+
let homeViewController = tabBarController.viewControllers?[0] as? UINavigationController,
127+
let recommendedRoutineViewController = tabBarController.viewControllers?[1] as? UINavigationController,
128+
let mypageViewController = tabBarController.viewControllers?[2] as? UINavigationController,
129+
let reportHistoryViewModel = DIContainer.shared.resolve(type: ReportHistoryViewModel.self) {
130+
131+
homeViewController.popToRootViewController(animated: false)
132+
recommendedRoutineViewController.popToRootViewController(animated: false)
133+
134+
tabBarController.selectedIndex = 2
135+
let reportHistoryViewController = ReportHistoryViewController(viewModel: reportHistoryViewModel)
136+
mypageViewController.pushViewController(reportHistoryViewController, animated: true)
137+
} else {
138+
self?.navigationController?.popToRootViewController(animated: true)
139+
}
110140
},
111141
for: .touchUpInside)
112142
}
113143

114-
private func configureLayout() {
144+
override func configureLayout() {
115145
let safeArea = view.safeAreaLayoutGuide
116146

117147
view.addSubview(scrollView)
@@ -123,10 +153,6 @@ final class ReportCompleteViewController: UIViewController {
123153

124154
backgroudView.addSubview(summaryStackView)
125155
summaryStackView.addArrangedSubview(summaryLabel)
126-
ReportCompleteContent.allCases.forEach { reportCompleteContentType in
127-
let contentStackView = makeContentView(contentType: reportCompleteContentType)
128-
summaryStackView.addArrangedSubview(contentStackView)
129-
}
130156

131157
scrollView.snp.makeConstraints { make in
132158
make.edges.equalTo(safeArea)
@@ -177,8 +203,37 @@ final class ReportCompleteViewController: UIViewController {
177203
}
178204
}
179205

180-
private func bind() {
181-
fetchReport()
206+
override func bind() {
207+
viewModel.output.reportDetailPublisher
208+
.receive(on: DispatchQueue.main)
209+
.sink(receiveValue: { [weak self] reportDetail in
210+
guard let reportDetail else { return }
211+
212+
self?.titleLabel.text = reportDetail.title
213+
self?.categoryLabel.text = reportDetail.category.name
214+
self?.locationLabel.text = reportDetail.location
215+
let descriptionText = reportDetail.description
216+
self?.descriptionLabel.numberOfLines = 0
217+
self?.descriptionLabel.attributedText = BitnagilFont(style: .body1, weight: .medium)
218+
.attributedString(text: descriptionText, alignment: .right)
219+
220+
ReportCompleteContent.allCases.forEach { reportCompleteContentType in
221+
let contentStackView = self?.makeContentView(contentType: reportCompleteContentType)
222+
self?.summaryStackView.addArrangedSubview(contentStackView ?? UIView())
223+
}
224+
225+
self?.photoStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
226+
for photoURL in reportDetail.photoUrls {
227+
guard
228+
let photoView = self?.makePhotoView(),
229+
let url = URL(string: photoURL)
230+
else { continue }
231+
232+
photoView.kf.setImage(with: url)
233+
self?.photoStackView.addArrangedSubview(photoView)
234+
}
235+
})
236+
.store(in: &cancellables)
182237
}
183238

184239
private func makeContentView(contentType: ReportCompleteContent) -> UIView {
@@ -200,7 +255,6 @@ final class ReportCompleteViewController: UIViewController {
200255
contentView = photoStackView
201256
} else {
202257
var contentLabel = UILabel()
203-
contentLabel.text = " "
204258
switch contentType {
205259
case .title:
206260
contentLabel = titleLabel
@@ -229,25 +283,8 @@ final class ReportCompleteViewController: UIViewController {
229283
return contentContainerView
230284
}
231285

232-
// TODO: 추후 ViewModel로 옮기기
233-
private func fetchReport() {
234-
titleLabel.text = "가로등이 깜박거려요."
235-
categoryLabel.text = "교통시설"
236-
locationLabel.text = "서울특별시 강남구 삼성동"
237-
let descriptionText = "150자 내용 채우기"
238-
descriptionLabel.attributedText = BitnagilFont(style: .body1, weight: .medium)
239-
.attributedString(text: descriptionText, alignment: .right)
240-
241-
let photoView1 = makePhotoView()
242-
let photoView2 = makePhotoView()
243-
let photoView3 = makePhotoView()
244-
[photoView1, photoView2, photoView3].forEach {
245-
photoStackView.addArrangedSubview($0)
246-
}
247-
}
248-
249-
private func makePhotoView() -> UIView {
250-
let photoView = UIView()
286+
private func makePhotoView() -> UIImageView {
287+
let photoView = UIImageView()
251288
photoView.backgroundColor = BitnagilColor.gray30
252289
photoView.layer.masksToBounds = true
253290
photoView.layer.cornerRadius = 6

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// Created by 최정인 on 11/18/25.
66
//
77

8+
import Shared
89
import SnapKit
910
import UIKit
1011

@@ -82,7 +83,10 @@ final class ReportLoadingViewController: UIViewController {
8283
extension ReportLoadingViewController: ReportRegistrationViewControllerDelegate {
8384
func reportRegistrationViewController(_ sender: ReportRegistrationViewController, completeRegistration reportId: Int?) {
8485
if let reportId {
85-
let reportCompleteViewController = ReportCompleteViewController()
86+
guard let reportDetailViewModel = DIContainer.shared.resolve(type: ReportDetailViewModel.self)
87+
else { fatalError("reportDetailViewModel 의존성이 등록되지 않았습니다.") }
88+
89+
let reportCompleteViewController = ReportCompleteViewController(viewModel: reportDetailViewModel, reportId: reportId)
8690
// TODO: - reportCompleteViewController에 제보id 전달 (또는 생성한 제보 객체 자체를 넘기기. 논의 필요)
8791
self.navigationController?.pushViewController(reportCompleteViewController, animated: true)
8892
} else {

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

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ final class ReportRegistrationViewController: BaseViewController<ReportRegistrat
101101
cameraButton.layer.masksToBounds = true
102102
cameraButton.addAction(
103103
UIAction { [weak self] _ in
104-
self?.showCameraBottomSheet()
104+
self?.viewModel.action(input: .checkSelectedPhotoCount)
105105
},
106106
for: .touchUpInside)
107107

@@ -129,6 +129,10 @@ final class ReportRegistrationViewController: BaseViewController<ReportRegistrat
129129

130130
photoSelectionView.delegate = self
131131
applySnapshot(items: [], animating: false)
132+
133+
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
134+
tapGesture.cancelsTouchesInView = false
135+
view.addGestureRecognizer(tapGesture)
132136
}
133137

134138
override func configureLayout() {
@@ -404,6 +408,21 @@ final class ReportRegistrationViewController: BaseViewController<ReportRegistrat
404408
}
405409
.store(in: &cancellables)
406410

411+
viewModel.output.selectedPhotoCountPublisher
412+
.receive(on: DispatchQueue.main)
413+
.sink { [weak self] photoCount in
414+
guard let self else { return }
415+
if photoCount >= self.viewModel.output.maxPhotoCount {
416+
let message = "사진은 최대 \(self.viewModel.output.maxPhotoCount)장까지 선택할 수 있습니다."
417+
let alertController = UIAlertController(title: "알림", message: message, preferredStyle: .alert)
418+
alertController.addAction(UIAlertAction(title: "확인", style: .default))
419+
self.present(alertController, animated: true)
420+
} else {
421+
self.showCameraBottomSheet()
422+
}
423+
}
424+
.store(in: &cancellables)
425+
407426
viewModel.output.isReportValid
408427
.receive(on: DispatchQueue.main)
409428
.sink { [weak self] isReportValid in
@@ -514,6 +533,10 @@ final class ReportRegistrationViewController: BaseViewController<ReportRegistrat
514533
}
515534
}
516535
}
536+
537+
@objc private func dismissKeyboard() {
538+
view.endEditing(true)
539+
}
517540
}
518541

519542
extension ReportRegistrationViewController: ReportTextViewDelegate {
@@ -595,19 +618,21 @@ extension ReportRegistrationViewController: UIImagePickerControllerDelegate, UIN
595618
extension ReportRegistrationViewController: PHPickerViewControllerDelegate {
596619
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
597620
picker.dismiss(animated: true, completion: nil)
598-
guard
599-
let itemProvider = results.first?.itemProvider,
600-
itemProvider.canLoadObject(ofClass: UIImage.self)
601-
else { return }
621+
guard !results.isEmpty else { return }
622+
623+
for result in results {
624+
guard result.itemProvider.canLoadObject(ofClass: UIImage.self)
625+
else { continue }
602626

603-
itemProvider.loadObject(ofClass: UIImage.self) { image, _ in
604-
guard
605-
let selectedImage = image as? UIImage,
606-
let imageData = selectedImage.jpegData(compressionQuality: 0.5)
607-
else { return }
627+
result.itemProvider.loadObject(ofClass: UIImage.self) { image, _ in
628+
guard
629+
let selectedImage = image as? UIImage,
630+
let imageData = selectedImage.jpegData(compressionQuality: 0.5)
631+
else { return }
608632

609-
DispatchQueue.main.async {
610-
self.viewModel.action(input: .selectPhoto(photoData: imageData))
633+
DispatchQueue.main.async {
634+
self.viewModel.action(input: .selectPhoto(photoData: imageData))
635+
}
611636
}
612637
}
613638
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ final class ReportRegistrationViewModel: ViewModel {
1717
case configureLocation
1818
case selectPhoto(photoData: Data)
1919
case removePhoto(id: UUID)
20+
case checkSelectedPhotoCount
2021
case register
2122
}
2223

@@ -26,6 +27,7 @@ final class ReportRegistrationViewModel: ViewModel {
2627
let contentPublisher: AnyPublisher<String?, Never>
2728
let locationPublisher: AnyPublisher<String?, Never>
2829
let selectedPhotoPublisher: AnyPublisher<[PhotoItem], Never>
30+
let selectedPhotoCountPublisher: AnyPublisher<Int, Never>
2931
let isReportValid: AnyPublisher<Bool, Never>
3032
let exceptionPublisher: AnyPublisher<String, Never>
3133
let reportRegistrationCompletePublisher: AnyPublisher<Int?, Never>
@@ -39,6 +41,7 @@ final class ReportRegistrationViewModel: ViewModel {
3941
private let contentSubject = CurrentValueSubject<String?, Never>(nil)
4042
private let locationSubject = CurrentValueSubject<String?, Never>(nil)
4143
private let selectedPhotoSubject = CurrentValueSubject<[PhotoItem], Never>([])
44+
private let selectedPhotoCountSubject = PassthroughSubject<Int, Never>()
4245
private let reportVerificationSubject = PassthroughSubject<Bool, Never>()
4346
private let reportRegistrationCompleteSubject = PassthroughSubject<Int?, Never>()
4447
private let exceptionSubject = PassthroughSubject<String, Never>()
@@ -55,6 +58,7 @@ final class ReportRegistrationViewModel: ViewModel {
5558
contentPublisher: contentSubject.eraseToAnyPublisher(),
5659
locationPublisher: locationSubject.eraseToAnyPublisher(),
5760
selectedPhotoPublisher: selectedPhotoSubject.eraseToAnyPublisher(),
61+
selectedPhotoCountPublisher: selectedPhotoCountSubject.eraseToAnyPublisher(),
5862
isReportValid: reportVerificationSubject.eraseToAnyPublisher(),
5963
exceptionPublisher: exceptionSubject.eraseToAnyPublisher(),
6064
reportRegistrationCompletePublisher: reportRegistrationCompleteSubject.eraseToAnyPublisher(),
@@ -75,6 +79,8 @@ final class ReportRegistrationViewModel: ViewModel {
7579
selectPhoto(photoData: photoData)
7680
case .removePhoto(let id):
7781
removePhoto(id: id)
82+
case .checkSelectedPhotoCount:
83+
selectedPhotoCountSubject.send(selectedPhotoSubject.value.count)
7884
case .register:
7985
register()
8086
}

Projects/Shared/Sources/Extension/Date+.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ extension Date {
4646
switch self {
4747
case .yearMonthDate: "yyyy-MM-dd"
4848
case .yearMonthDateShort: "yy.MM.dd"
49-
case .yearMonthDateWeek: "yyyy-MM-dd E"
50-
case .yearMonthDateWeek2: "yyyy-MM-dd (E)"
49+
case .yearMonthDateWeek: "yy.MM.dd E"
50+
case .yearMonthDateWeek2: "yyyy.MM.dd (E)"
5151
case .yearMonth: "yyyy년 M월"
5252
case .dayOfWeek: "E"
5353
case .date: "d"

0 commit comments

Comments
 (0)