From abe4b88dc40c9fa767ec78ae34c6fdfaba58b49d Mon Sep 17 00:00:00 2001 From: taipaise Date: Sat, 22 Nov 2025 20:26:44 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=EC=A0=9C=EB=B3=B4=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20VC=20=EB=B7=B0=20=EB=AA=A8=EB=8D=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../View/ReportCompleteViewController.swift | 68 ++++++++++++------- 1 file changed, 43 insertions(+), 25 deletions(-) diff --git a/Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift b/Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift index 9da9358..8d348e8 100644 --- a/Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift +++ b/Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift @@ -5,10 +5,11 @@ // Created by 최정인 on 11/19/25. // +import Combine import SnapKit import UIKit -final class ReportCompleteViewController: UIViewController { +final class ReportCompleteViewController: BaseViewController { private enum Layout { static let horizontalMargin: CGFloat = 20 static let completeImageViewTopSpacing: CGFloat = 78 @@ -65,15 +66,27 @@ final class ReportCompleteViewController: UIViewController { private let descriptionLabel = UILabel() private let photoStackView = UIStackView() private let confirmButton = PrimaryButton(buttonState: .default, buttonTitle: "확인") + private let reportId: Int + private var cancellables: Set = [] + + 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() configureAttribute() configureLayout() - fetchReport() + viewModel.action(input: .fetchReportDetail(reportId: reportId)) } - private func configureAttribute() { + override func configureAttribute() { view.backgroundColor = .white scrollView.showsVerticalScrollIndicator = false @@ -105,7 +118,7 @@ final class ReportCompleteViewController: UIViewController { photoStackView.spacing = Layout.photoStackViewSpacing } - private func configureLayout() { + override func configureLayout() { let safeArea = view.safeAreaLayoutGuide view.addSubview(scrollView) @@ -171,8 +184,30 @@ final class ReportCompleteViewController: UIViewController { } } - private func bind() { - fetchReport() + override func bind() { + viewModel.output.reportDetailPublisher + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] reportDetail in + guard let reportDetail else { return } + + self?.titleLabel.text = reportDetail.title + self?.categoryLabel.text = reportDetail.category.name + self?.locationLabel.text = reportDetail.location + let descriptionText = reportDetail.description + self?.descriptionLabel.attributedText = BitnagilFont(style: .body1, weight: .medium) + .attributedString(text: descriptionText, alignment: .right) + + for photoURL in reportDetail.photoUrls { + guard + let photoView = self?.makePhotoView(), + let url = URL(string: photoURL) + else { continue } + + photoView.kf.setImage(with: url) + self?.photoStackView.addArrangedSubview(photoView) + } + }) + .store(in: &cancellables) } private func makeContentView(contentType: ReportCompleteContent) -> UIView { @@ -223,25 +258,8 @@ final class ReportCompleteViewController: UIViewController { return contentContainerView } - // TODO: 추후 ViewModel로 옮기기 - private func fetchReport() { - titleLabel.text = "가로등이 깜박거려요." - categoryLabel.text = "교통시설" - locationLabel.text = "서울특별시 강남구 삼성동" - let descriptionText = "150자 내용 채우기" - descriptionLabel.attributedText = BitnagilFont(style: .body1, weight: .medium) - .attributedString(text: descriptionText, alignment: .right) - - let photoView1 = makePhotoView() - let photoView2 = makePhotoView() - let photoView3 = makePhotoView() - [photoView1, photoView2, photoView3].forEach { - photoStackView.addArrangedSubview($0) - } - } - - private func makePhotoView() -> UIView { - let photoView = UIView() + private func makePhotoView() -> UIImageView { + let photoView = UIImageView() photoView.backgroundColor = BitnagilColor.gray30 photoView.layer.masksToBounds = true photoView.layer.cornerRadius = 6 From 885a27287a751fea60486871d34bab6a415867ad Mon Sep 17 00:00:00 2001 From: taipaise Date: Sun, 23 Nov 2025 14:35:52 +0900 Subject: [PATCH 2/8] =?UTF-8?q?Feat:=20=EC=A0=9C=EB=B3=B4=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84,=20?= =?UTF-8?q?=EC=A0=9C=EB=B3=B4=20=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20(#T3-204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: NetworkService 로직 수정 - request 생성로직 수정. body에 jsonData 뿐 아니라, rawdata도 들어갈 수 있도록 수정 (s3 업로드 위함) - response body가 empty여도 error를 throw 하지 않도록 수정 * feat: 제보하기 로직 구현 * refactor: 제보하기 수정된 디자인 적용 * feat: 제보하기 필터링 로직 구현 * feat: 제보히스토리 -> 제보상세 플로우 미비사항 구현 - 진행상황 collectionView cell에 진행상황 별 갯수 표시 - ReportEntity id 값을 옵셔널로 변경 - ReportDetail 날짜 포멧 변경 * fix: 제보하기 로직 수정 * refactor: 코드래빗 리뷰 반영 --- .../DataSourceDependencyAssembler.swift | 4 + .../Sources/Common/Enum/Endpoint.swift | 9 ++ .../Common/Enum/EndpointBodyType.swift | 11 +++ .../DTO/FilePresignedConditionDTO.swift | 11 +++ .../Sources/DTO/FilePresignedDTO.swift | 18 ++++ .../DataSource/Sources/DTO/ReportDTO.swift | 11 +-- .../Endpoint/FilePresignedEndpoint.swift | 68 ++++++++++++++ .../Sources/Endpoint/ReportEndpoint.swift | 19 ++-- .../Sources/Endpoint/S3UploadEndpoint.swift | 63 +++++++++++++ .../NetworkService/Extension/Endpoint+.swift | 9 +- .../NetworkService/NetworkService.swift | 4 +- .../Sources/Repository/FileRepository.swift | 25 ++++++ .../Sources/Repository/ReportRepository.swift | 28 +++++- .../Sources/DomainDependencyAssembler.swift | 8 +- .../Sources/Entity/Enum/ReportType.swift | 6 ++ .../Domain/Sources/Entity/ReportEntity.swift | 12 +-- .../Repository/FileRepositoryProtocol.swift | 23 +++++ .../Repository/ReportRepositoryProtocol.swift | 19 +++- .../UseCase/ReportUseCaseProtocol.swift | 29 +++++- .../UseCase/Report/ReportUseCase.swift | 59 +++++++++++- .../Contents.json | 23 +++++ .../bitnagil_camera_icon.png | Bin 0 -> 587 bytes .../bitnagil_camera_icon@2x.png | Bin 0 -> 1036 bytes .../bitnagil_camera_icon@3x.png | Bin 0 -> 1522 bytes .../Contents.json | 23 +++++ .../bitnagil_photo_icon.png | Bin 0 -> 672 bytes .../bitnagil_photo_icon@2x.png | Bin 0 -> 1118 bytes .../bitnagil_photo_icon@3x.png | Bin 0 -> 1772 bytes .../Common/Component/SelectableItemCell.swift | 29 +++++- .../Common/DesignSystem/BitnagilIcon.swift | 2 + .../Common/Protocol/SelectableItem.swift | 7 ++ .../Home/View/HomeViewController.swift | 12 +-- .../Report/Model/SelectPhotoType.swift | 11 +++ .../ReportHistoryTableViewCell.swift | 2 + .../ReportRegistration/ReportTextView.swift | 7 +- .../View/ReportCompleteViewController.swift | 6 ++ .../View/ReportHistoryViewController.swift | 5 +- .../View/ReportLoadingViewController.swift | 17 ++-- .../ReportRegistrationViewController.swift | 85 +++++++++++++++--- .../ViewModel/ReportDetailViewModel.swift | 9 +- .../ViewModel/ReportHistoryViewModel.swift | 44 +++++++-- .../ReportRegistrationViewModel.swift | 72 ++++++++++++++- Projects/Shared/Sources/Extension/Date+.swift | 4 + 43 files changed, 723 insertions(+), 71 deletions(-) create mode 100644 Projects/DataSource/Sources/Common/Enum/EndpointBodyType.swift create mode 100644 Projects/DataSource/Sources/DTO/FilePresignedConditionDTO.swift create mode 100644 Projects/DataSource/Sources/DTO/FilePresignedDTO.swift create mode 100644 Projects/DataSource/Sources/Endpoint/FilePresignedEndpoint.swift create mode 100644 Projects/DataSource/Sources/Endpoint/S3UploadEndpoint.swift create mode 100644 Projects/DataSource/Sources/Repository/FileRepository.swift create mode 100644 Projects/Domain/Sources/Protocol/Repository/FileRepositoryProtocol.swift create mode 100644 Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_camera_icon.imageset/Contents.json create mode 100644 Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_camera_icon.imageset/bitnagil_camera_icon.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_camera_icon.imageset/bitnagil_camera_icon@2x.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_camera_icon.imageset/bitnagil_camera_icon@3x.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_photo_icon.imageset/Contents.json create mode 100644 Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_photo_icon.imageset/bitnagil_photo_icon.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_photo_icon.imageset/bitnagil_photo_icon@2x.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_photo_icon.imageset/bitnagil_photo_icon@3x.png diff --git a/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift b/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift index fb82e01..8e21d30 100644 --- a/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift +++ b/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift @@ -48,5 +48,9 @@ public struct DataSourceDependencyAssembler: DependencyAssemblerProtocol { DIContainer.shared.register(type: ReportRepositoryProtocol.self) { _ in return ReportRepository() } + + DIContainer.shared.register(type: FileRepositoryProtocol.self) { _ in + return FileRepository() + } } } diff --git a/Projects/DataSource/Sources/Common/Enum/Endpoint.swift b/Projects/DataSource/Sources/Common/Enum/Endpoint.swift index cd47d6b..d07631f 100644 --- a/Projects/DataSource/Sources/Common/Enum/Endpoint.swift +++ b/Projects/DataSource/Sources/Common/Enum/Endpoint.swift @@ -5,6 +5,8 @@ // Created by 최정인 on 6/21/25. // +import Foundation + public protocol Endpoint { var baseURL: String { get } var path: String { get } @@ -13,4 +15,11 @@ public protocol Endpoint { var queryParameters: [String: String] { get } var bodyParameters: [String: Any] { get } var isAuthorized: Bool { get } + var bodyType: EndpointBodyType { get } + var bodyData: Data? { get } +} + +extension Endpoint { + var bodyType: EndpointBodyType { return .json } + var bodyData: Data? { return nil } } diff --git a/Projects/DataSource/Sources/Common/Enum/EndpointBodyType.swift b/Projects/DataSource/Sources/Common/Enum/EndpointBodyType.swift new file mode 100644 index 0000000..6a2a521 --- /dev/null +++ b/Projects/DataSource/Sources/Common/Enum/EndpointBodyType.swift @@ -0,0 +1,11 @@ +// +// EndpointBodyType.swift +// DataSource +// +// Created by 이동현 on 11/22/25. +// + +public enum EndpointBodyType { + case json + case rawData +} diff --git a/Projects/DataSource/Sources/DTO/FilePresignedConditionDTO.swift b/Projects/DataSource/Sources/DTO/FilePresignedConditionDTO.swift new file mode 100644 index 0000000..7f14153 --- /dev/null +++ b/Projects/DataSource/Sources/DTO/FilePresignedConditionDTO.swift @@ -0,0 +1,11 @@ +// +// FilePresignedConditionDTO.swift +// DataSource +// +// Created by 이동현 on 11/22/25. +// + +struct FilePresignedConditionDTO: Codable { + let prefix: String? + let fileName: String +} diff --git a/Projects/DataSource/Sources/DTO/FilePresignedDTO.swift b/Projects/DataSource/Sources/DTO/FilePresignedDTO.swift new file mode 100644 index 0000000..a992e63 --- /dev/null +++ b/Projects/DataSource/Sources/DTO/FilePresignedDTO.swift @@ -0,0 +1,18 @@ +// +// FilePresignedDTO.swift +// DataSource +// +// Created by 이동현 on 11/22/25. +// + +struct FilePresignedDTO: Decodable { + let file1: String? + let file2: String? + let file3: String? + + enum CodingKeys: String, CodingKey { + case file1 = "additionalProp1" + case file2 = "additionalProp2" + case file3 = "additionalProp3" + } +} diff --git a/Projects/DataSource/Sources/DTO/ReportDTO.swift b/Projects/DataSource/Sources/DTO/ReportDTO.swift index 882ccbd..fc6d1a6 100644 --- a/Projects/DataSource/Sources/DTO/ReportDTO.swift +++ b/Projects/DataSource/Sources/DTO/ReportDTO.swift @@ -7,13 +7,13 @@ import Domain -struct ReportDTO: Decodable { +struct ReportDTO: Codable { let reportId: Int? let reportDate: String? let reportTitle: String let reportContent: String? let reportLocation: String - let reportStatus: String + let reportStatus: String? let reportCategory: String let reportImageUrl: String? let reportImageUrls: [String]? @@ -21,18 +21,18 @@ struct ReportDTO: Decodable { 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, + progress: ReportProgress(rawValue: reportStatus ?? "") ?? .received, content: reportContent, location: LocationEntity( longitude: longitude, latitude: latitude, address: reportLocation), + thumbnailURL: reportImageUrl, photoUrls: reportImageUrls ?? []) } @@ -43,12 +43,13 @@ struct ReportDTO: Decodable { title: reportTitle, date: date, type: ReportType(rawValue: reportCategory) ?? .transportation, - progress: ReportProgress(rawValue: reportStatus) ?? .received, + progress: ReportProgress(rawValue: reportStatus ?? "") ?? .received, content: reportContent, location: LocationEntity( longitude: longitude, latitude: latitude, address: reportLocation), + thumbnailURL: reportImageUrl, photoUrls: reportImageUrls ?? []) } } diff --git a/Projects/DataSource/Sources/Endpoint/FilePresignedEndpoint.swift b/Projects/DataSource/Sources/Endpoint/FilePresignedEndpoint.swift new file mode 100644 index 0000000..b6be968 --- /dev/null +++ b/Projects/DataSource/Sources/Endpoint/FilePresignedEndpoint.swift @@ -0,0 +1,68 @@ +// +// FilePresignedEndpoint.swift +// DataSource +// +// Created by 이동현 on 11/22/25. +// + +import Foundation + +enum FilePresignedEndpoint { + case fetchPresignedURL(presignedConditions: [FilePresignedConditionDTO]) +} + +extension FilePresignedEndpoint: Endpoint { + var baseURL: String { + switch self { + case .fetchPresignedURL: + return AppProperties.baseURL + "/api/v2/files" + } + } + + var path: String { + switch self { + case .fetchPresignedURL: + return baseURL + "/presigned-urls" + } + } + + var method: HTTPMethod { + switch self { + case .fetchPresignedURL: .post + } + } + + var headers: [String : String] { + let headers: [String: String] = [ + "Content-Type": "application/json", + "accept": "*/*" + ] + return headers + } + + var queryParameters: [String : String] { + return [:] + } + + var bodyParameters: [String : Any] { + switch self { + case .fetchPresignedURL(let presignedConditions): + return [:] + } + } + + var isAuthorized: Bool { + return true + } + + var bodyType: EndpointBodyType { + return .rawData + } + + var bodyData: Data? { + switch self { + case .fetchPresignedURL(let presignedConditions): + return try? JSONEncoder().encode(presignedConditions) + } + } +} diff --git a/Projects/DataSource/Sources/Endpoint/ReportEndpoint.swift b/Projects/DataSource/Sources/Endpoint/ReportEndpoint.swift index f6d451f..2a85b66 100644 --- a/Projects/DataSource/Sources/Endpoint/ReportEndpoint.swift +++ b/Projects/DataSource/Sources/Endpoint/ReportEndpoint.swift @@ -6,21 +6,19 @@ // enum ReportEndpoint { + case register(report: ReportDTO) case fetchReports case fetchReportDetail(reportId: Int) } extension ReportEndpoint: Endpoint { var baseURL: String { - switch self { - case .fetchReports, .fetchReportDetail: - return AppProperties.baseURL + "/api/v2/reports" - } + return AppProperties.baseURL + "/api/v2/reports" } var path: String { switch self { - case .fetchReports: + case .register, .fetchReports: return baseURL case .fetchReportDetail(let reportId): return "\(baseURL)/\(reportId)" @@ -29,8 +27,10 @@ extension ReportEndpoint: Endpoint { var method: HTTPMethod { switch self { + case.register: + return .post case .fetchReports, .fetchReportDetail: - .get + return .get } } @@ -47,7 +47,12 @@ extension ReportEndpoint: Endpoint { } var bodyParameters: [String : Any] { - return [:] + switch self { + case .register(let report): + return report.dictionary + case .fetchReports, .fetchReportDetail: + return [:] + } } var isAuthorized: Bool { diff --git a/Projects/DataSource/Sources/Endpoint/S3UploadEndpoint.swift b/Projects/DataSource/Sources/Endpoint/S3UploadEndpoint.swift new file mode 100644 index 0000000..fb7ff55 --- /dev/null +++ b/Projects/DataSource/Sources/Endpoint/S3UploadEndpoint.swift @@ -0,0 +1,63 @@ +// +// S3UploadEndpoint.swift +// DataSource +// +// Created by 이동현 on 11/22/25. +// + +import Foundation + +enum S3Endpoint { + case uploadImage(uploadURL: String, data: Data) +} + +extension S3Endpoint: Endpoint { + var baseURL: String { + return "" + } + + var path: String { + switch self { + case .uploadImage(let uploadURL, _): + return uploadURL + } + } + + var method: HTTPMethod { + switch self { + case .uploadImage: + return .put + } + } + + var headers: [String : String] { + switch self { + case .uploadImage: + return [:] + } + } + + var queryParameters: [String : String] { + + return [:] + } + + var bodyParameters: [String : Any] { + return [:] + } + + var isAuthorized: Bool { + return false + } + + var bodyType: EndpointBodyType { + return .rawData + } + + var bodyData: Data? { + switch self { + case .uploadImage(_, let data): + return data + } + } +} diff --git a/Projects/DataSource/Sources/NetworkService/Extension/Endpoint+.swift b/Projects/DataSource/Sources/NetworkService/Extension/Endpoint+.swift index 83ab723..87446ef 100644 --- a/Projects/DataSource/Sources/NetworkService/Extension/Endpoint+.swift +++ b/Projects/DataSource/Sources/NetworkService/Extension/Endpoint+.swift @@ -12,7 +12,14 @@ extension Endpoint { var request = try URLRequest(urlString: path, queryParameters: queryParameters) request.httpMethod = method.rawValue request.makeHeaders(headers: headers) - try request.makeBodyParameter(with: bodyParameters) + switch bodyType { + case .json: + try request.makeBodyParameter(with: bodyParameters) + case .rawData: + if let data = bodyData { + request.httpBody = data + } + } request.cachePolicy = .reloadIgnoringLocalCacheData return request } diff --git a/Projects/DataSource/Sources/NetworkService/NetworkService.swift b/Projects/DataSource/Sources/NetworkService/NetworkService.swift index 8ae6280..aa98c77 100644 --- a/Projects/DataSource/Sources/NetworkService/NetworkService.swift +++ b/Projects/DataSource/Sources/NetworkService/NetworkService.swift @@ -78,7 +78,9 @@ final class NetworkService { throw NetworkError.invalidStatusCode(statusCode: httpResponse.statusCode) } - guard !data.isEmpty else { throw NetworkError.emptyData } + if T.self == EmptyResponseDTO.self { + return EmptyResponseDTO() as? T + } do { let bitnagilResponse = try decoder.decode(BaseResponse.self, from: data) diff --git a/Projects/DataSource/Sources/Repository/FileRepository.swift b/Projects/DataSource/Sources/Repository/FileRepository.swift new file mode 100644 index 0000000..7d7aa3c --- /dev/null +++ b/Projects/DataSource/Sources/Repository/FileRepository.swift @@ -0,0 +1,25 @@ +// +// FileRepository.swift +// DataSource +// +// Created by 이동현 on 11/22/25. +// + +import Domain +import Foundation + +final class FileRepository: FileRepositoryProtocol { + private let networkService = NetworkService.shared + + func fetchPresignedURL(prefix: String?, fileNames: [String]) async throws -> [String : String]? { + let dtos = fileNames.map { FilePresignedConditionDTO(prefix: prefix, fileName: $0) } + let endpoint = FilePresignedEndpoint.fetchPresignedURL(presignedConditions: dtos) + + return try await networkService.request(endpoint: endpoint, type: [String:String].self) + } + + func uploadFile(url: String, data: Data) async throws { + let endPoint = S3Endpoint.uploadImage(uploadURL: url, data: data) + _ = try await networkService.request(endpoint: endPoint, type: EmptyResponseDTO.self) + } +} diff --git a/Projects/DataSource/Sources/Repository/ReportRepository.swift b/Projects/DataSource/Sources/Repository/ReportRepository.swift index 010b217..c7205bb 100644 --- a/Projects/DataSource/Sources/Repository/ReportRepository.swift +++ b/Projects/DataSource/Sources/Repository/ReportRepository.swift @@ -6,12 +6,36 @@ // import Domain +import Foundation final class ReportRepository: ReportRepositoryProtocol { private let networkService = NetworkService.shared - func report(reportEntity: ReportEntity) async { - + func report( + title: String, + content: String?, + category: ReportType, + location: LocationEntity?, + photoURLs: [String] + ) async throws -> Int? { + let reportDTO = ReportDTO( + reportId: nil, + reportDate: nil, + reportTitle: title, + reportContent: content, + reportLocation: location?.address ?? "", + reportStatus: nil, + reportCategory: category.description, + reportImageUrl: nil, + reportImageUrls: photoURLs, + latitude: location?.latitude, + longitude: location?.longitude + ) + + let endpoint = ReportEndpoint.register(report: reportDTO) + guard let id = try await networkService.request(endpoint: endpoint, type: Int.self) else { return nil } + + return id } func fetchReports() async throws -> [ReportEntity] { diff --git a/Projects/Domain/Sources/DomainDependencyAssembler.swift b/Projects/Domain/Sources/DomainDependencyAssembler.swift index 3a18143..8cc4def 100644 --- a/Projects/Domain/Sources/DomainDependencyAssembler.swift +++ b/Projects/Domain/Sources/DomainDependencyAssembler.swift @@ -66,10 +66,14 @@ public struct DomainDependencyAssembler: DependencyAssemblerProtocol { DIContainer.shared.register(type: ReportUseCaseProtocol.self) { container in guard let locationRepository = container.resolve(type: LocationRepositoryProtocol.self), - let reportRepository = container.resolve(type: ReportRepositoryProtocol.self) + let reportRepository = container.resolve(type: ReportRepositoryProtocol.self), + let fileRepository = container.resolve(type: FileRepositoryProtocol.self) else { fatalError("reportUseCase에 필요한 의존성이 등록되지 않았습니다.") } - return ReportUseCase(locationRepository: locationRepository, reportRepository: reportRepository) + return ReportUseCase( + locationRepository: locationRepository, + reportRepository: reportRepository, + fileRepository: fileRepository) } } } diff --git a/Projects/Domain/Sources/Entity/Enum/ReportType.swift b/Projects/Domain/Sources/Entity/Enum/ReportType.swift index add8c31..11789c1 100644 --- a/Projects/Domain/Sources/Entity/Enum/ReportType.swift +++ b/Projects/Domain/Sources/Entity/Enum/ReportType.swift @@ -11,3 +11,9 @@ public enum ReportType: String, CaseIterable { case water case convenience } + +extension ReportType: CustomStringConvertible { + public var description: String { + return self.rawValue.uppercased() + } +} diff --git a/Projects/Domain/Sources/Entity/ReportEntity.swift b/Projects/Domain/Sources/Entity/ReportEntity.swift index 18b9c4c..bd5f79c 100644 --- a/Projects/Domain/Sources/Entity/ReportEntity.swift +++ b/Projects/Domain/Sources/Entity/ReportEntity.swift @@ -6,23 +6,25 @@ // public struct ReportEntity { - public let id: Int + 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 let thumbnailURL: String? + public let photoURLs: [String] public init( - id: Int, + id: Int?, title: String, date: String?, type: ReportType, progress: ReportProgress, content: String?, location: LocationEntity, + thumbnailURL: String?, photoUrls: [String] ) { self.id = id @@ -32,7 +34,7 @@ public struct ReportEntity { self.progress = progress self.content = content self.location = location - self.photoUrls = photoUrls + self.thumbnailURL = thumbnailURL + self.photoURLs = photoUrls } - } diff --git a/Projects/Domain/Sources/Protocol/Repository/FileRepositoryProtocol.swift b/Projects/Domain/Sources/Protocol/Repository/FileRepositoryProtocol.swift new file mode 100644 index 0000000..67147bc --- /dev/null +++ b/Projects/Domain/Sources/Protocol/Repository/FileRepositoryProtocol.swift @@ -0,0 +1,23 @@ +// +// FileRepositoryProtocol.swift +// Domain +// +// Created by 이동현 on 11/22/25. +// + +import Foundation + +public protocol FileRepositoryProtocol { + /// 파일을 업로드할 presignedURL을 발급받습니다. + /// - Parameters: + /// - prefix: 파일을 저장할 경로 prefix + /// - fileNames: 업로드할 파일들의 이름. + /// - Returns: 업로드할 presignedURL (key: fileName, value: url) + func fetchPresignedURL(prefix: String?, fileNames: [String]) async throws -> [String: String]? + + /// 주어진 url로 파일을 업로드 합니다 + /// - Parameters: + /// - url: 파일을 업로드할 url + /// - data: 업로드할 data + func uploadFile(url: String, data: Data) async throws +} diff --git a/Projects/Domain/Sources/Protocol/Repository/ReportRepositoryProtocol.swift b/Projects/Domain/Sources/Protocol/Repository/ReportRepositoryProtocol.swift index 9e5d06c..6fca620 100644 --- a/Projects/Domain/Sources/Protocol/Repository/ReportRepositoryProtocol.swift +++ b/Projects/Domain/Sources/Protocol/Repository/ReportRepositoryProtocol.swift @@ -5,8 +5,25 @@ // Created by 이동현 on 11/9/25. // +import Foundation + public protocol ReportRepositoryProtocol { - func report(reportEntity: ReportEntity) async + + /// 제보를 등록합니다. + /// - Parameters: + /// - title: 제보 제목 + /// - content: 제보 내용 + /// - category: 제보 카테고리 + /// - location: 제보 위치 + /// - photos: 업로드한 사진의 presigned urls + /// - Returns: 제보 id + func report( + title: String, + content: String?, + category: ReportType, + location: LocationEntity?, + photoURLs: [String] + ) async throws -> Int? /// 제보 목록을 조회합니다. /// - Returns: 조회된 제보 목록 diff --git a/Projects/Domain/Sources/Protocol/UseCase/ReportUseCaseProtocol.swift b/Projects/Domain/Sources/Protocol/UseCase/ReportUseCaseProtocol.swift index 62577b6..a8e8302 100644 --- a/Projects/Domain/Sources/Protocol/UseCase/ReportUseCaseProtocol.swift +++ b/Projects/Domain/Sources/Protocol/UseCase/ReportUseCaseProtocol.swift @@ -5,8 +5,35 @@ // Created by 이동현 on 11/9/25. // +import Foundation + public protocol ReportUseCaseProtocol { + /// 현위치를 가져옵니다. + /// - Returns: 현재 위치 func fetchCurrentLocation() async throws -> LocationEntity? - func report(reportEntity: ReportEntity) async + /// 제보 목록을 가져옵니다. + /// - Returns: 제보 목록 + func fetchReports() async throws -> [ReportEntity] + + /// 제보 단건의 상세 정보를 조회합니다. + /// - Parameter reportId: 제보 id + /// - Returns: 제보 상세 정보 + func fetchReport(reportId: Int) async throws -> ReportEntity? + + /// 제보를 등록합니다. + /// - Parameters: + /// - title: 제보 제목 + /// - content: 제보 내용 + /// - category: 제보 카테고리 (교통, 상하수도 등) + /// - location: 제보 위치 + /// - photos: 제보 사진 배열 + /// - Returns: 등록한 제보 id + func report( + title: String, + content: String?, + category: ReportType, + location: LocationEntity?, + photos: [Data] + ) async throws -> Int? } diff --git a/Projects/Domain/Sources/UseCase/Report/ReportUseCase.swift b/Projects/Domain/Sources/UseCase/Report/ReportUseCase.swift index a5a11b6..fa54f15 100644 --- a/Projects/Domain/Sources/UseCase/Report/ReportUseCase.swift +++ b/Projects/Domain/Sources/UseCase/Report/ReportUseCase.swift @@ -5,13 +5,21 @@ // Created by 이동현 on 11/9/25. // +import Foundation + public final class ReportUseCase: ReportUseCaseProtocol { private let locationRepository: LocationRepositoryProtocol + private let fileRepository: FileRepositoryProtocol private let reportRepository: ReportRepositoryProtocol - public init(locationRepository: LocationRepositoryProtocol, reportRepository: ReportRepositoryProtocol) { + public init( + locationRepository: LocationRepositoryProtocol, + reportRepository: ReportRepositoryProtocol, + fileRepository: FileRepositoryProtocol + ) { self.locationRepository = locationRepository self.reportRepository = reportRepository + self.fileRepository = fileRepository } public func fetchCurrentLocation() async throws -> LocationEntity? { @@ -20,7 +28,54 @@ public final class ReportUseCase: ReportUseCaseProtocol { return try await locationRepository.fetchAddress(coordinate: coordinate) } - public func report(reportEntity: ReportEntity) async { + public func fetchReports() async throws -> [ReportEntity] { + return try await reportRepository.fetchReports() + } + + public func fetchReport(reportId: Int) async throws -> ReportEntity? { + return try await reportRepository.fetchReportDetail(reportId: reportId) + } + + public func report( + title: String, + content: String?, + category: ReportType, + location: LocationEntity?, + photos: [Data] + ) async throws -> Int? { + if photos.isEmpty { return nil } + + let fileNames = (1...photos.count).map { "\($0).jpg" } + + // TODO: - 사진 업로드 실패 시 에러 처리 필요 + guard + let presignedDict = try await fileRepository.fetchPresignedURL(prefix: "report", fileNames: fileNames), + presignedDict.count == photos.count + else { return nil } + + let presignedURLs = fileNames.compactMap { fileName in + presignedDict[fileName] + } + + for (url, photo) in zip(presignedURLs, photos) { + do { + try await fileRepository.uploadFile(url: url, data: photo) + } catch { + print(error.localizedDescription) + } + } + + let publicImageURLs = presignedURLs.map { url in + url.split(separator: "?", maxSplits: 1) + .map(String.init) + .first ?? url + } + return try await reportRepository.report( + title: title, + content: content, + category: category, + location: location, + photoURLs: publicImageURLs) } } diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_camera_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_camera_icon.imageset/Contents.json new file mode 100644 index 0000000..86ace5e --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_camera_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "bitnagil_camera_icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "bitnagil_camera_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "bitnagil_camera_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_camera_icon.imageset/bitnagil_camera_icon.png b/Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_camera_icon.imageset/bitnagil_camera_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..90429a620275b0513f56168ac4c34e4e9f19cddf GIT binary patch literal 587 zcmV-R0<`^!P)t~}8M5Z{N29&b5bRDLjLoP~rwv1^Rmdn1!d2c~2 zsU$}Nz6y6z;+iTj8X%(4j5D|_uO%FEIS zFkN$Vuki2hAN?s73)9^DB@4kEFgdn4KmQKJLIJ;4exu!PW9j=h)N1Rf1$7fD8fM73 zG|txJM-TCTbq#xa`)IX7tSIp8>=R6q2KK;Q)240badOfg+O06uW!ttr8AwQz4h|0S zqWo-VzkTysm0U-++Z_k-Q$1q3hYZ{`0k^hy@af~Cn$hQ&nYpJy?>{VS4v7Ne6NZ0@X002ovPDHLkV1oSB1DyZ> literal 0 HcmV?d00001 diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_camera_icon.imageset/bitnagil_camera_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_camera_icon.imageset/bitnagil_camera_icon@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..39917f90f531fe4c7af36640f5a6e452841dae50 GIT binary patch literal 1036 zcmV+n1oQieP)gfv5Fh2J@s$`wJ@|-^L`@V=8jTTd97qHq>dhjU zl-5U2MNR^qse81Z_5jily5oFX3f+Y%GYbiq{*q>QUhRLrZ@zD43RI|2p)3sRAR3K^ za=G~^y>lLD5RZ`fOftqN48yi;yUL2kVOaG*H3LHmGiBCiU|pl5!?(TlD_m^z{CBxH!ml`IY*^X?ek&3=-t4V+ z38d)syS_F8;qsf_I+sA^V-as8TqFsv6+JHICN>!k<_f@IX~BssnuibC+fU-$*)z_2 zI{gC!gYS{eW}ywYw?Zb+W_gQo_0+gy$9CMka|`wLb*}oBmLpExz55TfMT#9~qO|N@ zgFJ8w8oYe*Y)M=;`xiYuy*Pj25;B>LBmQlFudwQAov##vC>1RebT_L?{Fe4OQhs zF)t1bzEe&&blQPjprO15@7;gkh?Kdn80CrNS0u$p74MnRn`WX=gv3owO^H-;6z$Pd zsIRL-YwJ;`t<<7pV`I>waA4?^2-G8){Dy-~jZU)m?j{7KnRI#_nz)vkdoKo3T;oJT z0w_t7xZS&V32QAUWN&U%g)yWc6DXR8bRwA`SL>{?wJ^l(&s8ohaJHV)^2Sg$ibWxX zO+?&F%ckWFYLAyo-%mKQ=iCO)DC$fr&|6oio zV@UJ~t?VPlGWMZxlfxdY1;f+C=;!Avt0mAs2*ueH?+Il7TrM9Kk$y?$2Y zS5~o`hKqfd#~130_u-nFKp)lTa)n7%SHtpJu0n+hiu?x#fnjFRTl%yB0000sB9TZgi8xd;F$GjX;$xeWQL!CDXzXKALW}qVg47>UxwNimK`oUE2q{Qa zlh6pb#cGP`CZM_p?Dpm)94gTS_vl_QQUhsX?=Wwjb#3qLX6qf(SlW-|tUa@v-S50N zZ{Ex-fQyTZi;IiP^8%rX3=KtnjFqDN>j3{O0kBa97(h@9{MK3i$uS0Z=v+l9bpf=e zlF394nn+UwN2AfMfBr2^ae+~IRv4UFShx{~CgB0ekX1LgN*Vs-`=JpKf`0#Nuy^mi zhYudyFMwpETR0LKxy=BAAOQrNM)bsGy14=p(fX_;u>=Ell(FIzNJN@o605S(ZIBE# z;PCKp03;zza5)+?A2b0~Rk}bDZO|ZOQGlY)>ut*>5((+SrHVo_c_USQPyX0BuNMl9 z+;_*I6^R_0;BCBZJey1>tSoBZT#472)v6=~fdyh5rk&V1&P^iDW<`!{LL_+tUMsg9o6ux2IA!IJjT?EOOCbUb+mD5oTdR z!N}k*8G8NxKDc<{ye8U;Q0Sm`yf*tIhk(T?f#T}?D`iMuhCjgKAHe$)A5V;{id$LlbcxDHN0R1%_cgWyuB zt$<1qoY2?TSJPKdocz?(RcB{^(CWsyQ2m-%Y}Bz}uh%0KY#5fdwjjsb$I*0;+)`(G zd1c3bNkQwx$x|Sc&CN}psN5hpmEv;{Qg%e-^o4gpu0IimhSI=3!Jc}dW5Sa zP06&Xqw?z7nyqW7VaOYFjvxDE$LMZ_!c<|<6$@rK01l|NRZltL7ic8I!9q2v*gS;}yc{{b`jV*o5WzG>pZf zqm|!tbJrcGn0$FECn`6I($2QH3pRG<*DD%3;lM$yNW5QN8W?E1R@;ubB zTa#$AvL1wJfwS3_Eer8((mewj7X<5HC@61f$+QUM;C6+*JNDA*%D*Ywlzq! zZe8lj^@zsV<&_nX1*$B_4>f|Dy2La2-4)G6tdUX?cNbw;>daCJSJ#ga=W$rX?1PI0G#V-#X3RtQ&uI zgo2aF`DqkHxK@F$Sem}}WI7H%dD0d`lYDen!^M)p%&JY_)5_)UZ|vE#Z^rWidl@Rb z{gh*4l7D%l?k?2ly@q=u_Y#B|{)Ir}`LE0{kfl_!cOwwJp8qZPT_W=y}R#7JanR%Cpp5lixLQadB~RadC0T Ye}>r$Y!xkn$p8QV07*qoM6N<$f&gRdQ~&?~ literal 0 HcmV?d00001 diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_photo_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_photo_icon.imageset/Contents.json new file mode 100644 index 0000000..11c8b6f --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_photo_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "bitnagil_photo_icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "bitnagil_photo_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "bitnagil_photo_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_photo_icon.imageset/bitnagil_photo_icon.png b/Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_photo_icon.imageset/bitnagil_photo_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..46fbebe9c8857d9d7fdef97dd2601d92adb62912 GIT binary patch literal 672 zcmV;R0$=@!P){ z&VYLySP}*Lh=DIH9%J0+oSC$T>;i=uW85hgi*5_y^z`f>Q77v`)#m#8uSqSWuB=xw z0-Ys}kT3wA?Dnu_S#9kBV3Nj|{x!6p4p{!U1jEBOpj0Zuv*#}&YfNx6REQk>PGm$x zA0d^p)z=6_=7)rgiH?Njav3U>Lp7(7R7MoCKxJBm%sHBX=Z9Ra*5J+CcaX{4gpG~g zE$c_z7?Fdk6bcrY%^MPl1dNT1Mv_3vR#twwt&2iqR1t2;y3zNV&bf0OU$A@l$Q^bn27wKd0UYOr@6 z2>~XeaH)w>lU3me!=xrl4Od5`?HbJM>x;RL<8*w!J>dl0>cX`ID#$wk0000_>xWc&3iL^yqWhi3vh`46n4A4 zfu3S^Oiy964O%&eDGsa?fTtH?F?q*!X4Aj@HVQEcda1Lua*dJALB#F3M-4%+*a zbs4b7XzTZQ-nmL5XtUW(NuF56$tI{MSXg2;y>7QFER3p!>XFOP2_HLJbK2!{#gq|A ztxiC~T0L?EZra}dJE|TL2r>>sP;D^Dq83GhnP8|U#2u^^RH-NhBhk@u3hL_WAQbuu zkw^q~3xObc&?tIMO^q;hXA;cj7Fmu&qA+v!9t4A-!V@(FB#q7r29bL-`yfM38Jm50 zU-o7Zepb*Uh*$>$K-)@$w5YGIgPxwQf^(UMgQU?eg8}I1Xoo$(uNE|0oD72j@OnS$ z>H>jpPzXuJ^gYNxGJydI1ir${rPn#*b92v&MzX|{LaM7#3ubfc1pc&v0U(kTxfCc2 z57zwrBD{4KKS5}^6Ab=%qsaS~W(Z#I2l?3*7(1>OB{*QeEtCP;ww9I?(9_+8 z6(W@Pyp@jvrLGiEesf+OlXD$`evKrE=hdf{#Wcg%CKLe?Qpgb~`Hl=<2E)K%1M~=D zyQN!6hzQlzYtHc;o!vepl(juF41*p)&h*=@#>OVOOy#Sk3VGW4Vzss?J;oYz2^PYQ z>k|d_bT%E1A?WYBpld^3b~rAfZIiAI=E3uzN04zA(@r3dOixXgyoE$EpBIPmoIZQ{ z1id_tolb$ShV$RL1W}vQ@)No4?!q028stfuqa`;od_}O)Cn;ZBGY~s!iejFvB~Is< z5V56#<}5C}0FU=QSTs?ufOf=UX@|A7&%(I2SrBURNpmZCuu~!Du$O;_qQJ4peWCt& zJ9qXBw6(Q@-~TByCgHUs82rARl~qXH{woLCV#*BaJX)lq&Ze1odg_P=s}ZOr)gNTb&%R5>gS9Ek)So{Q3%od(kr2s3yE6n2N_%7oLiNN zH*ejRBT!kC(u4LgM8OdG6@_^E57y&WMWRu8%la^Blb((X{ljWc5h#G-@whOiy)@-~ z-5MAeTs7+YSr!<~c-$+~Suqd#2L``DI^9$h8ghiZ{otRS+TJ5a5~KJ!!Bc0#htai( zpGKM??Op8KT-01*UT;Xtv6ReWjFK$}E6+5aG?t8UF0GS|tWVoweciy1n9aEUg4?~~ kgGz$FHZMSNRfHIQP!n zIdgx`x#!#kSYwSfN*U&+^3I(*>kR|y4N$`7#hh*8jNetjNB}5_zP`(`Y@O^ihM^`E zMUD6MX^EDWsJJd24hDU3P1BOO+KNV_p;T%vnr$m{GZrr^Q)9EU=}yL2*kJIx3Th1R z4GwXna7)W8O$KLY0C+gh?Sv9Me{dQ~|9pKogWWjKd3* zTvE8HDH_VZ8W%ryohB(93_^*l$oa|G{lzHoNRtj*B|fVlm4c){0INcIc{wyTZin^j zpMsg085kVA4wI9UP$-IGNUJnymYQE~o$}h*sIA=!M-Cq}m0ifWb3eh4=YEDl5T>cp zYMLa+Bja81cJPpfhV5{?y9+8R%B3=V5!frYZ~qN$nbY~5@zIg^LYX{=6f<1cl z$ibX-+S=YS6?Y3Gtc|2FH+MeX@wQL~=WaeC)m7jYm9e;0CMkSB<<~Mh8*gyhbJdY* za7i){P-9l1q;M=|PVyKO88g$~JRWJ($e#p)a5 z=Ac^m!KF1iK!o}(8iZ`zj?r(wA%>O&JS-dVGQDw zoc6SUla(oOVLB`#6{k*~$XPEF%~%iKA<`}cq{0ROnWmuVcyGTsWb^H5aRlIy_e^Qd z|D(i;jj#?@095+a$sRcQbV>Cg9(%9JGD71qCJ{%P|9e()!JF{JW1oGRcX(rnhmuP@jN3yIxg_JJIK0rjEGOHxJuk(b zJ398m>#x0PdkrcbiB!SBz*XBi!n9r=8~f+HEGM8c51AGAgrj*^2phiV<$**(-g$`>b+u4o5a`=>T}R!r`jg{YBTJxwPD1uWj7;H2D2~$JbyU3g`6j z@CX?w7#kZoZIwxL7*Fh2^TRmLtT~@z0G|n!K61E|Dz4AyvKTq@z~y*We~!6Q3}7!) z=1K<^w#sCMS*aXRS`6i@`xrP2#FrvC3Z3MVC50LHF0ZtlP=UWRci>twCT|V#X5-_< z5u%~UL^_@2j~&}#G#lv8R##^Nb7(h*L_-_IN{MDGpY24mfmwzu*<%ej3{g&PLa=;j zK5RQVaSx{FO;3ert3j;J*fz?#y1L0W#hzfF_539rmu9?2_?L?gr{O~#c)#<(!a&Uz zEVIh9kxMG3g)b>}{z+8_s&%{UsoXp~rr-X^=DO=~;clok5VS6`lh?!M{ zbi2sAhD0Np^%%c-+)3f}_;r9_(63=x<`{o^{zdr`_}eh_dPUJgy6%swDsIy8s{yU; zA_ny^pKAu-u&4AS-YXNTNry2>FfgFS@xEfM3yU=`k?*SnYpk)xqs9L(1$^SObCCN0 O0000) var snapshot = NSDiffableDataSourceSnapshot() @@ -279,7 +279,7 @@ final class ReportHistoryViewController: BaseViewController { private enum CollectionViewSection { case main } @@ -40,6 +44,8 @@ final class ReportRegistrationViewController: BaseViewController(items: SelectPhotoType.allCases.sorted(by: { $0.id < $1.id }), markIsSelected: false) private var cancellables: Set = [] private var dataSource: DataSource? + weak var delegate: ReportRegistrationViewControllerDelegate? override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) @@ -108,10 +117,16 @@ final class ReportRegistrationViewController: BaseViewController - let selectedProgressPublisher: AnyPublisher let categoryPublisher: AnyPublisher<[ReportType], Never> let selectedCategoryPublisher: AnyPublisher let reportsPublisher: AnyPublisher<[ReportHistoryItem], Never> @@ -27,12 +26,12 @@ final class ReportHistoryViewModel: ViewModel { private(set) var output: Output private let progressSubject = CurrentValueSubject<[ReportProgressItem], Never>([]) - private let selectedProgressSubject = CurrentValueSubject(nil) private let categorySubject = CurrentValueSubject<[ReportType], Never>([]) private let selectedCategorySubject = CurrentValueSubject(nil) private let reportSubject = CurrentValueSubject<[ReportHistoryItem], Never>([]) private let selectedReportSubject = PassthroughSubject() private(set) var selectedReportCategory: ReportType? + private var selectedProgress: ReportProgress? private var reports: [ReportHistoryItem] = [] private let reportRepository: ReportRepositoryProtocol @@ -49,7 +48,6 @@ final class ReportHistoryViewModel: ViewModel { output = Output( progressPublisher: progressSubject.eraseToAnyPublisher(), - selectedProgressPublisher: selectedProgressSubject.eraseToAnyPublisher(), categoryPublisher: categorySubject.eraseToAnyPublisher(), selectedCategoryPublisher: selectedCategorySubject.eraseToAnyPublisher(), reportsPublisher: reportSubject.eraseToAnyPublisher(), @@ -89,25 +87,29 @@ final class ReportHistoryViewModel: ViewModel { for i in 0.. let locationPublisher: AnyPublisher let selectedPhotoPublisher: AnyPublisher<[PhotoItem], Never> + let isReportValid: AnyPublisher let exceptionPublisher: AnyPublisher + let reportRegistrationCompletePublisher: AnyPublisher let maxPhotoCount: Int } @@ -37,6 +39,8 @@ final class ReportRegistrationViewModel: ViewModel { private let contentSubject = CurrentValueSubject(nil) private let locationSubject = CurrentValueSubject(nil) private let selectedPhotoSubject = CurrentValueSubject<[PhotoItem], Never>([]) + private let reportVerificationSubject = PassthroughSubject() + private let reportRegistrationCompleteSubject = PassthroughSubject() private let exceptionSubject = PassthroughSubject() private let maxPhotoCount = 3 private var location: LocationEntity? = nil @@ -51,7 +55,9 @@ final class ReportRegistrationViewModel: ViewModel { contentPublisher: contentSubject.eraseToAnyPublisher(), locationPublisher: locationSubject.eraseToAnyPublisher(), selectedPhotoPublisher: selectedPhotoSubject.eraseToAnyPublisher(), + isReportValid: reportVerificationSubject.eraseToAnyPublisher(), exceptionPublisher: exceptionSubject.eraseToAnyPublisher(), + reportRegistrationCompletePublisher: reportRegistrationCompleteSubject.eraseToAnyPublisher(), maxPhotoCount: maxPhotoCount) } @@ -77,25 +83,29 @@ final class ReportRegistrationViewModel: ViewModel { private func configureCategory(type: ReportType?) { categorySubject.send(type) selectedReportType = type + verifyIsReportValid() } private func configureTitle(title: String?) { titleSubject.send(title) + verifyIsReportValid() } private func configureContent(content: String?) { contentSubject.send(content) + verifyIsReportValid() } private func configureLocation() { Task { do { self.location = try await reportUseCase.fetchCurrentLocation() + locationSubject.send(location?.address) + verifyIsReportValid() } catch { - + locationSubject.send(nil) + verifyIsReportValid() } - - locationSubject.send(location?.address) } } @@ -112,14 +122,70 @@ final class ReportRegistrationViewModel: ViewModel { currentSelectedPhoto.append(item) selectedPhotoSubject.send(currentSelectedPhoto) + verifyIsReportValid() } private func removePhoto(id: UUID) { let currentSelectedPhoto = selectedPhotoSubject.value.filter { $0.id != id } selectedPhotoSubject.send(currentSelectedPhoto) + verifyIsReportValid() } private func register() { + guard + let name = titleSubject.value, + !name.isEmpty, + let category = categorySubject.value, + let content = contentSubject.value, + let location, + selectedPhotoSubject.value.count > 0 + else { return } + + let selectedPhotos = selectedPhotoSubject.value.map({ $0.data }) + + Task { + let minimumDuration: TimeInterval = 0.7 + let startTime = Date() + let reportId: Int? + + do { + reportId = try await reportUseCase.report( + title: name, + content: content, + category: category, + location: location, + photos: selectedPhotos) + } catch { + reportId = nil + } + + let elapsed = Date().timeIntervalSince(startTime) + if elapsed < minimumDuration { + let remaining = minimumDuration - elapsed + try? await Task.sleep( + nanoseconds: UInt64(remaining * 1_000_000_000) + ) + } + + reportRegistrationCompleteSubject.send(reportId) + } + } + + private func verifyIsReportValid() { + guard + let name = titleSubject.value, + !name.isEmpty, + categorySubject.value != nil, + contentSubject.value != nil, + let location, + location.latitude != nil, + location.longitude != nil, + selectedPhotoSubject.value.count > 0 + else { + reportVerificationSubject.send(false) + return + } + reportVerificationSubject.send(true) } } diff --git a/Projects/Shared/Sources/Extension/Date+.swift b/Projects/Shared/Sources/Extension/Date+.swift index c921e6c..c5881dd 100644 --- a/Projects/Shared/Sources/Extension/Date+.swift +++ b/Projects/Shared/Sources/Extension/Date+.swift @@ -32,6 +32,8 @@ extension Date { public enum DateType { case yearMonthDate case yearMonthDateShort + case yearMonthDateWeek + case yearMonthDateWeek2 case yearMonth case dayOfWeek case date @@ -44,6 +46,8 @@ extension Date { switch self { case .yearMonthDate: "yyyy-MM-dd" case .yearMonthDateShort: "yy.MM.dd" + case .yearMonthDateWeek: "yyyy-MM-dd E" + case .yearMonthDateWeek2: "yyyy-MM-dd (E)" case .yearMonth: "yyyy년 M월" case .dayOfWeek: "E" case .date: "d" From 549716aa24853321ab433601e97b3ec60f7c76be Mon Sep 17 00:00:00 2001 From: taipaise Date: Sat, 22 Nov 2025 20:26:44 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=EC=A0=9C=EB=B3=B4=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20VC=20=EB=B7=B0=20=EB=AA=A8=EB=8D=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../View/ReportCompleteViewController.swift | 68 ++++++++++++------- 1 file changed, 43 insertions(+), 25 deletions(-) diff --git a/Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift b/Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift index 953d877..65cb081 100644 --- a/Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift +++ b/Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift @@ -5,10 +5,11 @@ // Created by 최정인 on 11/19/25. // +import Combine import SnapKit import UIKit -final class ReportCompleteViewController: UIViewController { +final class ReportCompleteViewController: BaseViewController { private enum Layout { static let horizontalMargin: CGFloat = 20 static let completeImageViewTopSpacing: CGFloat = 78 @@ -65,15 +66,27 @@ final class ReportCompleteViewController: UIViewController { private let descriptionLabel = UILabel() private let photoStackView = UIStackView() private let confirmButton = PrimaryButton(buttonState: .default, buttonTitle: "확인") + private let reportId: Int + private var cancellables: Set = [] + + 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() configureAttribute() configureLayout() - fetchReport() + viewModel.action(input: .fetchReportDetail(reportId: reportId)) } - private func configureAttribute() { + override func configureAttribute() { view.backgroundColor = .white scrollView.showsVerticalScrollIndicator = false @@ -111,7 +124,7 @@ final class ReportCompleteViewController: UIViewController { for: .touchUpInside) } - private func configureLayout() { + override func configureLayout() { let safeArea = view.safeAreaLayoutGuide view.addSubview(scrollView) @@ -177,8 +190,30 @@ final class ReportCompleteViewController: UIViewController { } } - private func bind() { - fetchReport() + override func bind() { + viewModel.output.reportDetailPublisher + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] reportDetail in + guard let reportDetail else { return } + + self?.titleLabel.text = reportDetail.title + self?.categoryLabel.text = reportDetail.category.name + self?.locationLabel.text = reportDetail.location + let descriptionText = reportDetail.description + self?.descriptionLabel.attributedText = BitnagilFont(style: .body1, weight: .medium) + .attributedString(text: descriptionText, alignment: .right) + + for photoURL in reportDetail.photoUrls { + guard + let photoView = self?.makePhotoView(), + let url = URL(string: photoURL) + else { continue } + + photoView.kf.setImage(with: url) + self?.photoStackView.addArrangedSubview(photoView) + } + }) + .store(in: &cancellables) } private func makeContentView(contentType: ReportCompleteContent) -> UIView { @@ -229,25 +264,8 @@ final class ReportCompleteViewController: UIViewController { return contentContainerView } - // TODO: 추후 ViewModel로 옮기기 - private func fetchReport() { - titleLabel.text = "가로등이 깜박거려요." - categoryLabel.text = "교통시설" - locationLabel.text = "서울특별시 강남구 삼성동" - let descriptionText = "150자 내용 채우기" - descriptionLabel.attributedText = BitnagilFont(style: .body1, weight: .medium) - .attributedString(text: descriptionText, alignment: .right) - - let photoView1 = makePhotoView() - let photoView2 = makePhotoView() - let photoView3 = makePhotoView() - [photoView1, photoView2, photoView3].forEach { - photoStackView.addArrangedSubview($0) - } - } - - private func makePhotoView() -> UIView { - let photoView = UIView() + private func makePhotoView() -> UIImageView { + let photoView = UIImageView() photoView.backgroundColor = BitnagilColor.gray30 photoView.layer.masksToBounds = true photoView.layer.cornerRadius = 6 From 7a4721e7eaec1b5dfae9e6ed15582ae76e1d10fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8E=E1=85=AC=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8B?= =?UTF-8?q?=E1=85=B5=E1=86=AB?= Date: Sun, 23 Nov 2025 16:01:43 +0900 Subject: [PATCH 4/8] =?UTF-8?q?Fix:=20UI=20=EC=9D=BC=EB=B6=80=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20Enum=20=EA=B0=92=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Entity/Enum/ReportType.swift | 8 ++--- .../View/ReportCompleteViewController.swift | 29 +++++++++++++++---- .../View/ReportLoadingViewController.swift | 6 +++- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/Projects/Domain/Sources/Entity/Enum/ReportType.swift b/Projects/Domain/Sources/Entity/Enum/ReportType.swift index 11789c1..31da451 100644 --- a/Projects/Domain/Sources/Entity/Enum/ReportType.swift +++ b/Projects/Domain/Sources/Entity/Enum/ReportType.swift @@ -6,10 +6,10 @@ // public enum ReportType: String, CaseIterable { - case transportation - case lamp - case water - case convenience + case transportation = "TRANSPORTATION" + case lamp = "LIGHTING" + case water = "WATERFACILITY" + case convenience = "AMENITY" } extension ReportType: CustomStringConvertible { diff --git a/Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift b/Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift index 65cb081..d86da5e 100644 --- a/Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift +++ b/Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift @@ -6,6 +6,7 @@ // import Combine +import Shared import SnapKit import UIKit @@ -119,7 +120,21 @@ final class ReportCompleteViewController: BaseViewController Date: Thu, 4 Dec 2025 16:04:59 +0900 Subject: [PATCH 5/8] =?UTF-8?q?Fix:=20PhotoURL=20=EB=94=95=EC=85=94?= =?UTF-8?q?=EB=84=88=EB=A6=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/Sources/UseCase/Report/ReportUseCase.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Projects/Domain/Sources/UseCase/Report/ReportUseCase.swift b/Projects/Domain/Sources/UseCase/Report/ReportUseCase.swift index fa54f15..3d46af9 100644 --- a/Projects/Domain/Sources/UseCase/Report/ReportUseCase.swift +++ b/Projects/Domain/Sources/UseCase/Report/ReportUseCase.swift @@ -53,11 +53,7 @@ public final class ReportUseCase: ReportUseCaseProtocol { presignedDict.count == photos.count else { return nil } - let presignedURLs = fileNames.compactMap { fileName in - presignedDict[fileName] - } - - for (url, photo) in zip(presignedURLs, photos) { + for (url, photo) in zip(presignedDict.values, photos) { do { try await fileRepository.uploadFile(url: url, data: photo) } catch { @@ -65,7 +61,7 @@ public final class ReportUseCase: ReportUseCaseProtocol { } } - let publicImageURLs = presignedURLs.map { url in + let publicImageURLs = presignedDict.values.map { url in url.split(separator: "?", maxSplits: 1) .map(String.init) .first ?? url From 2592b2989839c3fbac3ea0c22244d8684efe9b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8E=E1=85=AC=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8B?= =?UTF-8?q?=E1=85=B5=E1=86=AB?= Date: Thu, 4 Dec 2025 16:08:45 +0900 Subject: [PATCH 6/8] =?UTF-8?q?Fix:=20Report=20UI=20Date=20=ED=98=95?= =?UTF-8?q?=EC=8B=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/Shared/Sources/Extension/Date+.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Projects/Shared/Sources/Extension/Date+.swift b/Projects/Shared/Sources/Extension/Date+.swift index c5881dd..2f1b80e 100644 --- a/Projects/Shared/Sources/Extension/Date+.swift +++ b/Projects/Shared/Sources/Extension/Date+.swift @@ -46,8 +46,8 @@ extension Date { switch self { case .yearMonthDate: "yyyy-MM-dd" case .yearMonthDateShort: "yy.MM.dd" - case .yearMonthDateWeek: "yyyy-MM-dd E" - case .yearMonthDateWeek2: "yyyy-MM-dd (E)" + case .yearMonthDateWeek: "yy.MM.dd E" + case .yearMonthDateWeek2: "yyyy.MM.dd (E)" case .yearMonth: "yyyy년 M월" case .dayOfWeek: "E" case .date: "d" From 86493ac2bb127098b5237da4092ab0aebcd42df5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8E=E1=85=AC=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8B?= =?UTF-8?q?=E1=85=B5=E1=86=AB?= Date: Thu, 4 Dec 2025 16:14:07 +0900 Subject: [PATCH 7/8] =?UTF-8?q?Feat:=20=EC=B6=94=EC=B2=9C=20=EB=A3=A8?= =?UTF-8?q?=ED=8B=B4=20=ED=83=AD=20=ED=94=8C=EB=A1=9C=ED=8C=85=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=EC=97=90=20'=EC=A0=9C=EB=B3=B4=ED=95=98=EA=B8=B0'=20?= =?UTF-8?q?=EC=A7=84=EC=9E=85=EC=A0=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../View/RecommendedRoutineViewController.swift | 9 ++++++++- .../Report/View/ReportCompleteViewController.swift | 4 +++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineViewController.swift b/Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineViewController.swift index 17fbe42..8570d69 100644 --- a/Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineViewController.swift +++ b/Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineViewController.swift @@ -337,7 +337,14 @@ extension RecommendedRoutineViewController: SelectableItemTableViewDelegate { extension RecommendedRoutineViewController: FloatingMenuViewDelegate { func floatingMenuDidTapReportButton(_ sender: FloatingMenuView) { toggleFloatingButton() - // TODO: 제보하기 뷰로 이동 + + guard let reportRegistrationViewModel = DIContainer.shared.resolve(type: ReportRegistrationViewModel.self) + else { fatalError("reportRegistrationViewController 의존성이 등록되지 않았습니다.") } + + let reportRegistrationViewController = ReportRegistrationViewController(viewModel: reportRegistrationViewModel) + reportRegistrationViewController.hidesBottomBarWhenPushed = true + + self.navigationController?.pushViewController(reportRegistrationViewController, animated: true) } func floatingMenuDidTapRegisterRoutineButton(_ sender: FloatingMenuView) { diff --git a/Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift b/Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift index d86da5e..a969abd 100644 --- a/Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift +++ b/Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift @@ -124,11 +124,13 @@ final class ReportCompleteViewController: BaseViewController Date: Thu, 4 Dec 2025 17:05:40 +0900 Subject: [PATCH 8/8] =?UTF-8?q?Feat:=20ReportRegistrationViewController=20?= =?UTF-8?q?=ED=82=A4=EB=B3=B4=EB=93=9C=20=EB=82=B4=EB=A0=A4=EA=B0=80?= =?UTF-8?q?=EA=B8=B0=20=EB=B0=8F=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EC=9D=BC=EB=B6=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReportRegistrationViewController.swift | 49 ++++++++++++++----- .../ReportRegistrationViewModel.swift | 6 +++ 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/Projects/Presentation/Sources/Report/View/ReportRegistrationViewController.swift b/Projects/Presentation/Sources/Report/View/ReportRegistrationViewController.swift index 33f00b5..f2b6641 100644 --- a/Projects/Presentation/Sources/Report/View/ReportRegistrationViewController.swift +++ b/Projects/Presentation/Sources/Report/View/ReportRegistrationViewController.swift @@ -101,7 +101,7 @@ final class ReportRegistrationViewController: BaseViewController= self.viewModel.output.maxPhotoCount { + let message = "사진은 최대 \(self.viewModel.output.maxPhotoCount)장까지 선택할 수 있습니다." + let alertController = UIAlertController(title: "알림", message: message, preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: "확인", style: .default)) + self.present(alertController, animated: true) + } else { + self.showCameraBottomSheet() + } + } + .store(in: &cancellables) + viewModel.output.isReportValid .receive(on: DispatchQueue.main) .sink { [weak self] isReportValid in @@ -514,6 +533,10 @@ final class ReportRegistrationViewController: BaseViewController let locationPublisher: AnyPublisher let selectedPhotoPublisher: AnyPublisher<[PhotoItem], Never> + let selectedPhotoCountPublisher: AnyPublisher let isReportValid: AnyPublisher let exceptionPublisher: AnyPublisher let reportRegistrationCompletePublisher: AnyPublisher @@ -39,6 +41,7 @@ final class ReportRegistrationViewModel: ViewModel { private let contentSubject = CurrentValueSubject(nil) private let locationSubject = CurrentValueSubject(nil) private let selectedPhotoSubject = CurrentValueSubject<[PhotoItem], Never>([]) + private let selectedPhotoCountSubject = PassthroughSubject() private let reportVerificationSubject = PassthroughSubject() private let reportRegistrationCompleteSubject = PassthroughSubject() private let exceptionSubject = PassthroughSubject() @@ -55,6 +58,7 @@ final class ReportRegistrationViewModel: ViewModel { contentPublisher: contentSubject.eraseToAnyPublisher(), locationPublisher: locationSubject.eraseToAnyPublisher(), selectedPhotoPublisher: selectedPhotoSubject.eraseToAnyPublisher(), + selectedPhotoCountPublisher: selectedPhotoCountSubject.eraseToAnyPublisher(), isReportValid: reportVerificationSubject.eraseToAnyPublisher(), exceptionPublisher: exceptionSubject.eraseToAnyPublisher(), reportRegistrationCompletePublisher: reportRegistrationCompleteSubject.eraseToAnyPublisher(), @@ -75,6 +79,8 @@ final class ReportRegistrationViewModel: ViewModel { selectPhoto(photoData: photoData) case .removePhoto(let id): removePhoto(id: id) + case .checkSelectedPhotoCount: + selectedPhotoCountSubject.send(selectedPhotoSubject.value.count) case .register: register() }