diff --git a/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift b/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift index fb82e010..8e21d308 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 cd47d6b5..d07631f8 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 00000000..6a2a521c --- /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 00000000..7f141531 --- /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 00000000..a992e63e --- /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 882ccbd8..fc6d1a68 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 00000000..b6be9683 --- /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 f6d451fc..2a85b660 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 00000000..fb7ff55e --- /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 83ab723f..87446ef3 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 8ae62808..aa98c779 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 00000000..7d7aa3c4 --- /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 010b2176..c7205bbb 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 3a18143d..8cc4def2 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 add8c316..31da451f 100644 --- a/Projects/Domain/Sources/Entity/Enum/ReportType.swift +++ b/Projects/Domain/Sources/Entity/Enum/ReportType.swift @@ -6,8 +6,14 @@ // 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 { + 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 18b9c4c7..bd5f79c5 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 00000000..67147bcf --- /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 9e5d06cd..6fca620d 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 62577b6c..a8e83028 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 a5a11b63..3d46af92 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,50 @@ 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 } + + for (url, photo) in zip(presignedDict.values, photos) { + do { + try await fileRepository.uploadFile(url: url, data: photo) + } catch { + print(error.localizedDescription) + } + } + + let publicImageURLs = presignedDict.values.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 00000000..86ace5ec --- /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 00000000..90429a62 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_camera_icon.imageset/bitnagil_camera_icon.png differ 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 00000000..39917f90 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_camera_icon.imageset/bitnagil_camera_icon@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_camera_icon.imageset/bitnagil_camera_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_camera_icon.imageset/bitnagil_camera_icon@3x.png new file mode 100644 index 00000000..fd0a7cdd Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_camera_icon.imageset/bitnagil_camera_icon@3x.png differ 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 00000000..11c8b6f1 --- /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 00000000..46fbebe9 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_photo_icon.imageset/bitnagil_photo_icon.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_photo_icon.imageset/bitnagil_photo_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_photo_icon.imageset/bitnagil_photo_icon@2x.png new file mode 100644 index 00000000..adcd1c02 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_photo_icon.imageset/bitnagil_photo_icon@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_photo_icon.imageset/bitnagil_photo_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_photo_icon.imageset/bitnagil_photo_icon@3x.png new file mode 100644 index 00000000..acd5f043 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_photo_icon.imageset/bitnagil_photo_icon@3x.png differ diff --git a/Projects/Presentation/Sources/Common/Component/SelectableItemCell.swift b/Projects/Presentation/Sources/Common/Component/SelectableItemCell.swift index 649db925..50758d6c 100644 --- a/Projects/Presentation/Sources/Common/Component/SelectableItemCell.swift +++ b/Projects/Presentation/Sources/Common/Component/SelectableItemCell.swift @@ -11,9 +11,14 @@ import UIKit final class SelectableItemCell: UITableViewCell { private enum Layout { static let checkIconSize: CGFloat = 16 + static let stackViewHeight: CGFloat = 24 + static let stackViewSpacing: CGFloat = 10 static let horizontalMargin: CGFloat = 20 + static let iconImageViewSize: CGFloat = 20 } + private let stackView = UIStackView() + private let iconImageView = UIImageView() private let titleLabel = UILabel() private let checkIcon = UIImageView() @@ -28,6 +33,9 @@ final class SelectableItemCell: UITableViewCell { } private func configureAttribute() { + stackView.spacing = Layout.stackViewSpacing + stackView.alignment = .leading + titleLabel.font = BitnagilFont(style: .body1, weight: .regular).font titleLabel.textColor = BitnagilColor.gray10 @@ -36,15 +44,25 @@ final class SelectableItemCell: UITableViewCell { .withRenderingMode(.alwaysTemplate) checkIcon.tintColor = BitnagilColor.orange500 checkIcon.image = checkImage + + iconImageView.isHidden = true } private func configureLayout() { - contentView.addSubview(titleLabel) + contentView.addSubview(stackView) + stackView.addArrangedSubview(iconImageView) + stackView.addArrangedSubview(titleLabel) contentView.addSubview(checkIcon) - titleLabel.snp.makeConstraints { make in + stackView.snp.makeConstraints { make in make.leading.equalToSuperview().offset(Layout.horizontalMargin) make.centerY.equalToSuperview() + make.height.equalTo(Layout.stackViewHeight) + make.width.equalTo(CGFloat.zero).priority(.medium) + } + + iconImageView.snp.makeConstraints { make in + make.size.equalTo(Layout.iconImageViewSize) } checkIcon.snp.makeConstraints { make in @@ -61,6 +79,12 @@ final class SelectableItemCell: UITableViewCell { titleLabel.text = item.description return } + + if let icon = item.icon { + iconImageView.image = icon + iconImageView.isHidden = false + } + let attributedString = NSMutableAttributedString(string: item.description) attributedString.addAttribute( .font, @@ -73,6 +97,7 @@ final class SelectableItemCell: UITableViewCell { .font: BitnagilFont(style: .body1, weight: .semiBold).font ], range: nsRange) } + titleLabel.attributedText = attributedString } } diff --git a/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift b/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift index 50602370..199eb497 100644 --- a/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift +++ b/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift @@ -76,6 +76,8 @@ enum BitnagilIcon { // MARK: - Report static let roundDeleteIcon = UIImage(named: "round_delete_icon", in: bundle, with: nil) static let cameraIcon = UIImage(named: "camera_icon", in: bundle, with: nil) + static let bitnagilCameraIcon = UIImage(named: "bitnagil_camera_icon", in: bundle, with: nil) + static let bitnagilPhotoIcon = UIImage(named: "bitnagil_photo_icon", in: bundle, with: nil) static let locationIcon = UIImage(named: "location_icon", in: bundle, with: nil) static let carIcon = UIImage(named: "icon_car", in: bundle, with: nil) static let lightIcon = UIImage(named: "icon_light", in: bundle, with: nil) diff --git a/Projects/Presentation/Sources/Common/Protocol/SelectableItem.swift b/Projects/Presentation/Sources/Common/Protocol/SelectableItem.swift index d43adc41..bce4ce7c 100644 --- a/Projects/Presentation/Sources/Common/Protocol/SelectableItem.swift +++ b/Projects/Presentation/Sources/Common/Protocol/SelectableItem.swift @@ -5,8 +5,15 @@ // Created by 최정인 on 7/24/25. // +import UIKit + protocol SelectableItem { var id: Int { get } var displayName: String? { get } var description: String { get } + var icon: UIImage? { get } +} + +extension SelectableItem { + var icon: UIImage? { return nil } } diff --git a/Projects/Presentation/Sources/Home/View/HomeViewController.swift b/Projects/Presentation/Sources/Home/View/HomeViewController.swift index 6fcc022a..9cb45712 100644 --- a/Projects/Presentation/Sources/Home/View/HomeViewController.swift +++ b/Projects/Presentation/Sources/Home/View/HomeViewController.swift @@ -666,14 +666,14 @@ extension HomeViewController: RoutineViewDelegate { extension HomeViewController: FloatingMenuViewDelegate { func floatingMenuDidTapReportButton(_ sender: FloatingMenuView) { toggleFloatingButton() - // TODO: 제보하기 뷰로 이동 (현재는 제보 detailView) - guard let reportDetailViewModel = DIContainer.shared.resolve(type: ReportDetailViewModel.self) - else { fatalError("reportDetailViewModel 의존성이 등록되지 않았습니다.") } - let reportDetailViewController = ReportDetailViewController(viewModel: reportDetailViewModel, reportId: 1) - reportDetailViewController.hidesBottomBarWhenPushed = true + guard let reportRegistrationViewModel = DIContainer.shared.resolve(type: ReportRegistrationViewModel.self) + else { fatalError("reportRegistrationViewController 의존성이 등록되지 않았습니다.") } - self.navigationController?.pushViewController(reportDetailViewController, animated: true) + let reportRegistrationViewController = ReportRegistrationViewController(viewModel: reportRegistrationViewModel) + reportRegistrationViewController.hidesBottomBarWhenPushed = true + + self.navigationController?.pushViewController(reportRegistrationViewController, animated: true) } func floatingMenuDidTapRegisterRoutineButton(_ sender: FloatingMenuView) { diff --git a/Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineViewController.swift b/Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineViewController.swift index 17fbe427..8570d693 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/Model/SelectPhotoType.swift b/Projects/Presentation/Sources/Report/Model/SelectPhotoType.swift index 62b3766f..e9910d62 100644 --- a/Projects/Presentation/Sources/Report/Model/SelectPhotoType.swift +++ b/Projects/Presentation/Sources/Report/Model/SelectPhotoType.swift @@ -5,6 +5,8 @@ // Created by 이동현 on 11/9/25. // +import UIKit + enum SelectPhotoType: String, CaseIterable { case camera case library @@ -32,4 +34,13 @@ extension SelectPhotoType: SelectableItem { return "사진 라이브러리에서 선택" } } + + var icon: UIImage? { + switch self { + case .camera: + return BitnagilIcon.bitnagilCameraIcon + case .library: + return BitnagilIcon.bitnagilPhotoIcon + } + } } diff --git a/Projects/Presentation/Sources/Report/View/Component/ReportHistory/ReportHistoryTableViewCell.swift b/Projects/Presentation/Sources/Report/View/Component/ReportHistory/ReportHistoryTableViewCell.swift index 5d50f045..1e93b2f7 100644 --- a/Projects/Presentation/Sources/Report/View/Component/ReportHistory/ReportHistoryTableViewCell.swift +++ b/Projects/Presentation/Sources/Report/View/Component/ReportHistory/ReportHistoryTableViewCell.swift @@ -46,6 +46,8 @@ final class ReportHistoryTableViewCell: UITableViewCell { private func configureAttribute() { backgroundColor = .clear + selectionStyle = .none + containerView.backgroundColor = .white containerView.layer.cornerRadius = Layout.containerViewCornerRadius containerView.layer.masksToBounds = true diff --git a/Projects/Presentation/Sources/Report/View/Component/ReportRegistration/ReportTextView.swift b/Projects/Presentation/Sources/Report/View/Component/ReportRegistration/ReportTextView.swift index 922fc763..8640e669 100644 --- a/Projects/Presentation/Sources/Report/View/Component/ReportRegistration/ReportTextView.swift +++ b/Projects/Presentation/Sources/Report/View/Component/ReportRegistration/ReportTextView.swift @@ -26,8 +26,8 @@ final class ReportTextView: UIView { static let placeholderTopSpacing: CGFloat = 15 static let chevronImageLeadingSpacing: CGFloat = 17 static let chevronImageTrailingSpacing: CGFloat = 19 - static let chevronImageWidth: CGFloat = 24 - static let chevronImageHeight: CGFloat = 24 + static let chevronImageWidth: CGFloat = 10.12 + static let chevronImageHeight: CGFloat = 5.74 } private let placeholderLabel = UILabel() @@ -56,7 +56,7 @@ final class ReportTextView: UIView { ).font chevronImage.image = BitnagilIcon - .chevronIcon(direction: .down)? + .bitnagilChevronIcon(direction: .down)? .withRenderingMode(.alwaysTemplate) chevronImage.tintColor = BitnagilColor.gray10 @@ -158,6 +158,7 @@ extension ReportTextView: UITextViewDelegate { func textViewDidChange(_ textView: UITextView) { updatePlaceholderVisibility() + delegate?.reportTextViewDidChanged(self, text: textView.text) } func textViewDidEndEditing(_ textView: UITextView) { diff --git a/Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift b/Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift index 9da93582..a969abd6 100644 --- a/Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift +++ b/Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift @@ -5,10 +5,12 @@ // Created by 최정인 on 11/19/25. // +import Combine +import Shared 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 +67,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 @@ -103,9 +117,31 @@ final class ReportCompleteViewController: UIViewController { photoStackView.axis = .horizontal photoStackView.spacing = Layout.photoStackViewSpacing + + confirmButton.addAction( + UIAction { [weak self] _ in + if + let self, + let tabBarController = self.tabBarController, + let homeViewController = tabBarController.viewControllers?[0] as? UINavigationController, + let recommendedRoutineViewController = tabBarController.viewControllers?[1] as? UINavigationController, + let mypageViewController = tabBarController.viewControllers?[2] as? UINavigationController, + let reportHistoryViewModel = DIContainer.shared.resolve(type: ReportHistoryViewModel.self) { + + homeViewController.popToRootViewController(animated: false) + recommendedRoutineViewController.popToRootViewController(animated: false) + + tabBarController.selectedIndex = 2 + let reportHistoryViewController = ReportHistoryViewController(viewModel: reportHistoryViewModel) + mypageViewController.pushViewController(reportHistoryViewController, animated: true) + } else { + self?.navigationController?.popToRootViewController(animated: true) + } + }, + for: .touchUpInside) } - private func configureLayout() { + override func configureLayout() { let safeArea = view.safeAreaLayoutGuide view.addSubview(scrollView) @@ -117,10 +153,6 @@ final class ReportCompleteViewController: UIViewController { backgroudView.addSubview(summaryStackView) summaryStackView.addArrangedSubview(summaryLabel) - ReportCompleteContent.allCases.forEach { reportCompleteContentType in - let contentStackView = makeContentView(contentType: reportCompleteContentType) - summaryStackView.addArrangedSubview(contentStackView) - } scrollView.snp.makeConstraints { make in make.edges.equalTo(safeArea) @@ -171,8 +203,37 @@ 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.numberOfLines = 0 + self?.descriptionLabel.attributedText = BitnagilFont(style: .body1, weight: .medium) + .attributedString(text: descriptionText, alignment: .right) + + ReportCompleteContent.allCases.forEach { reportCompleteContentType in + let contentStackView = self?.makeContentView(contentType: reportCompleteContentType) + self?.summaryStackView.addArrangedSubview(contentStackView ?? UIView()) + } + + self?.photoStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + 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 { @@ -194,7 +255,6 @@ final class ReportCompleteViewController: UIViewController { contentView = photoStackView } else { var contentLabel = UILabel() - contentLabel.text = " " switch contentType { case .title: contentLabel = titleLabel @@ -223,25 +283,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 diff --git a/Projects/Presentation/Sources/Report/View/ReportHistoryViewController.swift b/Projects/Presentation/Sources/Report/View/ReportHistoryViewController.swift index 25eb2f7f..072a077b 100644 --- a/Projects/Presentation/Sources/Report/View/ReportHistoryViewController.swift +++ b/Projects/Presentation/Sources/Report/View/ReportHistoryViewController.swift @@ -267,7 +267,7 @@ final class ReportHistoryViewController: BaseViewController) 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) @@ -92,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 + if isReportValid { + self?.registerButton.updateButtonState(buttonState: .default) + } else { + self?.registerButton.updateButtonState(buttonState: .disabled) + } + } + .store(in: &cancellables) + + viewModel.output.reportRegistrationCompletePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] reportId in + guard let self else { return } + + delegate?.reportRegistrationViewController(self, completeRegistration: reportId) + } + .store(in: &cancellables) } private func showCameraBottomSheet() { @@ -449,6 +533,10 @@ 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 selectedPhotoCountPublisher: AnyPublisher + let isReportValid: AnyPublisher let exceptionPublisher: AnyPublisher + let reportRegistrationCompletePublisher: AnyPublisher let maxPhotoCount: Int } @@ -37,6 +41,9 @@ 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() private let maxPhotoCount = 3 private var location: LocationEntity? = nil @@ -51,7 +58,10 @@ 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(), maxPhotoCount: maxPhotoCount) } @@ -69,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() } @@ -77,25 +89,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 +128,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 c921e6cf..2f1b80e0 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: "yy.MM.dd E" + case .yearMonthDateWeek2: "yyyy.MM.dd (E)" case .yearMonth: "yyyy년 M월" case .dayOfWeek: "E" case .date: "d"