From 48d16e33ee5a3946481d371828df562d75aea752 Mon Sep 17 00:00:00 2001 From: taipaise Date: Tue, 17 Feb 2026 16:25:14 +0900 Subject: [PATCH 1/8] =?UTF-8?q?fix:=20NetworkService=EC=97=90=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=EB=84=B7=20=EC=97=B0=EA=B2=B0=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?throw=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/NetworkService/NetworkService.swift | 14 +++++++++++++- .../Domain/Sources/Entity/Enum/DomainError.swift | 0 .../Common/Component/NetworkErrorView.swift | 0 .../Common/Protocol/NetworkRetryCapable.swift | 0 4 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 Projects/Domain/Sources/Entity/Enum/DomainError.swift create mode 100644 Projects/Presentation/Sources/Common/Component/NetworkErrorView.swift create mode 100644 Projects/Presentation/Sources/Common/Protocol/NetworkRetryCapable.swift diff --git a/Projects/DataSource/Sources/NetworkService/NetworkService.swift b/Projects/DataSource/Sources/NetworkService/NetworkService.swift index aa98c779..e2c80823 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/Domain/Sources/Entity/Enum/DomainError.swift b/Projects/Domain/Sources/Entity/Enum/DomainError.swift new file mode 100644 index 00000000..e69de29b diff --git a/Projects/Presentation/Sources/Common/Component/NetworkErrorView.swift b/Projects/Presentation/Sources/Common/Component/NetworkErrorView.swift new file mode 100644 index 00000000..e69de29b diff --git a/Projects/Presentation/Sources/Common/Protocol/NetworkRetryCapable.swift b/Projects/Presentation/Sources/Common/Protocol/NetworkRetryCapable.swift new file mode 100644 index 00000000..e69de29b From dc31176ac2706d1b6ee682ee41ad55d2411cb5ad Mon Sep 17 00:00:00 2001 From: taipaise Date: Tue, 17 Feb 2026 16:36:39 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20Domain=20Error=20=EC=A0=95=EC=9D=98?= =?UTF-8?q?=20=EB=B0=8F=20repository=EC=97=90=EC=84=9C=20Domain=20Error?= =?UTF-8?q?=EB=A5=BC=20throw=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/EmotionRepository.swift | 60 +++++++-- .../Sources/Repository/FileRepository.swift | 27 +++- .../Repository/LocationRepository.swift | 19 ++- .../Repository/OnboardingRepository.swift | 54 ++++++-- .../RecommendedRoutineRepository.swift | 47 +++++-- .../Sources/Repository/ReportRepository.swift | 62 +++++++-- .../Repository/RoutineRepository.swift | 118 +++++++++++++++--- .../Sources/Entity/Enum/DomainError.swift | 12 ++ 8 files changed, 328 insertions(+), 71 deletions(-) diff --git a/Projects/DataSource/Sources/Repository/EmotionRepository.swift b/Projects/DataSource/Sources/Repository/EmotionRepository.swift index fc0487e9..e30cbbfc 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 7d7aa3c4..4f5c6808 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 f8695a80..3a9d520a 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 e3de37e5..8c30745b 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 f0a51416..58dbc5f1 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 c7205bbb..cc4bf0c7 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 ab4632c1..7e8ef504 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 index e69de29b..f33d052a 100644 --- a/Projects/Domain/Sources/Entity/Enum/DomainError.swift +++ 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 +} From 2f83e18a62e83293775b25a6c0d9050de4812b41 Mon Sep 17 00:00:00 2001 From: taipaise Date: Tue, 17 Feb 2026 16:49:23 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=EB=84=A4=ED=8A=B8=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20View=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Common/Component/NetworkErrorView.swift | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/Projects/Presentation/Sources/Common/Component/NetworkErrorView.swift b/Projects/Presentation/Sources/Common/Component/NetworkErrorView.swift index e69de29b..fd180916 100644 --- a/Projects/Presentation/Sources/Common/Component/NetworkErrorView.swift +++ b/Projects/Presentation/Sources/Common/Component/NetworkErrorView.swift @@ -0,0 +1,122 @@ +// +// 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) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureAttribute() { + backgroundColor = .white + contentView.backgroundColor = .white + + errorImageView.image = BitnagilIcon + .exclamationFilledIcon? + .withTintColor(BitnagilColor.gray60 ?? .gray, renderingMode: .alwaysTemplate) + + 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() + } + } +} From 47a8f5370a0f17f2e6ede2d4fabf1fbd28cd0f36 Mon Sep 17 00:00:00 2001 From: taipaise Date: Tue, 17 Feb 2026 16:48:25 +0900 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20BaseViewController=EC=9D=98=20?= =?UTF-8?q?=EC=84=9C=EB=B8=8C=ED=81=B4=EB=9E=98=EC=8A=A4=EB=93=A4=EC=9D=B4?= =?UTF-8?q?=20super=EC=9D=98=20configureAttribute,=20configureLayout?= =?UTF-8?q?=EC=9D=84=20=ED=98=B8=EC=B6=9C=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Common/Protocol/BaseViewController.swift | 44 +++++++++++++++++-- .../EmotionRegistrationViewController.swift | 4 ++ .../Home/View/HomeViewController.swift | 4 ++ .../Login/View/IntroViewController.swift | 4 ++ .../Login/View/LoginViewController.swift | 4 ++ .../View/TermsAgreementViewController.swift | 4 ++ .../Sources/MyPage/View/MypageView.swift | 4 ++ .../View/OnboardingResultViewController.swift | 4 ++ .../View/OnboardingViewController.swift | 4 ++ .../RecommendedRoutineViewController.swift | 4 ++ .../View/ReportCompleteViewController.swift | 4 ++ .../View/ReportDetailViewController.swift | 4 ++ .../View/ReportHistoryViewController.swift | 3 ++ .../ReportRegistrationViewController.swift | 4 ++ ...sultRecommendedRoutineViewController.swift | 2 + .../View/RoutineCreationViewController.swift | 2 + .../RoutineDeleteAlertViewController.swift | 2 + .../View/RoutineDeleteViewController.swift | 2 + .../View/RoutineListViewController.swift | 2 + .../Sources/Setting/View/SettingView.swift | 2 + .../View/WithdrawViewController.swift | 3 ++ 21 files changed, 107 insertions(+), 3 deletions(-) diff --git a/Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift b/Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift index a3b75a9a..e0cfeabd 100644 --- a/Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift +++ b/Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift @@ -5,17 +5,20 @@ // 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() init(viewModel: T) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -30,11 +33,46 @@ public class BaseViewController: UIViewController { } /// 뷰의 속성(스타일, 컬러, 폰트 등)을 설정합니다. - func configureAttribute() { } + func configureAttribute() { + networkErrorView.isHidden = true + } /// 뷰의 계층 구조를 구성하고 Auto Layout 제약을 설정합니다. - func configureLayout() { } + func configureLayout() { + view.addSubview(networkErrorView) + + view.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } /// ViewModel의 데이터를 구독하고 UI에 바인딩합니다. func bind() { } + + /// 네트워크 재시도 기능이 필요할 경우 BaseViewController의 subclass 내의 bind() 함수 내에서 호출합니다. + /// 네트워크 재시도 화면의 '다시 시도하기' 버튼의 재시도 action을 설정합니다. + /// - Parameter retryViewModel: NetworkRetryCapable를 채택한 ViewModel + func bindNetworkError(to retryViewModel: NetworkRetryCapable) { + retryViewModel.networkErrorActionSubject + .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) { + if show { + view.bringSubviewToFront(networkErrorView) + networkErrorView.isHidden = false + } else { + networkErrorView.isHidden = true + } + } } diff --git a/Projects/Presentation/Sources/EmotionRegister/View/EmotionRegistrationViewController.swift b/Projects/Presentation/Sources/EmotionRegister/View/EmotionRegistrationViewController.swift index 7f83c5e8..74461639 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) diff --git a/Projects/Presentation/Sources/Login/View/IntroViewController.swift b/Projects/Presentation/Sources/Login/View/IntroViewController.swift index f10349e1..7aa0b014 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 04ca6dfb..5028769d 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 0da5b4fc..b0ed0924 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 ec856794..e5de4b6d 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 ae4c1bd6..b3984de5 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 8570d693..09a6ee91 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 072a077b..3d7c0772 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 } 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) diff --git a/Projects/Presentation/Sources/Setting/View/SettingView.swift b/Projects/Presentation/Sources/Setting/View/SettingView.swift index 643f6a3a..aea11fad 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 bd1d7cd3..d737f24d 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) From 33489da5c391712069f273238fef74b4386f650e Mon Sep 17 00:00:00 2001 From: taipaise Date: Tue, 17 Feb 2026 16:49:56 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20=EB=84=A4=ED=8A=B8=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=20=EC=9A=94=EC=B2=AD=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?protocol=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Common/Protocol/NetworkRetryCapable.swift | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/Projects/Presentation/Sources/Common/Protocol/NetworkRetryCapable.swift b/Projects/Presentation/Sources/Common/Protocol/NetworkRetryCapable.swift index e69de29b..fafbc459 100644 --- a/Projects/Presentation/Sources/Common/Protocol/NetworkRetryCapable.swift +++ 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) + } +} From c3c2c5a09b9ab3c66c72d19b27e8cd8b699b71e1 Mon Sep 17 00:00:00 2001 From: taipaise Date: Tue, 17 Feb 2026 17:21:13 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20View=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NetworkError/Contents.json | 6 +++++ .../network_error_icon.imageset/Contents.json | 23 ++++++++++++++++++ .../ic_network_error.png | Bin 0 -> 1004 bytes .../ic_network_error@2x.png | Bin 0 -> 1827 bytes .../ic_network_error@3x.png | Bin 0 -> 2630 bytes .../Common/Component/NetworkErrorView.swift | 7 ++++-- .../Common/DesignSystem/BitnagilIcon.swift | 3 +++ .../Common/Protocol/BaseViewController.swift | 10 +++++++- 8 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 Projects/Presentation/Resources/Images.xcassets/NetworkError/Contents.json create mode 100644 Projects/Presentation/Resources/Images.xcassets/NetworkError/network_error_icon.imageset/Contents.json create mode 100644 Projects/Presentation/Resources/Images.xcassets/NetworkError/network_error_icon.imageset/ic_network_error.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/NetworkError/network_error_icon.imageset/ic_network_error@2x.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/NetworkError/network_error_icon.imageset/ic_network_error@3x.png 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 00000000..73c00596 --- /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 00000000..0738a4d3 --- /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 0000000000000000000000000000000000000000..7bf0e56b43102cd3e221bea7b8e3893c79169e0b GIT binary patch literal 1004 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 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/Protocol/BaseViewController.swift b/Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift index e0cfeabd..d9b6fdf8 100644 --- a/Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift +++ b/Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift @@ -19,6 +19,12 @@ public class BaseViewController: UIViewController { super.init(nibName: nil, bundle: nil) } + deinit { + DispatchQueue.main.async { [weak tabBarController = self.tabBarController] in + tabBarController?.tabBar.isHidden = false + } + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -41,7 +47,7 @@ public class BaseViewController: UIViewController { func configureLayout() { view.addSubview(networkErrorView) - view.snp.makeConstraints { make in + networkErrorView.snp.makeConstraints { make in make.edges.equalToSuperview() } } @@ -71,8 +77,10 @@ public class BaseViewController: UIViewController { if show { view.bringSubviewToFront(networkErrorView) networkErrorView.isHidden = false + tabBarController?.tabBar.isHidden = true } else { networkErrorView.isHidden = true + tabBarController?.tabBar.isHidden = false } } } From c2e269caebbdd65d2240bcc72811e414f948fe24 Mon Sep 17 00:00:00 2001 From: taipaise Date: Tue, 17 Feb 2026 18:53:50 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20=EB=84=A4=ED=8A=B8=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=20=EC=97=B0=EA=B2=B0=20=EC=98=A4=EB=A5=98=20=EC=8B=9C?= =?UTF-8?q?,=20network=20Error=20View=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Common/NetworkRetryHandler.swift | 11 ++++++ .../Common/Protocol/BaseViewController.swift | 6 +-- .../Home/View/HomeViewController.swift | 30 ++++++++------- .../Home/ViewModel/HomeViewModel.swift | 37 ++++++++++++++++--- .../View/ReportHistoryViewController.swift | 2 + .../ViewModel/ReportDetailViewModel.swift | 14 ++++++- .../ViewModel/ReportHistoryViewModel.swift | 12 +++++- ...sultRecommendedRoutineViewController.swift | 2 + .../ResultRecommendedRoutineViewModel.swift | 20 +++++++++- .../ViewModel/RoutineCreationViewModel.swift | 26 ++++++++++--- .../View/RoutineListViewController.swift | 2 + .../ViewModel/RoutineListViewModel.swift | 19 +++++++++- 12 files changed, 148 insertions(+), 33 deletions(-) create mode 100644 Projects/Presentation/Sources/Common/NetworkRetryHandler.swift diff --git a/Projects/Presentation/Sources/Common/NetworkRetryHandler.swift b/Projects/Presentation/Sources/Common/NetworkRetryHandler.swift new file mode 100644 index 00000000..0215d91c --- /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 d9b6fdf8..864a9f07 100644 --- a/Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift +++ b/Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift @@ -57,9 +57,9 @@ public class BaseViewController: UIViewController { /// 네트워크 재시도 기능이 필요할 경우 BaseViewController의 subclass 내의 bind() 함수 내에서 호출합니다. /// 네트워크 재시도 화면의 '다시 시도하기' 버튼의 재시도 action을 설정합니다. - /// - Parameter retryViewModel: NetworkRetryCapable를 채택한 ViewModel - func bindNetworkError(to retryViewModel: NetworkRetryCapable) { - retryViewModel.networkErrorActionSubject + /// - 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 { diff --git a/Projects/Presentation/Sources/Home/View/HomeViewController.swift b/Projects/Presentation/Sources/Home/View/HomeViewController.swift index a27a5558..31c8de33 100644 --- a/Projects/Presentation/Sources/Home/View/HomeViewController.swift +++ b/Projects/Presentation/Sources/Home/View/HomeViewController.swift @@ -410,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 @@ -418,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 @@ -428,7 +428,7 @@ final class HomeViewController: BaseViewController { self?.hideIndicatorView() } .store(in: &cancellables) - + viewModel.output.routinesPublisher .receive(on: DispatchQueue.main) .sink { [weak self] routines in @@ -436,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 @@ -453,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 a65d3d51..0a138994 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/Report/View/ReportHistoryViewController.swift b/Projects/Presentation/Sources/Report/View/ReportHistoryViewController.swift index 3d7c0772..20a64acf 100644 --- a/Projects/Presentation/Sources/Report/View/ReportHistoryViewController.swift +++ b/Projects/Presentation/Sources/Report/View/ReportHistoryViewController.swift @@ -198,6 +198,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 e5480c5b..a806cb69 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 0f9d44cc..d92e7915 100644 --- a/Projects/Presentation/Sources/ResultRecommendedRoutine/View/ResultRecommendedRoutineViewController.swift +++ b/Projects/Presentation/Sources/ResultRecommendedRoutine/View/ResultRecommendedRoutineViewController.swift @@ -264,6 +264,8 @@ 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?.fetchResultRecommendedRoutines() + } } } } diff --git a/Projects/Presentation/Sources/RoutineCreation/ViewModel/RoutineCreationViewModel.swift b/Projects/Presentation/Sources/RoutineCreation/ViewModel/RoutineCreationViewModel.swift index d5dbac61..7d98a12c 100644 --- a/Projects/Presentation/Sources/RoutineCreation/ViewModel/RoutineCreationViewModel.swift +++ b/Projects/Presentation/Sources/RoutineCreation/ViewModel/RoutineCreationViewModel.swift @@ -44,6 +44,7 @@ final class RoutineCreationViewModel: ViewModel { let periodPublisher: AnyPublisher<(Date?, Date?), Never> 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/RoutineListViewController.swift b/Projects/Presentation/Sources/RoutineList/View/RoutineListViewController.swift index b90c36ec..23b1a346 100644 --- a/Projects/Presentation/Sources/RoutineList/View/RoutineListViewController.swift +++ b/Projects/Presentation/Sources/RoutineList/View/RoutineListViewController.swift @@ -145,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 ae671a9c..92ff5db1 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?.fetchRoutines() + } } } } From 7df525510d403c5ba42c161545176f125b021112 Mon Sep 17 00:00:00 2001 From: taipaise Date: Tue, 17 Feb 2026 19:50:08 +0900 Subject: [PATCH 8/8] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Common/Component/NetworkErrorView.swift | 4 ++-- .../Sources/Common/Protocol/BaseViewController.swift | 6 ++++++ .../ViewModel/ResultRecommendedRoutineViewModel.swift | 2 +- .../RoutineList/ViewModel/RoutineListViewModel.swift | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Projects/Presentation/Sources/Common/Component/NetworkErrorView.swift b/Projects/Presentation/Sources/Common/Component/NetworkErrorView.swift index 581b9424..4947a89c 100644 --- a/Projects/Presentation/Sources/Common/Component/NetworkErrorView.swift +++ b/Projects/Presentation/Sources/Common/Component/NetworkErrorView.swift @@ -13,7 +13,7 @@ final class NetworkErrorView: UIView { static let contentViewWidth: CGFloat = 204 static let contentViewHeight: CGFloat = 215 static let errorImageSize: CGFloat = 40 - static let boldtitleLabelTopSpacing: CGFloat = 21 + static let boldTitleLabelTopSpacing: CGFloat = 21 static let boldTitleLabelHeight: CGFloat = 30 static let mediumTitleLabelTopSpacing: CGFloat = 2 static let mediumTitleLabelHeight: CGFloat = 24 @@ -95,7 +95,7 @@ final class NetworkErrorView: UIView { boldTitleLabel.snp.makeConstraints { make in make.top .equalTo(errorImageView.snp.bottom) - .offset(Layout.boldtitleLabelTopSpacing) + .offset(Layout.boldTitleLabelTopSpacing) make.height.equalTo(Layout.boldTitleLabelHeight) diff --git a/Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift b/Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift index 864a9f07..cc9c4877 100644 --- a/Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift +++ b/Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift @@ -13,6 +13,8 @@ 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 @@ -20,6 +22,8 @@ public class BaseViewController: UIViewController { } deinit { + guard isShowingNetworkError else { return } + DispatchQueue.main.async { [weak tabBarController = self.tabBarController] in tabBarController?.tabBar.isHidden = false } @@ -74,6 +78,8 @@ public class BaseViewController: UIViewController { /// 네트워크 에러 뷰 를 보이거나 숨깁니다. private func handleNetworkErrorView(show: Bool) { + isShowingNetworkError = show + if show { view.bringSubviewToFront(networkErrorView) networkErrorView.isHidden = false diff --git a/Projects/Presentation/Sources/ResultRecommendedRoutine/ViewModel/ResultRecommendedRoutineViewModel.swift b/Projects/Presentation/Sources/ResultRecommendedRoutine/ViewModel/ResultRecommendedRoutineViewModel.swift index 46463fac..c844bacc 100644 --- a/Projects/Presentation/Sources/ResultRecommendedRoutine/ViewModel/ResultRecommendedRoutineViewModel.swift +++ b/Projects/Presentation/Sources/ResultRecommendedRoutine/ViewModel/ResultRecommendedRoutineViewModel.swift @@ -168,7 +168,7 @@ final class ResultRecommendedRoutineViewModel: ViewModel { registerRoutineResultSubject.send(false) networkRetryHandler.handleNetworkError(error) { [weak self] in - self?.fetchResultRecommendedRoutines() + self?.registerRecommendedRoutine() } } } diff --git a/Projects/Presentation/Sources/RoutineList/ViewModel/RoutineListViewModel.swift b/Projects/Presentation/Sources/RoutineList/ViewModel/RoutineListViewModel.swift index 92ff5db1..824e2dbf 100644 --- a/Projects/Presentation/Sources/RoutineList/ViewModel/RoutineListViewModel.swift +++ b/Projects/Presentation/Sources/RoutineList/ViewModel/RoutineListViewModel.swift @@ -138,7 +138,7 @@ final class RoutineListViewModel: ViewModel { deleteRoutineResultSubject.send(false) networkRetryHandler.handleNetworkError(error) { [weak self] in - self?.fetchRoutines() + self?.deleteRoutine(isDeleteAllRoutines: isDeleteAllRoutines) } } }