diff --git a/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift b/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift index 1113be0..4ba1087 100644 --- a/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift +++ b/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift @@ -56,5 +56,9 @@ public struct DataSourceDependencyAssembler: DependencyAssemblerProtocol { DIContainer.shared.register(type: ActivityHistoryRepositoryProtocol.self) { _ in return ActivityHistoryRepository() } + + DIContainer.shared.register(type: YouthPolicyRepositoryProtocol.self) { _ in + return YouthPolicyRepository() + } } } diff --git a/Projects/DataSource/Sources/DTO/YouthPolicyDTO.swift b/Projects/DataSource/Sources/DTO/YouthPolicyDTO.swift new file mode 100644 index 0000000..fe6ad34 --- /dev/null +++ b/Projects/DataSource/Sources/DTO/YouthPolicyDTO.swift @@ -0,0 +1,61 @@ +// +// YouthPolicyDTO.swift +// DataSource +// + +import Domain +import Foundation + +struct YouthPolicyDTO: Decodable { + let plcyNo: String + let title: String + let category: String? + let thumbnailUrl: String? + let status: String + let startDate: String? + let endDate: String? + let dday: Int? + let applyUrl: String? + let bookmarked: Bool +} + +extension YouthPolicyDTO { + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "Asia/Seoul") + return formatter + }() + + func toYouthPolicyEntity() -> YouthPolicyEntity? { + guard let status = YouthPolicyStatus(rawValue: status) else { return nil } + + return YouthPolicyEntity( + policyNumber: plcyNo, + title: title, + category: category, + thumbnailURL: thumbnailUrl, + status: status, + startDate: startDate.flatMap { Self.dateFormatter.date(from: $0) }, + endDate: endDate.flatMap { Self.dateFormatter.date(from: $0) }, + dday: dday, + applyURL: applyUrl, + isBookmarked: bookmarked) + } +} + +struct YouthPolicyPageDTO: Decodable { + let totalCount: Int + let hasNext: Bool + let nextCursor: String? + let items: [YouthPolicyDTO] + + func toYouthPolicyPageEntity() -> YouthPolicyPageEntity { + return YouthPolicyPageEntity( + policies: items.compactMap { $0.toYouthPolicyEntity() }, + totalCount: totalCount, + hasNext: hasNext, + nextCursor: nextCursor) + } +} diff --git a/Projects/DataSource/Sources/Endpoint/YouthPolicyEndpoint.swift b/Projects/DataSource/Sources/Endpoint/YouthPolicyEndpoint.swift new file mode 100644 index 0000000..bc47ddb --- /dev/null +++ b/Projects/DataSource/Sources/Endpoint/YouthPolicyEndpoint.swift @@ -0,0 +1,75 @@ +// +// YouthPolicyEndpoint.swift +// DataSource +// + +enum YouthPolicyEndpoint { + case fetchPolicies(latitude: Double, longitude: Double, cursor: String?, size: Int?) + case fetchBookmarkedPolicies(cursor: String?, size: Int?) + case addBookmark(policyNumber: String) + case removeBookmark(policyNumber: String) +} + +extension YouthPolicyEndpoint: Endpoint { + var baseURL: String { + return AppProperties.baseURL + "/api/v1/youth-policies" + } + + var path: String { + switch self { + case .fetchPolicies: + return baseURL + case .fetchBookmarkedPolicies: + return "\(baseURL)/bookmarks" + case .addBookmark(let policyNumber), .removeBookmark(let policyNumber): + return "\(baseURL)/\(policyNumber)/bookmark" + } + } + + var method: HTTPMethod { + switch self { + case .fetchPolicies, .fetchBookmarkedPolicies: + return .get + case .addBookmark: + return .post + case .removeBookmark: + return .delete + } + } + + var headers: [String: String] { + let headers: [String: String] = [ + "Content-Type": "application/json", + "accept": "*/*" + ] + return headers + } + + var queryParameters: [String: String] { + switch self { + case .fetchPolicies(let latitude, let longitude, let cursor, let size): + var parameters = [ + "latitude": "\(latitude)", + "longitude": "\(longitude)" + ] + if let cursor { parameters["cursor"] = cursor } + if let size { parameters["size"] = "\(size)" } + return parameters + case .fetchBookmarkedPolicies(let cursor, let size): + var parameters: [String: String] = [:] + if let cursor { parameters["cursor"] = cursor } + if let size { parameters["size"] = "\(size)" } + return parameters + case .addBookmark, .removeBookmark: + return [:] + } + } + + var bodyParameters: [String: Any] { + return [:] + } + + var isAuthorized: Bool { + return true + } +} diff --git a/Projects/DataSource/Sources/Repository/YouthPolicyRepository.swift b/Projects/DataSource/Sources/Repository/YouthPolicyRepository.swift new file mode 100644 index 0000000..ff0aeb3 --- /dev/null +++ b/Projects/DataSource/Sources/Repository/YouthPolicyRepository.swift @@ -0,0 +1,81 @@ +// +// YouthPolicyRepository.swift +// DataSource +// + +import Domain +import Foundation + +final class YouthPolicyRepository: YouthPolicyRepositoryProtocol { + private let networkService = NetworkService.shared + + func fetchPolicies( + latitude: Double, + longitude: Double, + cursor: String?, + size: Int? + ) async throws -> YouthPolicyPageEntity { + let endpoint = YouthPolicyEndpoint.fetchPolicies( + latitude: latitude, + longitude: longitude, + cursor: cursor, + size: size) + + return try await fetchPage(endpoint: endpoint) + } + + func fetchBookmarkedPolicies( + cursor: String?, + size: Int? + ) async throws -> YouthPolicyPageEntity { + let endpoint = YouthPolicyEndpoint.fetchBookmarkedPolicies(cursor: cursor, size: size) + + return try await fetchPage(endpoint: endpoint) + } + + func addBookmark(policyNumber: String) async throws { + let endpoint = YouthPolicyEndpoint.addBookmark(policyNumber: policyNumber) + + try await updateBookmark(endpoint: endpoint) + } + + func removeBookmark(policyNumber: String) async throws { + let endpoint = YouthPolicyEndpoint.removeBookmark(policyNumber: policyNumber) + + try await updateBookmark(endpoint: endpoint) + } + + private func fetchPage(endpoint: YouthPolicyEndpoint) async throws -> YouthPolicyPageEntity { + do { + guard let response = try await networkService.request(endpoint: endpoint, type: YouthPolicyPageDTO.self) + else { return YouthPolicyPageEntity(policies: [], totalCount: 0, hasNext: false, nextCursor: nil) } + + return response.toYouthPolicyPageEntity() + } catch let error as NetworkError { + throw error.toDomainError() + } catch { + throw DomainError.unknown + } + } + + private func updateBookmark(endpoint: YouthPolicyEndpoint) async throws { + do { + _ = try await networkService.request(endpoint: endpoint, type: EmptyResponseDTO.self) + } catch let error as NetworkError { + throw error.toDomainError() + } catch { + throw DomainError.unknown + } + } +} + +private extension NetworkError { + func toDomainError() -> DomainError { + switch self { + case .needRetry, .invalidURL, .emptyData: + return DomainError.requireRetry + default: + return DomainError.business(description) + } + } +} diff --git a/Projects/Domain/Sources/DomainDependencyAssembler.swift b/Projects/Domain/Sources/DomainDependencyAssembler.swift index 8cc4def..ff4d081 100644 --- a/Projects/Domain/Sources/DomainDependencyAssembler.swift +++ b/Projects/Domain/Sources/DomainDependencyAssembler.swift @@ -75,5 +75,16 @@ public struct DomainDependencyAssembler: DependencyAssemblerProtocol { reportRepository: reportRepository, fileRepository: fileRepository) } + + DIContainer.shared.register(type: YouthPolicyUseCaseProtocol.self) { container in + guard + let youthPolicyRepository = container.resolve(type: YouthPolicyRepositoryProtocol.self), + let locationRepository = container.resolve(type: LocationRepositoryProtocol.self) + else { fatalError("youthPolicyUseCase에 필요한 의존성이 등록되지 않았습니다.") } + + return YouthPolicyUseCase( + youthPolicyRepository: youthPolicyRepository, + locationRepository: locationRepository) + } } } diff --git a/Projects/Domain/Sources/Entity/Enum/YouthPolicyStatus.swift b/Projects/Domain/Sources/Entity/Enum/YouthPolicyStatus.swift new file mode 100644 index 0000000..f872581 --- /dev/null +++ b/Projects/Domain/Sources/Entity/Enum/YouthPolicyStatus.swift @@ -0,0 +1,13 @@ +// +// YouthPolicyStatus.swift +// Domain +// + +public enum YouthPolicyStatus: String { + /// 신청 기간 내 (마감 전) + case open = "OPEN" + /// 상시 모집 + case always = "ALWAYS" + /// 마감됨. 찜 목록에서만 내려옵니다. + case closed = "CLOSED" +} diff --git a/Projects/Domain/Sources/Entity/YouthPolicyEntity.swift b/Projects/Domain/Sources/Entity/YouthPolicyEntity.swift new file mode 100644 index 0000000..3517130 --- /dev/null +++ b/Projects/Domain/Sources/Entity/YouthPolicyEntity.swift @@ -0,0 +1,50 @@ +// +// YouthPolicyEntity.swift +// Domain +// + +import Foundation + +public struct YouthPolicyEntity { + /// 공고 고유 번호. 찜 등록/해제 시 이 값을 사용합니다. + public let policyNumber: String + public let title: String + /// 대분류(일자리·주거·교육 등). 일부 공고에서 nil. + public let category: String? + /// 원본 API에 이미지가 없어 현재는 항상 nil입니다. 클라이언트 기본 이미지를 사용하세요. + public let thumbnailURL: String? + public let status: YouthPolicyStatus + /// 신청 시작일. 상시/마감 공고에서 nil 가능. + public let startDate: Date? + /// 신청 마감일. 상시 공고는 nil. + public let endDate: Date? + /// 마감까지 남은 일수(오늘 = 0). status가 open이 아니면 nil. + public let dday: Int? + /// 외부 신청 페이지 URL. 없을 수 있습니다. + public let applyURL: String? + public let isBookmarked: Bool + + public init( + policyNumber: String, + title: String, + category: String?, + thumbnailURL: String?, + status: YouthPolicyStatus, + startDate: Date?, + endDate: Date?, + dday: Int?, + applyURL: String?, + isBookmarked: Bool + ) { + self.policyNumber = policyNumber + self.title = title + self.category = category + self.thumbnailURL = thumbnailURL + self.status = status + self.startDate = startDate + self.endDate = endDate + self.dday = dday + self.applyURL = applyURL + self.isBookmarked = isBookmarked + } +} diff --git a/Projects/Domain/Sources/Entity/YouthPolicyPageEntity.swift b/Projects/Domain/Sources/Entity/YouthPolicyPageEntity.swift new file mode 100644 index 0000000..84d99f8 --- /dev/null +++ b/Projects/Domain/Sources/Entity/YouthPolicyPageEntity.swift @@ -0,0 +1,25 @@ +// +// YouthPolicyPageEntity.swift +// Domain +// + +public struct YouthPolicyPageEntity { + public let policies: [YouthPolicyEntity] + /// 필터링된 전체 건수. 탭의 "전체 N" 표기용이며 페이지마다 동일하게 내려옵니다. + public let totalCount: Int + public let hasNext: Bool + /// 다음 페이지 요청에 그대로 넘길 불투명 토큰. hasNext가 false면 nil. + public let nextCursor: String? + + public init( + policies: [YouthPolicyEntity], + totalCount: Int, + hasNext: Bool, + nextCursor: String? + ) { + self.policies = policies + self.totalCount = totalCount + self.hasNext = hasNext + self.nextCursor = nextCursor + } +} diff --git a/Projects/Domain/Sources/Protocol/Repository/YouthPolicyRepositoryProtocol.swift b/Projects/Domain/Sources/Protocol/Repository/YouthPolicyRepositoryProtocol.swift new file mode 100644 index 0000000..9f6d37d --- /dev/null +++ b/Projects/Domain/Sources/Protocol/Repository/YouthPolicyRepositoryProtocol.swift @@ -0,0 +1,39 @@ +// +// YouthPolicyRepositoryProtocol.swift +// Domain +// + +public protocol YouthPolicyRepositoryProtocol { + + /// 현위치 기준 청년 공고 목록을 조회합니다. 마감된 공고는 제외됩니다. + /// - Parameters: + /// - latitude: 현재 위도 + /// - longitude: 현재 경도 + /// - cursor: 이전 응답의 nextCursor. 첫 페이지는 nil + /// - size: 페이지 크기. nil이면 서버 기본값(10) 사용 + /// - Returns: 공고 페이지 + func fetchPolicies( + latitude: Double, + longitude: Double, + cursor: String?, + size: Int? + ) async throws -> YouthPolicyPageEntity + + /// 찜한 공고 목록을 조회합니다. 지역과 무관하며 마감된 공고도 포함됩니다. + /// - Parameters: + /// - cursor: 이전 응답의 nextCursor. 첫 페이지는 nil + /// - size: 페이지 크기. nil이면 서버 기본값(10) 사용 + /// - Returns: 공고 페이지 + func fetchBookmarkedPolicies( + cursor: String?, + size: Int? + ) async throws -> YouthPolicyPageEntity + + /// 공고를 찜 목록에 추가합니다. 멱등이므로 이미 찜한 공고를 다시 호출해도 성공합니다. + /// - Parameter policyNumber: 공고 번호 + func addBookmark(policyNumber: String) async throws + + /// 공고를 찜 목록에서 제거합니다. 멱등이므로 찜하지 않은 공고를 해제해도 성공합니다. + /// - Parameter policyNumber: 공고 번호 + func removeBookmark(policyNumber: String) async throws +} diff --git a/Projects/Domain/Sources/Protocol/UseCase/YouthPolicyUseCaseProtocol.swift b/Projects/Domain/Sources/Protocol/UseCase/YouthPolicyUseCaseProtocol.swift new file mode 100644 index 0000000..927454d --- /dev/null +++ b/Projects/Domain/Sources/Protocol/UseCase/YouthPolicyUseCaseProtocol.swift @@ -0,0 +1,27 @@ +// +// YouthPolicyUseCaseProtocol.swift +// Domain +// + +public protocol YouthPolicyUseCaseProtocol { + + /// 현위치를 조회한 뒤 해당 지역의 청년 공고 목록을 가져옵니다. + /// - Parameters: + /// - cursor: 이전 응답의 nextCursor. 첫 페이지는 nil + /// - size: 페이지 크기. nil이면 서버 기본값 사용 + /// - Returns: 공고 페이지. 위치 권한이 없거나 좌표를 얻지 못하면 nil + func fetchPolicies(cursor: String?, size: Int?) async throws -> YouthPolicyPageEntity? + + /// 찜한 공고 목록을 가져옵니다. + /// - Parameters: + /// - cursor: 이전 응답의 nextCursor. 첫 페이지는 nil + /// - size: 페이지 크기. nil이면 서버 기본값 사용 + /// - Returns: 공고 페이지 + func fetchBookmarkedPolicies(cursor: String?, size: Int?) async throws -> YouthPolicyPageEntity + + /// 공고의 찜 상태를 변경합니다. + /// - Parameters: + /// - policyNumber: 공고 번호 + /// - isBookmarked: 변경할 찜 상태. true면 등록, false면 해제 + func updateBookmark(policyNumber: String, isBookmarked: Bool) async throws +} diff --git a/Projects/Domain/Sources/UseCase/YouthPolicy/YouthPolicyUseCase.swift b/Projects/Domain/Sources/UseCase/YouthPolicy/YouthPolicyUseCase.swift new file mode 100644 index 0000000..bb37117 --- /dev/null +++ b/Projects/Domain/Sources/UseCase/YouthPolicy/YouthPolicyUseCase.swift @@ -0,0 +1,43 @@ +// +// YouthPolicyUseCase.swift +// Domain +// + +public final class YouthPolicyUseCase: YouthPolicyUseCaseProtocol { + private let youthPolicyRepository: YouthPolicyRepositoryProtocol + private let locationRepository: LocationRepositoryProtocol + + public init( + youthPolicyRepository: YouthPolicyRepositoryProtocol, + locationRepository: LocationRepositoryProtocol + ) { + self.youthPolicyRepository = youthPolicyRepository + self.locationRepository = locationRepository + } + + public func fetchPolicies(cursor: String?, size: Int?) async throws -> YouthPolicyPageEntity? { + guard + let coordinate = await locationRepository.fetchCoordinate(), + let latitude = coordinate.latitude, + let longitude = coordinate.longitude + else { return nil } + + return try await youthPolicyRepository.fetchPolicies( + latitude: latitude, + longitude: longitude, + cursor: cursor, + size: size) + } + + public func fetchBookmarkedPolicies(cursor: String?, size: Int?) async throws -> YouthPolicyPageEntity { + return try await youthPolicyRepository.fetchBookmarkedPolicies(cursor: cursor, size: size) + } + + public func updateBookmark(policyNumber: String, isBookmarked: Bool) async throws { + if isBookmarked { + try await youthPolicyRepository.addBookmark(policyNumber: policyNumber) + } else { + try await youthPolicyRepository.removeBookmark(policyNumber: policyNumber) + } + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/Common/heart_empty_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/Common/heart_empty_icon.imageset/Contents.json new file mode 100644 index 0000000..7a21692 --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/Common/heart_empty_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "heart_empty_icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "heart_empty_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "heart_empty_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/Common/heart_empty_icon.imageset/heart_empty_icon.png b/Projects/Presentation/Resources/Images.xcassets/Common/heart_empty_icon.imageset/heart_empty_icon.png new file mode 100644 index 0000000..3d61aea Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Common/heart_empty_icon.imageset/heart_empty_icon.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/Common/heart_empty_icon.imageset/heart_empty_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/Common/heart_empty_icon.imageset/heart_empty_icon@2x.png new file mode 100644 index 0000000..690796d Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Common/heart_empty_icon.imageset/heart_empty_icon@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/Common/heart_empty_icon.imageset/heart_empty_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/Common/heart_empty_icon.imageset/heart_empty_icon@3x.png new file mode 100644 index 0000000..34c37a5 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Common/heart_empty_icon.imageset/heart_empty_icon@3x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/Common/heart_filled_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/Common/heart_filled_icon.imageset/Contents.json new file mode 100644 index 0000000..864fbcb --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/Common/heart_filled_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "heart_filled_icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "heart_filled_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "heart_filled_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/Common/heart_filled_icon.imageset/heart_filled_icon.png b/Projects/Presentation/Resources/Images.xcassets/Common/heart_filled_icon.imageset/heart_filled_icon.png new file mode 100644 index 0000000..e471c0f Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Common/heart_filled_icon.imageset/heart_filled_icon.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/Common/heart_filled_icon.imageset/heart_filled_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/Common/heart_filled_icon.imageset/heart_filled_icon@2x.png new file mode 100644 index 0000000..b28c926 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Common/heart_filled_icon.imageset/heart_filled_icon@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/Common/heart_filled_icon.imageset/heart_filled_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/Common/heart_filled_icon.imageset/heart_filled_icon@3x.png new file mode 100644 index 0000000..28e8af8 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Common/heart_filled_icon.imageset/heart_filled_icon@3x.png differ diff --git a/Projects/Presentation/Sources/ActivityHistory/View/ActivityHistoryViewController.swift b/Projects/Presentation/Sources/ActivityHistory/View/ActivityHistoryViewController.swift index f01e506..2de697c 100644 --- a/Projects/Presentation/Sources/ActivityHistory/View/ActivityHistoryViewController.swift +++ b/Projects/Presentation/Sources/ActivityHistory/View/ActivityHistoryViewController.swift @@ -8,6 +8,7 @@ import Combine import Domain import FSCalendar +import Shared import SnapKit import UIKit @@ -92,6 +93,11 @@ final class ActivityHistoryViewController: BaseViewController String { + let startText = startDate?.convertToString(dateType: .yearMonthDateShort) + let endText = endDate?.convertToString(dateType: .yearMonthDateShort) + + switch (startText, endText) { + case (let start?, let end?): + return "\(start) ~ \(end)" + case (let start?, nil): + return "\(start) ~" + case (nil, let end?): + return "~ \(end)" + case (nil, nil): + return "" + } + } +} diff --git a/Projects/Presentation/Sources/YouthPolicy/Model/YouthPolicyTab.swift b/Projects/Presentation/Sources/YouthPolicy/Model/YouthPolicyTab.swift new file mode 100644 index 0000000..e5c719b --- /dev/null +++ b/Projects/Presentation/Sources/YouthPolicy/Model/YouthPolicyTab.swift @@ -0,0 +1,28 @@ +// +// YouthPolicyTab.swift +// Presentation +// + +enum YouthPolicyTab: CaseIterable { + case entire + case bookmarked + + var description: String { + switch self { + case .entire: + "전체" + case .bookmarked: + "찜한 공고" + } + } + + /// 탭 이름 옆에 공고 개수를 노출할지 여부입니다. 전체 탭만 노출합니다. + var showsCount: Bool { + switch self { + case .entire: + true + case .bookmarked: + false + } + } +} diff --git a/Projects/Presentation/Sources/YouthPolicy/Model/YouthPolicyTabItem.swift b/Projects/Presentation/Sources/YouthPolicy/Model/YouthPolicyTabItem.swift new file mode 100644 index 0000000..39ae70a --- /dev/null +++ b/Projects/Presentation/Sources/YouthPolicy/Model/YouthPolicyTabItem.swift @@ -0,0 +1,12 @@ +// +// YouthPolicyTabItem.swift +// Presentation +// + +import Foundation + +struct YouthPolicyTabItem: Hashable { + let tab: YouthPolicyTab + let count: Int + var isSelected: Bool +} diff --git a/Projects/Presentation/Sources/YouthPolicy/View/Component/YouthPolicyBadgeView.swift b/Projects/Presentation/Sources/YouthPolicy/View/Component/YouthPolicyBadgeView.swift new file mode 100644 index 0000000..a91fda9 --- /dev/null +++ b/Projects/Presentation/Sources/YouthPolicy/View/Component/YouthPolicyBadgeView.swift @@ -0,0 +1,64 @@ +// +// YouthPolicyBadgeView.swift +// Presentation +// + +import SnapKit +import UIKit + +final class YouthPolicyBadgeView: UIView { + private enum Layout { + static let badgeViewHeight: CGFloat = 26 + static let badgeLabelHeight: CGFloat = 18 + static let badgeLabelHorizontalSpacing: CGFloat = 10 + static let badgeLabelVerticalSpacing: CGFloat = 4 + static let cornerRadius: CGFloat = 6 + } + + private let badgeLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + + configureAttribute() + configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureAttribute() { + layer.cornerRadius = Layout.cornerRadius + layer.masksToBounds = true + + badgeLabel.font = BitnagilFont.init(style: .caption1, weight: .semiBold).font + } + + private func configureLayout() { + addSubview(badgeLabel) + + self.snp.makeConstraints { make in + make.height.equalTo(Layout.badgeViewHeight) + } + + badgeLabel.snp.makeConstraints { make in + make.height.equalTo(Layout.badgeLabelHeight) + + make.verticalEdges + .equalToSuperview() + .inset(Layout.badgeLabelVerticalSpacing) + + make.horizontalEdges + .equalToSuperview() + .inset(Layout.badgeLabelHorizontalSpacing) + } + } + + func configure(with badge: YouthPolicyBadge) { + backgroundColor = badge.backgroundColor + + badgeLabel.textColor = badge.titleColor + badgeLabel.text = badge.description + } +} diff --git a/Projects/Presentation/Sources/YouthPolicy/View/Component/YouthPolicyEmptyView.swift b/Projects/Presentation/Sources/YouthPolicy/View/Component/YouthPolicyEmptyView.swift new file mode 100644 index 0000000..e6aab92 --- /dev/null +++ b/Projects/Presentation/Sources/YouthPolicy/View/Component/YouthPolicyEmptyView.swift @@ -0,0 +1,75 @@ +// +// YouthPolicyEmptyView.swift +// Presentation +// + +import SnapKit +import UIKit + +final class YouthPolicyEmptyView: UIView { + private enum Layout { + static let semiBoldLabelHeight: CGFloat = 28 + static let regularLabelHeight: CGFloat = 20 + static let stackViewHeight: CGFloat = 50 + static let stackViewWidth: CGFloat = 269 + } + + private let labelStackView = UIStackView() + private let semiBoldLabel = UILabel() + private let regularLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + + configureAttribute() + configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureAttribute() { + backgroundColor = BitnagilColor.gray99 + + labelStackView.axis = .vertical + labelStackView.alignment = .center + + semiBoldLabel.font = BitnagilFont.init(style: .subtitle1, weight: .semiBold).font + semiBoldLabel.textColor = BitnagilColor.gray30 + + regularLabel.font = BitnagilFont.init(style: .body2, weight: .regular).font + regularLabel.textColor = BitnagilColor.gray70 + } + + private func configureLayout() { + addSubview(labelStackView) + labelStackView.addArrangedSubview(semiBoldLabel) + labelStackView.addArrangedSubview(regularLabel) + + labelStackView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.height.equalTo(Layout.stackViewHeight).priority(.medium) + make.width.equalTo(Layout.stackViewWidth) + } + + semiBoldLabel.snp.makeConstraints { make in + make.height.equalTo(Layout.semiBoldLabelHeight) + } + + regularLabel.snp.makeConstraints { make in + make.height.equalTo(Layout.regularLabelHeight) + } + } + + func configure(with tab: YouthPolicyTab) { + switch tab { + case .entire: + semiBoldLabel.text = "아직 공고가 없어요." + regularLabel.text = "곧 새로운 소식을 가져올게요!" + case .bookmarked: + semiBoldLabel.text = "찜한 공고가 없어요." + regularLabel.text = "관심있는 공고를 찜해보세요!" + } + } +} diff --git a/Projects/Presentation/Sources/YouthPolicy/View/Component/YouthPolicyTabCollectionViewCell.swift b/Projects/Presentation/Sources/YouthPolicy/View/Component/YouthPolicyTabCollectionViewCell.swift new file mode 100644 index 0000000..942d001 --- /dev/null +++ b/Projects/Presentation/Sources/YouthPolicy/View/Component/YouthPolicyTabCollectionViewCell.swift @@ -0,0 +1,61 @@ +// +// YouthPolicyTabCollectionViewCell.swift +// Presentation +// + +import SnapKit +import UIKit + +final class YouthPolicyTabCollectionViewCell: UICollectionViewCell { + private enum Layout { + static let labelHorizontalSpacing: CGFloat = 14 + static let labelVerticalSpacing: CGFloat = 9 + static let cornerRadius: CGFloat = 18 + } + + private let titleLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + + configureAttribute() + configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureAttribute() { + titleLabel.font = BitnagilFont.init( + style: .caption1, + weight: .semiBold + ).font + + contentView.layer.cornerRadius = Layout.cornerRadius + contentView.layer.masksToBounds = true + } + + private func configureLayout() { + contentView.addSubview(titleLabel) + + titleLabel.snp.makeConstraints { make in + make.horizontalEdges + .equalToSuperview() + .inset(Layout.labelHorizontalSpacing) + + make.verticalEdges + .equalToSuperview() + .inset(Layout.labelVerticalSpacing) + } + } + + func configure(with item: YouthPolicyTabItem) { + let countText = item.tab.showsCount ? " \(item.count)" : "" + + titleLabel.text = "\(item.tab.description)\(countText)" + + contentView.backgroundColor = item.isSelected ? BitnagilColor.gray10 : .white + titleLabel.textColor = item.isSelected ? .white : BitnagilColor.gray60 + } +} diff --git a/Projects/Presentation/Sources/YouthPolicy/View/Component/YouthPolicyTableViewCell.swift b/Projects/Presentation/Sources/YouthPolicy/View/Component/YouthPolicyTableViewCell.swift new file mode 100644 index 0000000..8674ea1 --- /dev/null +++ b/Projects/Presentation/Sources/YouthPolicy/View/Component/YouthPolicyTableViewCell.swift @@ -0,0 +1,158 @@ +// +// YouthPolicyTableViewCell.swift +// Presentation +// +// Created by 이동현 on 7/19/26. +// + +import SnapKit +import UIKit + +final class YouthPolicyTableViewCell: UITableViewCell { + private enum Layout { + static let horizontalSpacing: CGFloat = 16 + static let verticalSpacing: CGFloat = 14 + static let containerViewBottomSpacing: CGFloat = 10 + static let containerViewCornerRadius: CGFloat = 12 + static let titleLabelTopSpacing: CGFloat = 8 + static let titleLabelTrailingSpacing: CGFloat = 14 + static let periodLabelTopSpacing: CGFloat = 12 + /// 하트 아이콘의 실제 크기입니다. + static let bookmarkIconSize: CGFloat = 24 + /// 터치 영역입니다. 아이콘보다 크게 잡아 셀 선택으로 잘못 빠지는 것을 막습니다. + static let bookmarkButtonSize: CGFloat = 44 + /// 아이콘이 horizontalSpacing 위치에 놓이도록 버튼이 커진 만큼 당겨줍니다. + static let bookmarkButtonTrailingSpacing: CGFloat = horizontalSpacing - (bookmarkButtonSize - bookmarkIconSize) / 2 + } + + private let containerView = UIView() + private let badgeView = YouthPolicyBadgeView() + private let bookmarkButton = UIButton() + private let titleLabel = UILabel() + private let periodLabel = UILabel() + + /// 하트 버튼을 눌렀을 때 실행할 동작입니다. 셀을 구성할 때 주입합니다. + private var onBookmarkTap: (() -> Void)? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + configureLayout() + configureAttribute() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + onBookmarkTap = nil + } + + private func configureAttribute() { + backgroundColor = .clear + selectionStyle = .none + + containerView.backgroundColor = .white + containerView.layer.cornerRadius = Layout.containerViewCornerRadius + containerView.layer.masksToBounds = true + + titleLabel.numberOfLines = 2 + titleLabel.textColor = BitnagilColor.gray10 + titleLabel.font = BitnagilFont.init(style: .body2, weight: .semiBold).font + titleLabel.textAlignment = .left + + periodLabel.textColor = BitnagilColor.gray70 + periodLabel.font = BitnagilFont.init(style: .body2, weight: .regular).font + + bookmarkButton.addAction( + UIAction { [weak self] _ in + self?.onBookmarkTap?() + }, + for: .touchUpInside) + } + + private func configureLayout() { + contentView.addSubview(containerView) + containerView.addSubview(badgeView) + containerView.addSubview(bookmarkButton) + containerView.addSubview(titleLabel) + containerView.addSubview(periodLabel) + + containerView.snp.makeConstraints { make in + make.top.horizontalEdges.equalToSuperview() + + make.bottom + .equalToSuperview() + .offset(-Layout.containerViewBottomSpacing) + } + + badgeView.snp.makeConstraints { make in + make.leading + .equalToSuperview() + .offset(Layout.horizontalSpacing) + + make.top + .equalToSuperview() + .offset(Layout.verticalSpacing) + } + + bookmarkButton.snp.makeConstraints { make in + make.trailing + .equalToSuperview() + .offset(-Layout.bookmarkButtonTrailingSpacing) + + make.centerY.equalTo(badgeView) + + make.size.equalTo(Layout.bookmarkButtonSize) + } + + titleLabel.snp.makeConstraints { make in + make.top + .equalTo(badgeView.snp.bottom) + .offset(Layout.titleLabelTopSpacing) + + make.leading + .equalToSuperview() + .offset(Layout.horizontalSpacing) + + // 버튼은 터치 영역만 넓힌 상태라, 눈에 보이는 아이콘 위치를 기준으로 잡습니다. + make.trailing + .equalToSuperview() + .offset(-(Layout.horizontalSpacing + Layout.bookmarkIconSize + Layout.titleLabelTrailingSpacing)) + } + + periodLabel.snp.makeConstraints { make in + make.top + .equalTo(titleLabel.snp.bottom) + .offset(Layout.periodLabelTopSpacing) + + make.leading + .equalToSuperview() + .offset(Layout.horizontalSpacing) + + make.trailing.equalTo(titleLabel.snp.trailing) + + make.bottom + .equalToSuperview() + .offset(-Layout.verticalSpacing) + } + } + + func configure(with item: YouthPolicyItem, onBookmarkTap: @escaping () -> Void) { + badgeView.configure(with: item.badge) + + titleLabel.text = item.title + + periodLabel.text = item.periodText + periodLabel.isHidden = item.periodText.isEmpty + + bookmarkButton.setImage( + item.isBookmarked ? BitnagilIcon.heartFilledIcon : BitnagilIcon.heartEmptyIcon, + for: .normal) + + self.onBookmarkTap = onBookmarkTap + } +} diff --git a/Projects/Presentation/Sources/YouthPolicy/View/YouthPolicyViewController.swift b/Projects/Presentation/Sources/YouthPolicy/View/YouthPolicyViewController.swift new file mode 100644 index 0000000..18226a8 --- /dev/null +++ b/Projects/Presentation/Sources/YouthPolicy/View/YouthPolicyViewController.swift @@ -0,0 +1,263 @@ +// +// YouthPolicyViewController.swift +// Presentation +// +// Created by 이동현 on 7/19/26. +// + +import Combine +import SnapKit +import UIKit + +final class YouthPolicyViewController: BaseViewController { + private enum Layout { + static let horizontalSpacing: CGFloat = 20 + static let tabCollectionViewTopSpacing: CGFloat = 80 + static let tabCollectionViewHeight: CGFloat = 36 + static let tabCollectionViewWidth: CGFloat = 48 + static let tabCellSpacing: CGFloat = 8 + static let policyTableViewTopSpacing: CGFloat = 34 + static let policyTableViewCellHeight: CGFloat = 122 + static let emptyViewHeight: CGFloat = 50 + static let emptyViewWidth: CGFloat = 269 + } + + private enum TabSection { + case main + } + + private enum PolicySection { + case main + } + + private let tabCollectionView = UICollectionView(frame: .zero, collectionViewLayout: .init()) + private let policyTableView = UITableView(frame: .zero, style: .plain) + private let policyEmptyView = YouthPolicyEmptyView() + private var tabDataSource: UICollectionViewDiffableDataSource? + private var policyDataSource: UITableViewDiffableDataSource? + private var cancellables: Set = [] + + override func viewDidLoad() { + super.viewDidLoad() + + viewModel.action(input: .fetchPolicies) + navigationController?.navigationBar.isHidden = true + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + configureCustomNavigationBar( + navigationBarStyle: .withBackButton(title: "청년 공고"), + backgroundColor: BitnagilColor.gray99) + } + + override func configureAttribute() { + super.configureAttribute() + + view.backgroundColor = BitnagilColor.gray99 + + policyEmptyView.isHidden = true + + configureTabCollectionView() + configurePolicyTableView() + } + + override func configureLayout() { + super.configureLayout() + let safeArea = view.safeAreaLayoutGuide + + view.addSubview(tabCollectionView) + view.addSubview(policyTableView) + view.addSubview(policyEmptyView) + + tabCollectionView.snp.makeConstraints { make in + make.top + .equalTo(safeArea.snp.top) + .offset(Layout.tabCollectionViewTopSpacing) + + make.horizontalEdges + .equalToSuperview() + .inset(Layout.horizontalSpacing) + + make.height.equalTo(Layout.tabCollectionViewHeight) + } + + policyTableView.snp.makeConstraints { make in + make.horizontalEdges + .equalToSuperview() + .inset(Layout.horizontalSpacing) + + make.top + .equalTo(tabCollectionView.snp.bottom) + .offset(Layout.policyTableViewTopSpacing) + + make.bottom.equalToSuperview() + } + + policyEmptyView.snp.makeConstraints { make in + make.center.equalToSuperview() + + make.width.equalTo(Layout.emptyViewWidth) + + make.height.equalTo(Layout.emptyViewHeight) + } + } + + override func bind() { + viewModel.output.tabsPublisher + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] tabs in + self?.applyTabSnapshot(items: tabs) + }) + .store(in: &cancellables) + + viewModel.output.policiesPublisher + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] policies in + self?.applyPolicySnapshot(policies: policies) + }) + .store(in: &cancellables) + + viewModel.output.isEmptyPublisher + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] isEmpty in + guard let self else { return } + + self.policyEmptyView.configure(with: self.viewModel.selectedTab) + self.policyEmptyView.isHidden = !isEmpty + }) + .store(in: &cancellables) + + bindNetworkError(from: viewModel.output.networkErrorPublisher) + } + + private func configureTabCollectionView() { + tabCollectionView.backgroundColor = .clear + tabCollectionView.bounces = false + tabCollectionView.showsHorizontalScrollIndicator = false + + tabCollectionView.setCollectionViewLayout(createTabLayout(), animated: false) + + tabCollectionView.register( + YouthPolicyTabCollectionViewCell.self, + forCellWithReuseIdentifier: YouthPolicyTabCollectionViewCell.className) + + tabDataSource = UICollectionViewDiffableDataSource(collectionView: tabCollectionView) { collectionView, indexPath, item in + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: YouthPolicyTabCollectionViewCell.className, + for: indexPath) as? YouthPolicyTabCollectionViewCell + else { return UICollectionViewCell() } + + cell.configure(with: item) + return cell + } + + tabCollectionView.delegate = self + } + + private func createTabLayout() -> UICollectionViewLayout { + let itemSize = NSCollectionLayoutSize( + widthDimension: .estimated(Layout.tabCollectionViewWidth), + heightDimension: .fractionalHeight(1.0) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let groupSize = NSCollectionLayoutSize( + widthDimension: .estimated(Layout.tabCollectionViewWidth), + heightDimension: .fractionalHeight(1.0) + ) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + group.interItemSpacing = .fixed(Layout.tabCellSpacing) + + let section = NSCollectionLayoutSection(group: group) + section.orthogonalScrollingBehavior = .continuous + section.interGroupSpacing = Layout.tabCellSpacing + section.contentInsets = .zero + + return UICollectionViewCompositionalLayout(section: section) + } + + private func configurePolicyTableView() { + policyTableView.backgroundColor = .clear + policyTableView.separatorStyle = .none + policyTableView.showsVerticalScrollIndicator = false + policyTableView.rowHeight = UITableView.automaticDimension + policyTableView.estimatedRowHeight = Layout.policyTableViewCellHeight + policyTableView.sectionHeaderTopPadding = CGFloat.zero + policyTableView.sectionHeaderHeight = CGFloat.zero + policyTableView.sectionFooterHeight = CGFloat.zero + policyTableView.estimatedSectionHeaderHeight = CGFloat.zero + policyTableView.estimatedSectionFooterHeight = CGFloat.zero + policyTableView.contentInset = .zero + + policyTableView.register( + YouthPolicyTableViewCell.self, + forCellReuseIdentifier: YouthPolicyTableViewCell.className) + + policyDataSource = UITableViewDiffableDataSource(tableView: policyTableView) { [weak self] tableView, indexPath, item in + guard let cell = tableView.dequeueReusableCell( + withIdentifier: YouthPolicyTableViewCell.className, + for: indexPath) as? YouthPolicyTableViewCell + else { return UITableViewCell() } + + cell.configure(with: item) { [weak self] in + self?.viewModel.action(input: .toggleBookmark(policyNumber: item.policyNumber)) + } + + return cell + } + + policyTableView.dataSource = policyDataSource + policyTableView.delegate = self + } + + private func applyTabSnapshot(items: [YouthPolicyTabItem]) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(items, toSection: .main) + tabDataSource?.apply(snapshot, animatingDifferences: false) + } + + private func applyPolicySnapshot(policies: [YouthPolicyItem]) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(policies, toSection: .main) + policyDataSource?.apply(snapshot, animatingDifferences: false) + } +} + +extension YouthPolicyViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard + let snapshot = tabDataSource?.snapshot(), + indexPath.item < snapshot.itemIdentifiers.count + else { return } + + let item = snapshot.itemIdentifiers[indexPath.item] + + viewModel.action(input: .selectTab(tab: item.tab)) + } +} + +extension YouthPolicyViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + defer { tableView.deselectRow(at: indexPath, animated: true) } + + guard + let item = policyDataSource?.itemIdentifier(for: indexPath), + let applyURL = item.applyURL, + UIApplication.shared.canOpenURL(applyURL) + else { return } + + UIApplication.shared.open(applyURL) + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard + let snapshot = policyDataSource?.snapshot(), + indexPath.row == snapshot.numberOfItems - 1 + else { return } + + viewModel.action(input: .loadNextPage) + } +} diff --git a/Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift b/Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift new file mode 100644 index 0000000..7455a2f --- /dev/null +++ b/Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift @@ -0,0 +1,272 @@ +// +// YouthPolicyViewModel.swift +// Presentation +// +// Created by 이동현 on 7/19/26. +// + +import Combine +import Domain +import Foundation + +final class YouthPolicyViewModel: ViewModel { + enum Input { + case fetchPolicies + case selectTab(tab: YouthPolicyTab) + case loadNextPage + case toggleBookmark(policyNumber: String) + } + + struct Output { + let tabsPublisher: AnyPublisher<[YouthPolicyTabItem], Never> + let policiesPublisher: AnyPublisher<[YouthPolicyItem], Never> + let isEmptyPublisher: AnyPublisher + let networkErrorPublisher: AnyPublisher<(() -> Void)?, Never> + } + + /// 탭 하나의 목록과 커서 상태입니다. + private struct TabState { + var items: [YouthPolicyItem] = [] + var totalCount: Int = 0 + var nextCursor: String? + var hasNext: Bool = true + var isLoading: Bool = false + /// 다른 탭에서의 찜 변경으로 목록이 낡았는지 여부입니다. true면 탭 진입 시 다시 받아옵니다. + var isStale: Bool = false + /// 첫 페이지를 한 번이라도 받아왔는지 여부입니다. + var hasLoadedOnce: Bool = false + } + + /// 서버가 허용하는 최대 페이지 크기입니다. 한 번에 최대한 많이 받아 요청 횟수를 줄입니다. + private static let pageSize = 50 + + private(set) var output: Output + private(set) var selectedTab: YouthPolicyTab = .entire + + private let youthPolicyUseCase: YouthPolicyUseCaseProtocol + private let networkRetryHandler: NetworkRetryHandler + + private let tabsSubject = CurrentValueSubject<[YouthPolicyTabItem], Never>([]) + private let policiesSubject = CurrentValueSubject<[YouthPolicyItem], Never>([]) + private let isEmptySubject = CurrentValueSubject(false) + + private var entireState = TabState() + private var bookmarkedState = TabState() + + init(youthPolicyUseCase: YouthPolicyUseCaseProtocol) { + self.youthPolicyUseCase = youthPolicyUseCase + self.networkRetryHandler = NetworkRetryHandler() + + self.output = Output( + tabsPublisher: tabsSubject.eraseToAnyPublisher(), + policiesPublisher: policiesSubject.eraseToAnyPublisher(), + isEmptyPublisher: isEmptySubject.eraseToAnyPublisher(), + networkErrorPublisher: networkRetryHandler.networkErrorActionSubject.eraseToAnyPublisher()) + + sendTabs() + } + + func action(input: Input) { + switch input { + case .fetchPolicies: + fetchFirstPage(tab: selectedTab) + case .selectTab(let tab): + selectTab(tab: tab) + case .loadNextPage: + fetchNextPage(tab: selectedTab) + case .toggleBookmark(let policyNumber): + toggleBookmark(policyNumber: policyNumber) + } + } + + private func selectTab(tab: YouthPolicyTab) { + guard tab != selectedTab else { return } + + selectedTab = tab + sendTabs() + + let state = state(of: tab) + sendPolicies(state: state) + + // 아직 안 받았거나, 다른 탭에서의 찜 변경으로 낡았으면 다시 받아옵니다. + if !state.hasLoadedOnce || state.isStale { + fetchFirstPage(tab: tab) + } + } + + private func fetchFirstPage(tab: YouthPolicyTab) { + var state = state(of: tab) + guard !state.isLoading else { return } + + state.isLoading = true + updateState(state, of: tab) + + Task { [weak self] in + guard let self else { return } + + do { + let page = try await self.fetchPage(tab: tab, cursor: nil) + + var state = self.state(of: tab) + state.items = page?.policies.compactMap { YouthPolicyItem(entity: $0) } ?? [] + state.totalCount = page?.totalCount ?? 0 + state.nextCursor = page?.nextCursor + state.hasNext = page?.hasNext ?? false + state.isLoading = false + state.isStale = false + state.hasLoadedOnce = true + self.updateState(state, of: tab) + + self.networkRetryHandler.clearRetryState() + } catch { + var state = self.state(of: tab) + state.isLoading = false + state.hasLoadedOnce = true + self.updateState(state, of: tab) + + self.networkRetryHandler.handleNetworkError(error) { [weak self] in + self?.fetchFirstPage(tab: tab) + } + } + } + } + + private func fetchNextPage(tab: YouthPolicyTab) { + var state = state(of: tab) + + guard + !state.isLoading, + state.hasNext, + let cursor = state.nextCursor + else { return } + + state.isLoading = true + updateState(state, of: tab) + + Task { [weak self] in + guard let self else { return } + + do { + let page = try await self.fetchPage(tab: tab, cursor: cursor) + + var state = self.state(of: tab) + let newItems = page?.policies.compactMap { YouthPolicyItem(entity: $0) } ?? [] + + // 같은 공고가 두 번 들어오면 diffable data source가 크래시하므로 중복을 걸러냅니다. + let existingNumbers = Set(state.items.map { $0.policyNumber }) + state.items += newItems.filter { !existingNumbers.contains($0.policyNumber) } + + state.totalCount = page?.totalCount ?? state.totalCount + state.nextCursor = page?.nextCursor + state.hasNext = page?.hasNext ?? false + state.isLoading = false + self.updateState(state, of: tab) + + self.networkRetryHandler.clearRetryState() + } catch { + var state = self.state(of: tab) + state.isLoading = false + self.updateState(state, of: tab) + + self.networkRetryHandler.handleNetworkError(error) { [weak self] in + self?.fetchNextPage(tab: tab) + } + } + } + } + + private func fetchPage(tab: YouthPolicyTab, cursor: String?) async throws -> YouthPolicyPageEntity? { + switch tab { + case .entire: + return try await youthPolicyUseCase.fetchPolicies(cursor: cursor, size: Self.pageSize) + case .bookmarked: + return try await youthPolicyUseCase.fetchBookmarkedPolicies(cursor: cursor, size: Self.pageSize) + } + } + + private func toggleBookmark(policyNumber: String) { + guard let currentItem = state(of: selectedTab).items.first(where: { $0.policyNumber == policyNumber }) + else { return } + + let targetIsBookmarked = !currentItem.isBookmarked + + let previousEntireState = entireState + let previousBookmarkedState = bookmarkedState + + applyBookmarkChange(policyNumber: policyNumber, isBookmarked: targetIsBookmarked) + + Task { [weak self] in + guard let self else { return } + + do { + try await self.youthPolicyUseCase.updateBookmark( + policyNumber: policyNumber, + isBookmarked: targetIsBookmarked) + } catch { + // 실패하면 낙관적으로 반영했던 변경을 되돌립니다. + self.entireState = previousEntireState + self.bookmarkedState = previousBookmarkedState + self.sendTabs() + self.sendPolicies(state: self.state(of: self.selectedTab)) + } + } + } + + private func applyBookmarkChange(policyNumber: String, isBookmarked: Bool) { + if let index = entireState.items.firstIndex(where: { $0.policyNumber == policyNumber }) { + entireState.items[index].isBookmarked = isBookmarked + } + + if isBookmarked { + // 찜 목록의 정렬 순서는 서버가 정하므로 직접 끼워넣지 않고 다음 진입 시 다시 받아옵니다. + bookmarkedState.isStale = true + } else { + bookmarkedState.items.removeAll { $0.policyNumber == policyNumber } + bookmarkedState.totalCount = max(0, bookmarkedState.totalCount - 1) + } + + sendTabs() + sendPolicies(state: state(of: selectedTab)) + } + + private func state(of tab: YouthPolicyTab) -> TabState { + switch tab { + case .entire: + return entireState + case .bookmarked: + return bookmarkedState + } + } + + private func updateState(_ state: TabState, of tab: YouthPolicyTab) { + switch tab { + case .entire: + entireState = state + case .bookmarked: + bookmarkedState = state + } + + sendTabs() + + guard tab == selectedTab else { return } + sendPolicies(state: state) + } + + private func sendTabs() { + let tabItems = YouthPolicyTab.allCases.map { tab in + YouthPolicyTabItem( + tab: tab, + count: state(of: tab).totalCount, + isSelected: tab == selectedTab) + } + + tabsSubject.send(tabItems) + } + + private func sendPolicies(state: TabState) { + policiesSubject.send(state.items) + + // 첫 응답이 오기 전에는 빈 화면 문구가 스쳐 보이지 않도록 감춥니다. + isEmptySubject.send(state.items.isEmpty && state.hasLoadedOnce) + } +}