diff --git a/Projects/DataSource/Sources/NetworkService/NetworkService.swift b/Projects/DataSource/Sources/NetworkService/NetworkService.swift index aa98c77..e2c8082 100644 --- a/Projects/DataSource/Sources/NetworkService/NetworkService.swift +++ b/Projects/DataSource/Sources/NetworkService/NetworkService.swift @@ -60,7 +60,19 @@ final class NetworkService { } } - let (data, response) = try await URLSession.shared.data(for: request) + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch let error as URLError { + if [.notConnectedToInternet, .timedOut, .networkConnectionLost].contains(error.code) { + throw NetworkError.needRetry + } else { + throw NetworkError.unknown(description: error.localizedDescription) + } + } catch { + throw NetworkError.unknown(description: error.localizedDescription) + } if withPlugins { for plugin in plugins { diff --git a/Projects/DataSource/Sources/Repository/EmotionRepository.swift b/Projects/DataSource/Sources/Repository/EmotionRepository.swift index fc0487e..e30cbbf 100644 --- a/Projects/DataSource/Sources/Repository/EmotionRepository.swift +++ b/Projects/DataSource/Sources/Repository/EmotionRepository.swift @@ -12,28 +12,64 @@ final class EmotionRepository: EmotionRepositoryProtocol { func fetchEmotions() async throws -> [EmotionEntity] { let endpoint = EmotionEndpoint.fetchEmotions - guard let response = try await networkService.request(endpoint: endpoint, type: [EmotionResponseDTO].self) - else { return [] } - let emotionEntities = response.compactMap({ $0.toEmotionEntity() }) - return emotionEntities + do { + guard let response = try await networkService.request(endpoint: endpoint, type: [EmotionResponseDTO].self) + else { return [] } + + let emotionEntities = response.compactMap({ $0.toEmotionEntity() }) + return emotionEntities + } catch let error as NetworkError { + switch error { + case .needRetry, .invalidURL, .emptyData: + throw DomainError.requireRetry + default: + throw DomainError.business(error.description) + } + } catch { + throw DomainError.unknown + } } func loadEmotion(date: String) async throws -> EmotionEntity? { let endpoint = EmotionEndpoint.loadEmotion(date: date) - guard let response = try await networkService.request(endpoint: endpoint, type: EmotionResponseDTO.self) - else { throw NetworkError.unknown(description: "Emotion Reponse를 받아오지 못했습니다.") } - let emotionEntity = response.toEmotionEntity() - return emotionEntity + do { + guard let response = try await networkService.request(endpoint: endpoint, type: EmotionResponseDTO.self) + else { throw NetworkError.unknown(description: "Emotion Reponse를 받아오지 못했습니다.") } + + let emotionEntity = response.toEmotionEntity() + return emotionEntity + } catch let error as NetworkError { + switch error { + case .needRetry, .invalidURL, .emptyData: + throw DomainError.requireRetry + default: + throw DomainError.business(error.description) + } + } catch { + throw DomainError.unknown + } } func registerEmotion(emotion: String) async throws -> [RecommendedRoutineEntity] { let endpoint = EmotionEndpoint.registerEmotion(emotion: emotion) - guard let response = try await networkService.request(endpoint: endpoint, type: RecommendedRoutineListResponseDTO.self) - else { return [] } - let recommendedRoutineEntity = response.recommendedRoutines.compactMap({ $0.toRecommendedRoutineEntity() }) - return recommendedRoutineEntity + do { + guard let response = try await networkService.request(endpoint: endpoint, type: RecommendedRoutineListResponseDTO.self) + else { return [] } + + let recommendedRoutineEntity = response.recommendedRoutines.compactMap({ $0.toRecommendedRoutineEntity() }) + return recommendedRoutineEntity + } catch let error as NetworkError { + switch error { + case .needRetry, .invalidURL, .emptyData: + throw DomainError.requireRetry + default: + throw DomainError.business(error.description) + } + } catch { + throw DomainError.unknown + } } } diff --git a/Projects/DataSource/Sources/Repository/FileRepository.swift b/Projects/DataSource/Sources/Repository/FileRepository.swift index 7d7aa3c..4f5c680 100644 --- a/Projects/DataSource/Sources/Repository/FileRepository.swift +++ b/Projects/DataSource/Sources/Repository/FileRepository.swift @@ -15,11 +15,34 @@ final class FileRepository: FileRepositoryProtocol { 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) + do { + return try await networkService.request(endpoint: endpoint, type: [String:String].self) + } catch let error as NetworkError { + switch error { + case .needRetry, .invalidURL, .emptyData: + throw DomainError.requireRetry + default: + throw DomainError.business(error.description) + } + } catch { + throw DomainError.unknown + } } 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) + + do { + _ = try await networkService.request(endpoint: endPoint, type: EmptyResponseDTO.self) + } catch let error as NetworkError { + switch error { + case .needRetry, .invalidURL, .emptyData: + throw DomainError.requireRetry + default: + throw DomainError.business(error.description) + } + } catch { + throw DomainError.unknown + } } } diff --git a/Projects/DataSource/Sources/Repository/LocationRepository.swift b/Projects/DataSource/Sources/Repository/LocationRepository.swift index f8695a8..3a9d520 100644 --- a/Projects/DataSource/Sources/Repository/LocationRepository.swift +++ b/Projects/DataSource/Sources/Repository/LocationRepository.swift @@ -44,11 +44,22 @@ final class LocationRepository: NSObject, LocationRepositoryProtocol { let endpoint = LocationEndpoint.fetchAddress(longitude: longitude, latitude: latitude) - guard let response = try await networkService.request(endpoint: endpoint, type: KakaoLocationResponseDTO.self) - else { return nil } + do { + guard let response = try await networkService.request(endpoint: endpoint, type: KakaoLocationResponseDTO.self) + else { return nil } - let location = response.toLocationEntity(fallbackLongitude: coordinate.longitude, fallbackLatitude: coordinate.latitude) - return location + let location = response.toLocationEntity(fallbackLongitude: coordinate.longitude, fallbackLatitude: coordinate.latitude) + return location + } catch let error as NetworkError { + switch error { + case .needRetry, .invalidURL, .emptyData: + throw DomainError.requireRetry + default: + throw DomainError.business(error.description) + } + } catch { + throw DomainError.unknown + } } private func requestAuthorizationIfNeeded() async -> CLAuthorizationStatus { diff --git a/Projects/DataSource/Sources/Repository/OnboardingRepository.swift b/Projects/DataSource/Sources/Repository/OnboardingRepository.swift index e3de37e..8c30745 100644 --- a/Projects/DataSource/Sources/Repository/OnboardingRepository.swift +++ b/Projects/DataSource/Sources/Repository/OnboardingRepository.swift @@ -13,11 +13,21 @@ final class OnboardingRepository: OnboardingRepositoryProtocol { func loadOnboardingResult() async throws -> OnboardingEntity { let endpoint = OnboardingEndpoint.loadOnboardingResult - guard let response = try await networkService.request(endpoint: endpoint, type: OnboardingResponseDTO.self) - else { throw UserError.onboardingLoadFailed } - let onboardingEntity = response.toOnboardingEntity() - return onboardingEntity + do { + guard let response = try await networkService.request(endpoint: endpoint, type: OnboardingResponseDTO.self) + else { throw UserError.onboardingLoadFailed } + + let onboardingEntity = response.toOnboardingEntity() + return onboardingEntity + } catch let error as NetworkError { + switch error { + case .needRetry, .invalidURL, .emptyData: + throw DomainError.requireRetry + default: + throw DomainError.business(error.description) + } + } } func registerOnboarding(onboardingEntity: OnboardingEntity) async throws -> [RecommendedRoutineEntity] { @@ -27,15 +37,39 @@ final class OnboardingRepository: OnboardingRepositoryProtocol { realOutingFrequency: onboardingEntity.frequency, targetOutingFrequency: onboardingEntity.outdoor) let endpoint = OnboardingEndpoint.registerOnboarding(onboarding: onboardingDTO) - guard let response = try await networkService.request(endpoint: endpoint, type: RecommendedRoutineListResponseDTO.self) - else { return [] } - - let recommendedRoutineEntity = response.recommendedRoutines.compactMap({ $0.toRecommendedRoutineEntity() }) - return recommendedRoutineEntity + + do { + guard let response = try await networkService.request(endpoint: endpoint, type: RecommendedRoutineListResponseDTO.self) + else { return [] } + + let recommendedRoutineEntity = response.recommendedRoutines.compactMap({ $0.toRecommendedRoutineEntity() }) + return recommendedRoutineEntity + } catch let error as NetworkError { + switch error { + case .needRetry, .invalidURL, .emptyData: + throw DomainError.requireRetry + default: + throw DomainError.business(error.description) + } + } catch { + throw DomainError.unknown + } } func registerRecommendedRoutines(selectedRoutines: [Int]) async throws { let endpoint = OnboardingEndpoint.registerRecommendedRoutine(selectedRoutines: selectedRoutines) - _ = try await networkService.request(endpoint: endpoint, type: EmptyResponseDTO.self) + + do { + _ = try await networkService.request(endpoint: endpoint, type: EmptyResponseDTO.self) + } catch let error as NetworkError { + switch error { + case .needRetry, .invalidURL, .emptyData: + throw DomainError.requireRetry + default: + throw DomainError.business(error.description) + } + } catch { + throw DomainError.unknown + } } } diff --git a/Projects/DataSource/Sources/Repository/RecommendedRoutineRepository.swift b/Projects/DataSource/Sources/Repository/RecommendedRoutineRepository.swift index f0a5141..58dbc5f 100644 --- a/Projects/DataSource/Sources/Repository/RecommendedRoutineRepository.swift +++ b/Projects/DataSource/Sources/Repository/RecommendedRoutineRepository.swift @@ -13,23 +13,46 @@ final class RecommendedRoutineRepository: RecommendedRoutineRepositoryProtocol { func fetchRecommendedRoutine(id: Int) async throws -> RecommendedRoutineEntity? { let endpoint = RecommendedRoutineEndpoint.fetchRecommendedRoutine(id: id) - guard let recommendedRoutineDTO = try await networkService.request(endpoint: endpoint, type: RecommendedRoutineDTO.self) - else { return nil } - - return recommendedRoutineDTO.toRecommendedRoutineEntity() + do { + guard let recommendedRoutineDTO = try await networkService.request(endpoint: endpoint, type: RecommendedRoutineDTO.self) + else { return nil } + + return recommendedRoutineDTO.toRecommendedRoutineEntity() + } catch let error as NetworkError { + switch error { + case .needRetry, .invalidURL, .emptyData: + throw DomainError.requireRetry + default: + throw DomainError.business(error.description) + } + } catch { + throw DomainError.unknown + } } func fetchRecommendedRoutines() async throws -> [RecommendedRoutineEntity] { let endpoint = RecommendedRoutineEndpoint.fetchRecommendedRoutines - guard let response = try await networkService.request(endpoint: endpoint, type: RecommendedRoutineDictionaryResponseDTO.self) - else { return [] } - var entities: [RecommendedRoutineEntity] = [] - for (category, recommendedRoutines) in response.recommendedRoutines { - let recommendedRoutineEntity = recommendedRoutines.compactMap({ $0.toRecommendedRoutineEntity(category: category) }) - entities.append(contentsOf: recommendedRoutineEntity) + do { + guard let response = try await networkService.request(endpoint: endpoint, type: RecommendedRoutineDictionaryResponseDTO.self) + else { return [] } + + var entities: [RecommendedRoutineEntity] = [] + for (category, recommendedRoutines) in response.recommendedRoutines { + let recommendedRoutineEntity = recommendedRoutines.compactMap({ $0.toRecommendedRoutineEntity(category: category) }) + entities.append(contentsOf: recommendedRoutineEntity) + } + + return entities + } catch let error as NetworkError { + switch error { + case .needRetry, .invalidURL, .emptyData: + throw DomainError.requireRetry + default: + throw DomainError.business(error.description) + } + } catch { + throw DomainError.unknown } - - return entities } } diff --git a/Projects/DataSource/Sources/Repository/ReportRepository.swift b/Projects/DataSource/Sources/Repository/ReportRepository.swift index c7205bb..cc4bf0c 100644 --- a/Projects/DataSource/Sources/Repository/ReportRepository.swift +++ b/Projects/DataSource/Sources/Repository/ReportRepository.swift @@ -33,30 +33,66 @@ final class ReportRepository: ReportRepositoryProtocol { ) let endpoint = ReportEndpoint.register(report: reportDTO) - guard let id = try await networkService.request(endpoint: endpoint, type: Int.self) else { return nil } - return id + do { + guard let id = try await networkService.request(endpoint: endpoint, type: Int.self) else { return nil } + + return id + } catch let error as NetworkError { + switch error { + case .needRetry, .invalidURL, .emptyData: + throw DomainError.requireRetry + default: + throw DomainError.business(error.description) + } + } catch { + throw DomainError.unknown + } } func fetchReports() async throws -> [ReportEntity] { let endpoint = ReportEndpoint.fetchReports - guard let response = try await networkService.request(endpoint: endpoint, type: ReportDictonaryDTO.self) - else { return [] } - var reportEntities: [ReportEntity] = [] - for (date, reports) in response.reportInfos { - let reportHistories = reports.compactMap({ try? $0.toReportEntity(date: date) }) - reportEntities += reportHistories - } + do { + guard let response = try await networkService.request(endpoint: endpoint, type: ReportDictonaryDTO.self) + else { return [] } + + var reportEntities: [ReportEntity] = [] + for (date, reports) in response.reportInfos { + let reportHistories = reports.compactMap({ try? $0.toReportEntity(date: date) }) + reportEntities += reportHistories + } - return reportEntities + return reportEntities + } catch let error as NetworkError { + switch error { + case .needRetry, .invalidURL, .emptyData: + throw DomainError.requireRetry + default: + throw DomainError.business(error.description) + } + } catch { + throw DomainError.unknown + } } func fetchReportDetail(reportId: Int) async throws -> ReportEntity? { let endpoint = ReportEndpoint.fetchReportDetail(reportId: reportId) - guard let response = try await networkService.request(endpoint: endpoint, type: ReportDTO.self) - else { return nil } - return try response.toReportEntity() + do { + guard let response = try await networkService.request(endpoint: endpoint, type: ReportDTO.self) + else { return nil } + + return try response.toReportEntity() + } catch let error as NetworkError { + switch error { + case .needRetry, .invalidURL, .emptyData: + throw DomainError.requireRetry + default: + throw DomainError.business(error.description) + } + } catch { + throw DomainError.unknown + } } } diff --git a/Projects/DataSource/Sources/Repository/RoutineRepository.swift b/Projects/DataSource/Sources/Repository/RoutineRepository.swift index ab4632c..7e8ef50 100644 --- a/Projects/DataSource/Sources/Repository/RoutineRepository.swift +++ b/Projects/DataSource/Sources/Repository/RoutineRepository.swift @@ -24,28 +24,63 @@ final class RoutineRepository: RoutineRepositoryProtocol { let endpoint = RoutineEndpoint.createRoutine(routine: routineCreationDTO) - _ = try await networkService.request(endpoint: endpoint, type: EmptyResponseDTO.self) + do { + _ = try await networkService.request(endpoint: endpoint, type: EmptyResponseDTO.self) + } catch let error as NetworkError { + switch error { + case .needRetry, .invalidURL, .emptyData: + throw DomainError.requireRetry + default: + throw DomainError.business(error.description) + } + } catch { + throw DomainError.unknown + } } - + func fetchRoutine(routineId: String) async throws -> RoutineEntity? { let endpoint = RoutineEndpoint.fetchRoutine(routineId: routineId) - guard let response = try await networkService.request(endpoint: endpoint, type: RoutineDTO.self) else { return nil } - - return response.toRoutineEntity() + + do { + guard let response = try await networkService.request(endpoint: endpoint, type: RoutineDTO.self) else { return nil } + + return response.toRoutineEntity() + } catch let error as NetworkError { + switch error { + case .needRetry, .invalidURL, .emptyData: + throw DomainError.requireRetry + default: + throw DomainError.business(error.description) + } + } catch { + throw DomainError.unknown + } } func fetchRoutines(from startDate: String, to endDate: String) async throws -> [String: (routines: [RoutineEntity], allCompleted: Bool)] { let endpoint = RoutineEndpoint.fetchRoutines(startDate: startDate, endDate: endDate) - guard let response = try await networkService.request(endpoint: endpoint, type: RoutineDictionaryDTO.self) - else { return [:] } - - var result: [String: ([RoutineEntity], Bool)] = [:] - for (date, routineDTO) in response.routines { - let allCompleted = routineDTO.allCompleted - let routines = routineDTO.routineList.compactMap({ $0.toRoutineEntity() }) - result[date] = (routines, allCompleted) + + do { + guard let response = try await networkService.request(endpoint: endpoint, type: RoutineDictionaryDTO.self) + else { return [:] } + + var result: [String: ([RoutineEntity], Bool)] = [:] + for (date, routineDTO) in response.routines { + let allCompleted = routineDTO.allCompleted + let routines = routineDTO.routineList.compactMap({ $0.toRoutineEntity() }) + result[date] = (routines, allCompleted) + } + return result + } catch let error as NetworkError { + switch error { + case .needRetry, .invalidURL, .emptyData: + throw DomainError.requireRetry + default: + throw DomainError.business(error.description) + } + } catch { + throw DomainError.unknown } - return result } func updateRoutine(routine: RoutineCreationEntity) async throws { @@ -61,17 +96,52 @@ final class RoutineRepository: RoutineRepositoryProtocol { recommendedRoutineType: routine.recommendedRoutineType?.rawValue) let endpoint = RoutineEndpoint.updateRoutine(routine: routineUpdateDTO) - _ = try await networkService.request(endpoint: endpoint, type: EmptyResponseDTO.self) + do { + _ = try await networkService.request(endpoint: endpoint, type: EmptyResponseDTO.self) + } catch let error as NetworkError { + switch error { + case .needRetry, .invalidURL, .emptyData: + throw DomainError.requireRetry + default: + throw DomainError.business(error.description) + } + } catch { + throw DomainError.unknown + } } func deleteAllRoutine(routineId: String) async throws { let endpoint = RoutineEndpoint.deleteAllRoutine(routineId: routineId) - _ = try await networkService.request(endpoint: endpoint, type: EmptyResponseDTO.self) + + do { + _ = try await networkService.request(endpoint: endpoint, type: EmptyResponseDTO.self) + } catch let error as NetworkError { + switch error { + case .needRetry, .invalidURL, .emptyData: + throw DomainError.requireRetry + default: + throw DomainError.business(error.description) + } + } catch { + throw DomainError.unknown + } } func deleteDailyRoutine(routineId: String) async throws { let endpoint = RoutineEndpoint.deleteDailyRoutine(routineId: routineId) - _ = try await networkService.request(endpoint: endpoint, type: EmptyResponseDTO.self) + + do { + _ = try await networkService.request(endpoint: endpoint, type: EmptyResponseDTO.self) + } catch let error as NetworkError { + switch error { + case .needRetry, .invalidURL, .emptyData: + throw DomainError.requireRetry + default: + throw DomainError.business(error.description) + } + } catch { + throw DomainError.unknown + } } func updateRoutineCompletions(routines: [RoutineEntity]) async throws { @@ -82,6 +152,18 @@ final class RoutineRepository: RoutineRepositoryProtocol { let completionListDTO = RoutineCompletionListDTO(routineCompletionInfos: completionDTO) let endpoint = RoutineEndpoint.updateRoutineCompletion(routines: completionListDTO) - _ = try await networkService.request(endpoint: endpoint, type: EmptyResponseDTO.self) + + do { + _ = try await networkService.request(endpoint: endpoint, type: EmptyResponseDTO.self) + } catch let error as NetworkError { + switch error { + case .needRetry, .invalidURL, .emptyData: + throw DomainError.requireRetry + default: + throw DomainError.business(error.description) + } + } catch { + throw DomainError.unknown + } } } diff --git a/Projects/Domain/Sources/Entity/Enum/DomainError.swift b/Projects/Domain/Sources/Entity/Enum/DomainError.swift new file mode 100644 index 0000000..f33d052 --- /dev/null +++ b/Projects/Domain/Sources/Entity/Enum/DomainError.swift @@ -0,0 +1,12 @@ +// +// DomainError.swift +// Domain +// +// Created by 이동현 on 2/17/26. +// + +public enum DomainError: Error, Equatable { + case requireRetry + case business(String) + case unknown +} diff --git a/Projects/Presentation/Resources/Images.xcassets/NetworkError/Contents.json b/Projects/Presentation/Resources/Images.xcassets/NetworkError/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/NetworkError/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/NetworkError/network_error_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/NetworkError/network_error_icon.imageset/Contents.json new file mode 100644 index 0000000..0738a4d --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/NetworkError/network_error_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "ic_network_error.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "ic_network_error@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_network_error@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/NetworkError/network_error_icon.imageset/ic_network_error.png b/Projects/Presentation/Resources/Images.xcassets/NetworkError/network_error_icon.imageset/ic_network_error.png new file mode 100644 index 0000000..7bf0e56 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/NetworkError/network_error_icon.imageset/ic_network_error.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/NetworkError/network_error_icon.imageset/ic_network_error@2x.png b/Projects/Presentation/Resources/Images.xcassets/NetworkError/network_error_icon.imageset/ic_network_error@2x.png new file mode 100644 index 0000000..3106708 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/NetworkError/network_error_icon.imageset/ic_network_error@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/NetworkError/network_error_icon.imageset/ic_network_error@3x.png b/Projects/Presentation/Resources/Images.xcassets/NetworkError/network_error_icon.imageset/ic_network_error@3x.png new file mode 100644 index 0000000..6bcda8c Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/NetworkError/network_error_icon.imageset/ic_network_error@3x.png differ diff --git a/Projects/Presentation/Sources/Common/Component/NetworkErrorView.swift b/Projects/Presentation/Sources/Common/Component/NetworkErrorView.swift new file mode 100644 index 0000000..4947a89 --- /dev/null +++ b/Projects/Presentation/Sources/Common/Component/NetworkErrorView.swift @@ -0,0 +1,125 @@ +// +// NetworkErrorView.swift +// Presentation +// +// Created by 이동현 on 2/17/26. +// + +import SnapKit +import UIKit + +final class NetworkErrorView: UIView { + private enum Layout { + static let contentViewWidth: CGFloat = 204 + static let contentViewHeight: CGFloat = 215 + static let errorImageSize: CGFloat = 40 + static let boldTitleLabelTopSpacing: CGFloat = 21 + static let boldTitleLabelHeight: CGFloat = 30 + static let mediumTitleLabelTopSpacing: CGFloat = 2 + static let mediumTitleLabelHeight: CGFloat = 24 + static let retryButtonWidth: CGFloat = 113 + static let retryButtonHeight: CGFloat = 48 + } + + private let contentView = UIView() + private let errorImageView = UIImageView() + private let boldTitleLabel = UILabel() + private let mediumTitleLabel = UILabel() + private let retryButton = UIButton() + var onRetry: (() -> Void)? + + 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 = .white + contentView.backgroundColor = .white + + errorImageView.image = BitnagilIcon + .networkErrorIcon? + .withRenderingMode(.alwaysOriginal) + + boldTitleLabel.text = "네트워크가 불안정해요" + boldTitleLabel.font = BitnagilFont.init(style: .title2, weight: .bold).font + boldTitleLabel.textColor = .black + boldTitleLabel.textAlignment = .center + + mediumTitleLabel.text = "연결 확인 후 다시 시도해 주세요." + mediumTitleLabel.font = BitnagilFont.init(style: .body1, weight: .medium).font + mediumTitleLabel.textColor = BitnagilColor.gray40 + mediumTitleLabel.textAlignment = .center + + retryButton.tintColor = BitnagilColor.gray10 + retryButton.backgroundColor = BitnagilColor.gray10 + retryButton.setTitleColor(.white, for: .normal) + retryButton.setTitle("다시 시도", for: .normal) + retryButton.titleLabel?.font = BitnagilFont.init(style: .body2, weight: .medium).font + retryButton.layer.cornerRadius = 12 + retryButton.layer.masksToBounds = true + retryButton.addAction(UIAction { [weak self] _ in + self?.onRetry?() + }, for: .touchUpInside) + } + + private func configureLayout() { + addSubview(contentView) + contentView.addSubview(errorImageView) + contentView.addSubview(boldTitleLabel) + contentView.addSubview(mediumTitleLabel) + contentView.addSubview(retryButton) + + contentView.snp.makeConstraints { make in + make.width.equalTo(Layout.contentViewWidth) + + make.height.equalTo(Layout.contentViewHeight) + + make.center.equalToSuperview() + } + + errorImageView.snp.makeConstraints { make in + make.centerX.equalToSuperview() + + make.size.equalTo(Layout.errorImageSize) + + make.top.equalToSuperview() + } + + boldTitleLabel.snp.makeConstraints { make in + make.top + .equalTo(errorImageView.snp.bottom) + .offset(Layout.boldTitleLabelTopSpacing) + + make.height.equalTo(Layout.boldTitleLabelHeight) + + make.centerX.equalToSuperview() + } + + mediumTitleLabel.snp.makeConstraints { make in + make.top + .equalTo(boldTitleLabel.snp.bottom) + .offset(Layout.mediumTitleLabelTopSpacing) + + make.height.equalTo(Layout.mediumTitleLabelHeight) + + make.centerX.equalToSuperview() + } + + retryButton.snp.makeConstraints { make in + make.width.equalTo(Layout.retryButtonWidth) + + make.height.equalTo(Layout.retryButtonHeight) + + make.centerX.equalToSuperview() + + make.bottom.equalToSuperview() + } + } +} diff --git a/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift b/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift index 199eb49..233719a 100644 --- a/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift +++ b/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift @@ -110,6 +110,9 @@ enum BitnagilIcon { static func doubleChevronIcon(direction: Direction) -> UIImage? { return BitnagilIcon.doubleChevronIcon?.rotate(degrees: direction.rotation)?.withRenderingMode(.alwaysTemplate) } + + // MARK: - Network Error + static let networkErrorIcon = UIImage(named: "network_error_icon", in: bundle, with: nil) } enum Direction { diff --git a/Projects/Presentation/Sources/Common/NetworkRetryHandler.swift b/Projects/Presentation/Sources/Common/NetworkRetryHandler.swift new file mode 100644 index 0000000..0215d91 --- /dev/null +++ b/Projects/Presentation/Sources/Common/NetworkRetryHandler.swift @@ -0,0 +1,11 @@ +// +// NetworkRetryHandler.swift +// Presentation +// +// Created by 이동현 on 2/17/26. +// +import Combine + +final class NetworkRetryHandler: NetworkRetryCapable { + let networkErrorActionSubject = CurrentValueSubject<(() -> Void)?, Never>(nil) +} diff --git a/Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift b/Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift index a3b75a9..cc9c487 100644 --- a/Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift +++ b/Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift @@ -5,17 +5,30 @@ // Created by 최정인 on 6/26/25. // +import Combine import SnapKit import UIKit public class BaseViewController: UIViewController { let viewModel: T + private var baseCancellables = Set() + private lazy var networkErrorView = NetworkErrorView() + private var isShowingNetworkError = false + init(viewModel: T) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } - + + deinit { + guard isShowingNetworkError else { return } + + DispatchQueue.main.async { [weak tabBarController = self.tabBarController] in + tabBarController?.tabBar.isHidden = false + } + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -30,11 +43,50 @@ public class BaseViewController: UIViewController { } /// 뷰의 속성(스타일, 컬러, 폰트 등)을 설정합니다. - func configureAttribute() { } + func configureAttribute() { + networkErrorView.isHidden = true + } /// 뷰의 계층 구조를 구성하고 Auto Layout 제약을 설정합니다. - func configureLayout() { } + func configureLayout() { + view.addSubview(networkErrorView) + + networkErrorView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } /// ViewModel의 데이터를 구독하고 UI에 바인딩합니다. func bind() { } + + /// 네트워크 재시도 기능이 필요할 경우 BaseViewController의 subclass 내의 bind() 함수 내에서 호출합니다. + /// 네트워크 재시도 화면의 '다시 시도하기' 버튼의 재시도 action을 설정합니다. + /// - Parameter publisher: ViewModel의 Output에서 제공하는 네트워크 에러 Publisher + func bindNetworkError(from publisher: AnyPublisher<(() -> Void)?, Never>) { + publisher + .receive(on: DispatchQueue.main) + .sink { [weak self] retryAction in + if let action = retryAction { + self?.handleNetworkErrorView(show: true) + self?.networkErrorView.onRetry = action + } else { + self?.handleNetworkErrorView(show: false) + } + } + .store(in: &baseCancellables) + } + + /// 네트워크 에러 뷰 를 보이거나 숨깁니다. + private func handleNetworkErrorView(show: Bool) { + isShowingNetworkError = show + + if show { + view.bringSubviewToFront(networkErrorView) + networkErrorView.isHidden = false + tabBarController?.tabBar.isHidden = true + } else { + networkErrorView.isHidden = true + tabBarController?.tabBar.isHidden = false + } + } } diff --git a/Projects/Presentation/Sources/Common/Protocol/NetworkRetryCapable.swift b/Projects/Presentation/Sources/Common/Protocol/NetworkRetryCapable.swift new file mode 100644 index 0000000..fafbc45 --- /dev/null +++ b/Projects/Presentation/Sources/Common/Protocol/NetworkRetryCapable.swift @@ -0,0 +1,42 @@ +// +// NetworkRetryCapable.swift +// Presentation +// +// Created by 이동현 on 2/17/26. +// + +import Combine +import Domain +import Foundation + +protocol NetworkRetryCapable: AnyObject { + /// 네트워크 에러 발생 시, 재시도할 메서드를 방출하는 subject + var networkErrorActionSubject: CurrentValueSubject<(() -> Void)?, Never> { get } + + /// 네트워크 에러 처리 + /// - Parameters: + /// - error: 발생한 에러 + /// - retryAction: 재시도할 메서드 + func handleNetworkError(_ error: Error, retryAction: @escaping () -> Void) + + /// 네트워크 에러 핸들링 상태 초기화 + func clearRetryState() +} + +extension NetworkRetryCapable { + func handleNetworkError(_ error: Error, retryAction: @escaping () -> Void) { + guard + let domainError = error as? DomainError, + domainError == .requireRetry + else { + // 네트워크 에러 문제가 아닐 경우 + return + } + + networkErrorActionSubject.send(retryAction) + } + + func clearRetryState() { + networkErrorActionSubject.send(nil) + } +} diff --git a/Projects/Presentation/Sources/EmotionRegister/View/EmotionRegistrationViewController.swift b/Projects/Presentation/Sources/EmotionRegister/View/EmotionRegistrationViewController.swift index 7f83c5e..7446163 100644 --- a/Projects/Presentation/Sources/EmotionRegister/View/EmotionRegistrationViewController.swift +++ b/Projects/Presentation/Sources/EmotionRegister/View/EmotionRegistrationViewController.swift @@ -105,6 +105,8 @@ final class EmotionRegistrationViewController: BaseViewController { } override func configureAttribute() { + super.configureAttribute() + logoImageView.image = BitnagilGraphic.grayLogoGraphic helpButton.setImage(BitnagilIcon.helpIcon, for: .normal) alarmButton.setImage(BitnagilIcon.alarmIcon, for: .normal) @@ -221,6 +223,8 @@ final class HomeViewController: BaseViewController { } override func configureLayout() { + super.configureLayout() + let safeArea = view.safeAreaLayoutGuide view.backgroundColor = BitnagilColor.gray10 navigationController?.setNavigationBarHidden(true, animated: false) @@ -406,7 +410,7 @@ final class HomeViewController: BaseViewController { self?.nickname = nickname } .store(in: &cancellables) - + viewModel.output.selectedDatePublisher .receive(on: DispatchQueue.main) .sink { [weak self] selectedDate in @@ -414,7 +418,7 @@ final class HomeViewController: BaseViewController { self?.weekView.updateWeekDateViews(date: selectedDate) } .store(in: &cancellables) - + viewModel.output.fetchRoutineResultPublisher .receive(on: DispatchQueue.main) .sink { [weak self] fetchRoutineResult in @@ -424,7 +428,7 @@ final class HomeViewController: BaseViewController { self?.hideIndicatorView() } .store(in: &cancellables) - + viewModel.output.routinesPublisher .receive(on: DispatchQueue.main) .sink { [weak self] routines in @@ -432,14 +436,14 @@ final class HomeViewController: BaseViewController { self?.hideIndicatorView() } .store(in: &cancellables) - + viewModel.output.emotionPublisher .receive(on: DispatchQueue.main) .sink { [weak self] emotion in self?.updateEmotionOrbView(emotion: emotion) } .store(in: &cancellables) - + viewModel.output.updateRoutineCompletionResultPublisher .receive(on: DispatchQueue.main) .sink { [weak self] isUpdateRoutineCompletion in @@ -449,56 +453,58 @@ final class HomeViewController: BaseViewController { self?.hideIndicatorView() } .store(in: &cancellables) - + viewModel.output.routineListDatePublisher .receive(on: DispatchQueue.main) .sink { [weak self] selectedDate in guard let self else { return } - + guard let viewModel = DIContainer.shared.resolve(type: RoutineListViewModel.self) else { return } - + let routineListViewController = RoutineListViewController(viewModel: viewModel, selectedDate: selectedDate) routineListViewController.hidesBottomBarWhenPushed = true self.navigationController?.pushViewController(routineListViewController, animated: true) } .store(in: &cancellables) - + viewModel.output.allCompletedRoutineDatePublisher .receive(on: DispatchQueue.main) .sink { [weak self] allCompletedDates in self?.weekView.updateAllCompletedState(allCompletedDates: allCompletedDates) } .store(in: &cancellables) - + viewModel.output.updateVersionPublisher .receive(on: DispatchQueue.main) .sink { [weak self] updateURL in guard let updateURL else { return } - + let alert = UIAlertController( title: "업데이트가 필요합니다", message: "원활한 이용을 위해, 빛나길을 업데이트 해주세요!", preferredStyle: .alert) - + let cancel = UIAlertAction( title: "취소", style: .default, handler: { _ in exit(0) }) - + let update = UIAlertAction( title: "업데이트", style: .default, handler: { _ in UIApplication.shared.open(updateURL, options: [:], completionHandler: { _ in exit(0) }) }) - + alert.addAction(cancel) alert.addAction(update) alert.preferredAction = update self?.present(alert, animated: true) } .store(in: &cancellables) + + bindNetworkError(from: viewModel.output.networkErrorPublisher) } // 해당 날짜의 Routine View를 설정합니다. (없다면 EmptyView) diff --git a/Projects/Presentation/Sources/Home/ViewModel/HomeViewModel.swift b/Projects/Presentation/Sources/Home/ViewModel/HomeViewModel.swift index a65d3d5..0a13899 100644 --- a/Projects/Presentation/Sources/Home/ViewModel/HomeViewModel.swift +++ b/Projects/Presentation/Sources/Home/ViewModel/HomeViewModel.swift @@ -33,6 +33,7 @@ final class HomeViewModel: ViewModel { let updateRoutineCompletionResultPublisher: AnyPublisher let allCompletedRoutineDatePublisher: AnyPublisher<[Date], Never> let updateVersionPublisher: AnyPublisher + let networkErrorPublisher: AnyPublisher<(() -> Void)?, Never> } let output: Output @@ -48,6 +49,7 @@ final class HomeViewModel: ViewModel { private let updateRoutineCompletionResultSubject = PassthroughSubject() private let allCompletedRoutineDateSubject = CurrentValueSubject<[Date], Never>([]) private let updateVersionSubject = PassthroughSubject() + private let networkRetryHandler: NetworkRetryHandler private let calendar = Calendar.current private let today = Date() @@ -65,6 +67,8 @@ final class HomeViewModel: ViewModel { emotionUseCase: EmotionUseCaseProtocol, appConfigRepository: AppConfigRepositoryProtocol ) { + networkRetryHandler = NetworkRetryHandler() + self.routineUseCase = routineUseCase self.userDataUseCase = userDataUseCase self.emotionUseCase = emotionUseCase @@ -78,7 +82,8 @@ final class HomeViewModel: ViewModel { routinesPublisher: routinesSubject.eraseToAnyPublisher(), updateRoutineCompletionResultPublisher: updateRoutineCompletionResultSubject.eraseToAnyPublisher(), allCompletedRoutineDatePublisher: allCompletedRoutineDateSubject.eraseToAnyPublisher(), - updateVersionPublisher: updateVersionSubject.eraseToAnyPublisher()) + updateVersionPublisher: updateVersionSubject.eraseToAnyPublisher(), + networkErrorPublisher: networkRetryHandler.networkErrorActionSubject.eraseToAnyPublisher()) } func action(input: Input) { @@ -123,8 +128,12 @@ final class HomeViewModel: ViewModel { do { let nickname = try await userDataUseCase.loadNickname() nicknameSubject.send(nickname) + + networkRetryHandler.clearRetryState() } catch { - + networkRetryHandler.handleNetworkError(error) { [weak self] in + self?.loadNickname() + } } } } @@ -136,8 +145,12 @@ final class HomeViewModel: ViewModel { let emotionEntity = try await emotionUseCase.loadEmotion(date: today) let emotion = emotionEntity?.toEmotion() emotionSubject.send(emotion) - } catch { + networkRetryHandler.clearRetryState() + } catch { + networkRetryHandler.handleNetworkError(error) { [weak self] in + self?.fetchEmotion() + } } } } @@ -205,9 +218,15 @@ final class HomeViewModel: ViewModel { } fetchAllCompletedRoutine() fetchRoutineResultSubject.send(true) + + networkRetryHandler.clearRetryState() } catch { fetchRoutineResultSubject.send(false) - // TODO: 에러 처리 + // TODO: - 네트워크 에러 제외 오류 처리 + + networkRetryHandler.handleNetworkError(error) { [weak self] in + self?.fetchRoutines(startDate: startDate, endDate: endDate) + } } } } @@ -268,8 +287,13 @@ final class HomeViewModel: ViewModel { let routineEntity = updatedRoutine.toRoutineEntity() try await routineUseCase.updateRoutineCompletions(routines: [routineEntity]) updateRoutineCompletionResultSubject.send(true) + + networkRetryHandler.clearRetryState() } catch { // TODO: 에러 처리 + networkRetryHandler.handleNetworkError(error) { [weak self] in + self?.updateRoutineCompletion(updatedRoutine: updatedRoutine) + } } } } @@ -290,8 +314,11 @@ final class HomeViewModel: ViewModel { updateVersionSubject.send(nil) } + networkRetryHandler.clearRetryState() } catch { - + networkRetryHandler.handleNetworkError(error) { [weak self] in + self?.checkVersion() + } } } } diff --git a/Projects/Presentation/Sources/Login/View/IntroViewController.swift b/Projects/Presentation/Sources/Login/View/IntroViewController.swift index f10349e..7aa0b01 100644 --- a/Projects/Presentation/Sources/Login/View/IntroViewController.swift +++ b/Projects/Presentation/Sources/Login/View/IntroViewController.swift @@ -70,6 +70,8 @@ public final class IntroViewController: BaseViewController { } override func configureAttribute() { + super.configureAttribute() + introLabel.text = "포모는 님을 알고싶어요!" introLabel.font = BitnagilFont(style: .title1, weight: .bold).font introLabel.textColor = BitnagilColor.gray10 @@ -88,6 +90,8 @@ public final class IntroViewController: BaseViewController { } override func configureLayout() { + super.configureLayout() + let safeArea = view.safeAreaLayoutGuide view.backgroundColor = BitnagilColor.gray99 navigationController?.setNavigationBarHidden(true, animated: false) diff --git a/Projects/Presentation/Sources/Login/View/LoginViewController.swift b/Projects/Presentation/Sources/Login/View/LoginViewController.swift index 04ca6df..5028769 100644 --- a/Projects/Presentation/Sources/Login/View/LoginViewController.swift +++ b/Projects/Presentation/Sources/Login/View/LoginViewController.swift @@ -72,6 +72,8 @@ public final class LoginViewController: BaseViewController { } override func configureAttribute() { + super.configureAttribute() + let loginText = "안녕! 저는 포모예요\n함께 빛나길을 시작해볼까요?" loginLabel.attributedText = BitnagilFont(style: .title1, weight: .bold).attributedString(text: loginText) loginLabel.font = BitnagilFont(style: .title1, weight: .bold).font @@ -90,6 +92,8 @@ public final class LoginViewController: BaseViewController { } override func configureLayout() { + super.configureLayout() + let safeArea = view.safeAreaLayoutGuide view.backgroundColor = .systemBackground navigationController?.setNavigationBarHidden(true, animated: false) diff --git a/Projects/Presentation/Sources/Login/View/TermsAgreementViewController.swift b/Projects/Presentation/Sources/Login/View/TermsAgreementViewController.swift index 0da5b4f..b0ed092 100644 --- a/Projects/Presentation/Sources/Login/View/TermsAgreementViewController.swift +++ b/Projects/Presentation/Sources/Login/View/TermsAgreementViewController.swift @@ -40,6 +40,8 @@ final class TermsAgreementViewController: BaseViewController { } override func configureAttribute() { + super.configureAttribute() + let text = "빛나길 이용을 위해\n필수 약관에 동의해 주세요." agreementLabel.attributedText = BitnagilFont(style: .title2, weight: .bold).attributedString(text: text) agreementLabel.textAlignment = .left @@ -56,6 +58,8 @@ final class TermsAgreementViewController: BaseViewController { } override func configureLayout() { + super.configureLayout() + let safeArea = view.safeAreaLayoutGuide view.backgroundColor = .systemBackground navigationController?.setNavigationBarHidden(true, animated: false) diff --git a/Projects/Presentation/Sources/MyPage/View/MypageView.swift b/Projects/Presentation/Sources/MyPage/View/MypageView.swift index ec85679..e5de4b6 100644 --- a/Projects/Presentation/Sources/MyPage/View/MypageView.swift +++ b/Projects/Presentation/Sources/MyPage/View/MypageView.swift @@ -45,6 +45,8 @@ final class MypageView: BaseViewController { } override func configureAttribute() { + super.configureAttribute() + view.backgroundColor = .white profileImageView.image = BitnagilGraphic.profileGraphic @@ -78,6 +80,8 @@ final class MypageView: BaseViewController { } override func configureLayout() { + super.configureLayout() + let safeArea = view.safeAreaLayoutGuide view.addSubview(profileImageView) view.addSubview(nicknameLabel) diff --git a/Projects/Presentation/Sources/Onboarding/View/OnboardingResultViewController.swift b/Projects/Presentation/Sources/Onboarding/View/OnboardingResultViewController.swift index ae4c1bd..b3984de 100644 --- a/Projects/Presentation/Sources/Onboarding/View/OnboardingResultViewController.swift +++ b/Projects/Presentation/Sources/Onboarding/View/OnboardingResultViewController.swift @@ -101,6 +101,8 @@ final class OnboardingResultViewController: BaseViewController { } override func configureAttribute() { + super.configureAttribute() + mainLabel.attributedText = BitnagilFont(style: .title2, weight: .bold).attributedString(text: onboarding.mainTitle) mainLabel.textColor = BitnagilColor.gray10 mainLabel.numberOfLines = 2 @@ -120,6 +122,8 @@ final class OnboardingViewController: BaseViewController { } override func configureLayout() { + super.configureLayout() + let safeArea = view.safeAreaLayoutGuide view.backgroundColor = .systemBackground navigationController?.setNavigationBarHidden(true, animated: false) diff --git a/Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineViewController.swift b/Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineViewController.swift index 8570d69..09a6ee9 100644 --- a/Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineViewController.swift +++ b/Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineViewController.swift @@ -72,6 +72,8 @@ final class RecommendedRoutineViewController: BaseViewController { } override func configureAttribute() { + super.configureAttribute() + view.backgroundColor = .white configureCustomNavigationBar(navigationBarStyle: .withBackButton(title: "제보하기")) @@ -95,6 +97,8 @@ class ReportDetailViewController: BaseViewController { } override func configureLayout() { + super.configureLayout() + let safeArea = view.safeAreaLayoutGuide view.addSubview(scrollView) diff --git a/Projects/Presentation/Sources/Report/View/ReportHistoryViewController.swift b/Projects/Presentation/Sources/Report/View/ReportHistoryViewController.swift index 072a077..20a64ac 100644 --- a/Projects/Presentation/Sources/Report/View/ReportHistoryViewController.swift +++ b/Projects/Presentation/Sources/Report/View/ReportHistoryViewController.swift @@ -60,6 +60,8 @@ final class ReportHistoryViewController: BaseViewController + let networkErrorPublisher: AnyPublisher<(() -> Void)?, Never> } private(set) var output: Output private let reportDetailSubject = CurrentValueSubject(nil) private let reportRepository: ReportRepositoryProtocol + private let networkRetryHandler: NetworkRetryHandler init(reportRepository: ReportRepositoryProtocol) { + networkRetryHandler = NetworkRetryHandler() + self.reportRepository = reportRepository self.output = Output( - reportDetailPublisher: reportDetailSubject.eraseToAnyPublisher() - ) + reportDetailPublisher: reportDetailSubject.eraseToAnyPublisher(), + networkErrorPublisher: networkRetryHandler.networkErrorActionSubject.eraseToAnyPublisher()) } func action(input: Input) { @@ -53,8 +57,14 @@ final class ReportDetailViewModel: ViewModel { photoUrls: reportEntity.photoURLs) reportDetailSubject.send(reportDetail) } + + networkRetryHandler.clearRetryState() } catch { reportDetailSubject.send(nil) + + networkRetryHandler.handleNetworkError(error) { [weak self] in + self?.fetchReportDetail(reportId: reportId) + } } } } diff --git a/Projects/Presentation/Sources/Report/ViewModel/ReportHistoryViewModel.swift b/Projects/Presentation/Sources/Report/ViewModel/ReportHistoryViewModel.swift index e5480c5..a806cb6 100644 --- a/Projects/Presentation/Sources/Report/ViewModel/ReportHistoryViewModel.swift +++ b/Projects/Presentation/Sources/Report/ViewModel/ReportHistoryViewModel.swift @@ -22,6 +22,7 @@ final class ReportHistoryViewModel: ViewModel { let selectedCategoryPublisher: AnyPublisher let reportsPublisher: AnyPublisher<[ReportHistoryItem], Never> let selectedReportPublisher: AnyPublisher + let networkErrorPublisher: AnyPublisher<(() -> Void)?, Never> } private(set) var output: Output @@ -34,8 +35,11 @@ final class ReportHistoryViewModel: ViewModel { private var selectedProgress: ReportProgress? private var reports: [ReportHistoryItem] = [] private let reportRepository: ReportRepositoryProtocol + private let networkRetryHandler: NetworkRetryHandler init(reportRepository: ReportRepositoryProtocol) { + networkRetryHandler = NetworkRetryHandler() + self.reportRepository = reportRepository progressSubject .send( @@ -51,7 +55,8 @@ final class ReportHistoryViewModel: ViewModel { categoryPublisher: categorySubject.eraseToAnyPublisher(), selectedCategoryPublisher: selectedCategorySubject.eraseToAnyPublisher(), reportsPublisher: reportSubject.eraseToAnyPublisher(), - selectedReportPublisher: selectedReportSubject.eraseToAnyPublisher()) + selectedReportPublisher: selectedReportSubject.eraseToAnyPublisher(), + networkErrorPublisher: networkRetryHandler.networkErrorActionSubject.eraseToAnyPublisher()) } func action(input: Input) { @@ -160,8 +165,13 @@ final class ReportHistoryViewModel: ViewModel { } progressSubject.send(progressItems) + + networkRetryHandler.clearRetryState() } catch { // TODO: 에러 처리 + networkRetryHandler.handleNetworkError(error) { [weak self] in + self?.fetchReports() + } } } } diff --git a/Projects/Presentation/Sources/ResultRecommendedRoutine/View/ResultRecommendedRoutineViewController.swift b/Projects/Presentation/Sources/ResultRecommendedRoutine/View/ResultRecommendedRoutineViewController.swift index 0bdc45d..d92e791 100644 --- a/Projects/Presentation/Sources/ResultRecommendedRoutine/View/ResultRecommendedRoutineViewController.swift +++ b/Projects/Presentation/Sources/ResultRecommendedRoutine/View/ResultRecommendedRoutineViewController.swift @@ -125,6 +125,7 @@ final class ResultRecommendedRoutineViewController: BaseViewController, Never> let confirmButtonPublisher: AnyPublisher let registerRoutineResultPublisher: AnyPublisher + let networkErrorPublisher: AnyPublisher<(() -> Void)?, Never> } private(set) var output: Output @@ -39,18 +40,21 @@ final class ResultRecommendedRoutineViewModel: ViewModel { private let selectedRecommendedRoutineSubject = CurrentValueSubject, Never>([]) private let confirmButtonSubject = PassthroughSubject() private let registerRoutineResultSubject = PassthroughSubject() + private let networkRetryHandler: NetworkRetryHandler private var viewModelType: ResultRecommendedRoutineViewModelType? private let resultRecommendedRoutineUseCase: ResultRecommendedRoutineUseCaseProtocol init(resultRecommendedRoutineUseCase: ResultRecommendedRoutineUseCaseProtocol) { + networkRetryHandler = NetworkRetryHandler() + self.resultRecommendedRoutineUseCase = resultRecommendedRoutineUseCase output = Output( resultRecommendedRoutinesPublisher: resultRecommendedRoutinesSubject.eraseToAnyPublisher(), selectedRoutineIdPublisher: selectedRoutineIdSubject.eraseToAnyPublisher(), selectedRecommendedRoutinePublisher: selectedRecommendedRoutineSubject.eraseToAnyPublisher(), confirmButtonPublisher: confirmButtonSubject.eraseToAnyPublisher(), - registerRoutineResultPublisher: registerRoutineResultSubject.eraseToAnyPublisher() - ) + registerRoutineResultPublisher: registerRoutineResultSubject.eraseToAnyPublisher(), + networkErrorPublisher: networkRetryHandler.networkErrorActionSubject.eraseToAnyPublisher()) } func action(input: Input) { @@ -95,9 +99,15 @@ final class ResultRecommendedRoutineViewModel: ViewModel { case nil: fatalError("ResultRecommendedRoutineViewModel Type이 설정되지 않았습니다.") } + + networkRetryHandler.clearRetryState() } catch { // TODO: 에러 처리 BitnagilLogger.log(logType: .error, message: "\(error.localizedDescription)") + + networkRetryHandler.handleNetworkError(error) { [weak self] in + self?.fetchResultRecommendedRoutines() + } } } } @@ -151,9 +161,15 @@ final class ResultRecommendedRoutineViewModel: ViewModel { do { try await resultRecommendedRoutineUseCase.registerRecommendedRoutines(selectedRoutines: selectedRoutinesId) registerRoutineResultSubject.send(true) + + networkRetryHandler.clearRetryState() } catch { BitnagilLogger.log(logType: .error, message: "\(error.localizedDescription)") registerRoutineResultSubject.send(false) + + networkRetryHandler.handleNetworkError(error) { [weak self] in + self?.registerRecommendedRoutine() + } } } } diff --git a/Projects/Presentation/Sources/RoutineCreation/View/RoutineCreationViewController.swift b/Projects/Presentation/Sources/RoutineCreation/View/RoutineCreationViewController.swift index 981b9ed..9431abf 100644 --- a/Projects/Presentation/Sources/RoutineCreation/View/RoutineCreationViewController.swift +++ b/Projects/Presentation/Sources/RoutineCreation/View/RoutineCreationViewController.swift @@ -112,6 +112,7 @@ final class RoutineCreationViewController: BaseViewController let executionTimePublisher: AnyPublisher let isRoutineValid: AnyPublisher + let networkErrorPublisher: AnyPublisher<(() -> Void)?, Never> } private(set) var output: Output @@ -55,6 +56,7 @@ final class RoutineCreationViewModel: ViewModel { private let executionTimeSubject = CurrentValueSubject(.init(startAt: nil)) private let checkRoutinePublisher = CurrentValueSubject(false) private let routineUseCase: RoutineUseCaseProtocol + private let networkRetryHandler: NetworkRetryHandler private let recommenededRoutineUseCase: RecommendedRoutineUseCaseProtocol private let maxSubRoutineCount: Int = 3 private var deletedSubroutines = Set() @@ -65,7 +67,9 @@ final class RoutineCreationViewModel: ViewModel { init(routineUseCase: RoutineUseCaseProtocol, recommenededRoutineUseCase: RecommendedRoutineUseCaseProtocol) { self.routineUseCase = routineUseCase self.recommenededRoutineUseCase = recommenededRoutineUseCase - + + networkRetryHandler = NetworkRetryHandler() + output = Output( namePublisher: nameSubject.eraseToAnyPublisher(), subRoutinesPublisher: subRoutinesSubject.eraseToAnyPublisher(), @@ -77,8 +81,9 @@ final class RoutineCreationViewModel: ViewModel { executionTimePublisher: executionTimeSubject .map { $0.startAt } .eraseToAnyPublisher(), - isRoutineValid: checkRoutinePublisher.eraseToAnyPublisher()) - + isRoutineValid: checkRoutinePublisher.eraseToAnyPublisher(), + networkErrorPublisher: networkRetryHandler.networkErrorActionSubject.eraseToAnyPublisher()) + updateIsRoutineValid() } @@ -157,7 +162,11 @@ final class RoutineCreationViewModel: ViewModel { updateIsRoutineValid() } catch { - // TODO: - 요기도 마찬가지 (ViewModel 공통 todo) + // TODO: - 네트워크 에러 제외 오류 처리 + + networkRetryHandler.handleNetworkError(error) { [weak self] in + self?.fetchRoutine(id: id) + } } } } @@ -175,6 +184,9 @@ final class RoutineCreationViewModel: ViewModel { updateIsRoutineValid() } catch { + networkRetryHandler.handleNetworkError(error) { [weak self] in + self?.fetchRecommendedRoutine(id: id) + } } } } @@ -289,8 +301,12 @@ final class RoutineCreationViewModel: ViewModel { applyDateType: updateType) try await routineUseCase.saveRoutine(routine: routine) - } catch { + networkRetryHandler.clearRetryState() + } catch { + networkRetryHandler.handleNetworkError(error) { [weak self] in + self?.registerRoutine() + } } } } diff --git a/Projects/Presentation/Sources/RoutineList/View/RoutineDeleteAlertViewController.swift b/Projects/Presentation/Sources/RoutineList/View/RoutineDeleteAlertViewController.swift index e6849b0..0d7856e 100644 --- a/Projects/Presentation/Sources/RoutineList/View/RoutineDeleteAlertViewController.swift +++ b/Projects/Presentation/Sources/RoutineList/View/RoutineDeleteAlertViewController.swift @@ -54,6 +54,7 @@ final class RoutineDeleteAlertViewController: BaseViewController } override func configureAttribute() { + super.configureAttribute() weekView.delegate = self emptyView.isHidden = true @@ -76,6 +77,7 @@ final class RoutineListViewController: BaseViewController } override func configureLayout() { + super.configureLayout() let safeArea = view.safeAreaLayoutGuide view.backgroundColor = BitnagilColor.gray99 configureCustomNavigationBar(navigationBarStyle: .withBackButton(title: "루틴 리스트"), backgroundColor: BitnagilColor.gray99) @@ -143,6 +145,8 @@ final class RoutineListViewController: BaseViewController } .store(in: &cancellables) + bindNetworkError(from: viewModel.output.networkErrorPublisher) + NotificationCenter.default.publisher(for: .showDeletedRoutineToast) .receive(on: DispatchQueue.main) .sink { [weak self] _ in diff --git a/Projects/Presentation/Sources/RoutineList/ViewModel/RoutineListViewModel.swift b/Projects/Presentation/Sources/RoutineList/ViewModel/RoutineListViewModel.swift index ae671a9..824e2db 100644 --- a/Projects/Presentation/Sources/RoutineList/ViewModel/RoutineListViewModel.swift +++ b/Projects/Presentation/Sources/RoutineList/ViewModel/RoutineListViewModel.swift @@ -23,6 +23,7 @@ final class RoutineListViewModel: ViewModel { let selectedDatePublisher: AnyPublisher let routinesPublisher: AnyPublisher<[Routine], Never> let deleteRoutineResultPublisher: AnyPublisher + let networkErrorPublisher: AnyPublisher<(() -> Void)?, Never> } private(set) var output: Output @@ -31,17 +32,21 @@ final class RoutineListViewModel: ViewModel { private let routinesSubject = CurrentValueSubject<[Routine], Never>([]) private let selectedRoutine = CurrentValueSubject(nil) private let deleteRoutineResultSubject = PassthroughSubject() + private let networkRetryHandler: NetworkRetryHandler private var routines: [String: [Routine]] = [:] private let calendar = Calendar.current private let routineRepository: RoutineRepositoryProtocol init(routineRepository: RoutineRepositoryProtocol) { + networkRetryHandler = NetworkRetryHandler() + self.routineRepository = routineRepository self.output = Output( fetchRoutinesResultPublisher: fetchRoutinesResultSubject.eraseToAnyPublisher(), selectedDatePublisher: selectedDateSubject.eraseToAnyPublisher(), routinesPublisher: routinesSubject.eraseToAnyPublisher(), - deleteRoutineResultPublisher: deleteRoutineResultSubject.eraseToAnyPublisher()) + deleteRoutineResultPublisher: deleteRoutineResultSubject.eraseToAnyPublisher(), + networkErrorPublisher: networkRetryHandler.networkErrorActionSubject.eraseToAnyPublisher()) } func action(input: Input) { @@ -81,8 +86,14 @@ final class RoutineListViewModel: ViewModel { self.routines[date] = routine } fetchRoutinesResultSubject.send(true) + + networkRetryHandler.clearRetryState() } catch { fetchRoutinesResultSubject.send(false) + + networkRetryHandler.handleNetworkError(error) { [weak self] in + self?.fetchRoutines() + } } } } @@ -121,8 +132,14 @@ final class RoutineListViewModel: ViewModel { deleteRoutineResultSubject.send(true) fetchRoutines() showDeletedRoutineToastMessageView() + + networkRetryHandler.clearRetryState() } catch { deleteRoutineResultSubject.send(false) + + networkRetryHandler.handleNetworkError(error) { [weak self] in + self?.deleteRoutine(isDeleteAllRoutines: isDeleteAllRoutines) + } } } } diff --git a/Projects/Presentation/Sources/Setting/View/SettingView.swift b/Projects/Presentation/Sources/Setting/View/SettingView.swift index 643f6a3..aea11fa 100644 --- a/Projects/Presentation/Sources/Setting/View/SettingView.swift +++ b/Projects/Presentation/Sources/Setting/View/SettingView.swift @@ -115,6 +115,7 @@ final class SettingView: BaseViewController { } override func configureAttribute() { + super.configureAttribute() view.backgroundColor = .white guard @@ -135,6 +136,7 @@ final class SettingView: BaseViewController { } override func configureLayout() { + super.configureLayout() let safeArea = view.safeAreaLayoutGuide view.addSubview(tableView) diff --git a/Projects/Presentation/Sources/Withdraw/View/WithdrawViewController.swift b/Projects/Presentation/Sources/Withdraw/View/WithdrawViewController.swift index bd1d7cd..d737f24 100644 --- a/Projects/Presentation/Sources/Withdraw/View/WithdrawViewController.swift +++ b/Projects/Presentation/Sources/Withdraw/View/WithdrawViewController.swift @@ -78,6 +78,8 @@ final class WithdrawViewController: BaseViewController { } override func configureAttribute() { + super.configureAttribute() + view.backgroundColor = .systemBackground navigationController?.setNavigationBarHidden(true, animated: false) configureCustomNavigationBar(navigationBarStyle: .withBackButton(title: "탈퇴하기")) @@ -161,6 +163,7 @@ final class WithdrawViewController: BaseViewController { } override func configureLayout() { + super.configureLayout() let safeArea = view.safeAreaLayoutGuide view.addSubview(mainLabel)