From 4ff8b2956f83cd378c754300a59ec9771cf564bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Fri, 17 Oct 2025 22:49:46 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat.=20fetchMovieDetailUseCase=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Repository/MockMovieRepository.swift | 4 ++ .../Data/Repository/MovieRepository.swift | 4 ++ .../Repository/MovieRepositoryProtocol.swift | 1 + .../Movie/FetchMovieDetailUseCase.swift | 40 +++++++++++++++++++ ...gView.swift => DetailStarRatingView.swift} | 12 +++--- 5 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 MovieBooking/Domain/UseCase/Movie/FetchMovieDetailUseCase.swift rename MovieBooking/Feature/MovieDetail/Components/{StarRatingView.swift => DetailStarRatingView.swift} (89%) diff --git a/MovieBooking/Data/Repository/MockMovieRepository.swift b/MovieBooking/Data/Repository/MockMovieRepository.swift index d544806..fe16308 100644 --- a/MovieBooking/Data/Repository/MockMovieRepository.swift +++ b/MovieBooking/Data/Repository/MockMovieRepository.swift @@ -20,4 +20,8 @@ struct MockMovieRepository: MovieRepositoryProtocol { try await Task.sleep(for: .seconds(1)) return Movie.mockData } + + func fetchMovieDetail(id: String) async throws -> MovieDetail { + return MovieDetail.mockData + } } diff --git a/MovieBooking/Data/Repository/MovieRepository.swift b/MovieBooking/Data/Repository/MovieRepository.swift index 09d6dea..fecd096 100644 --- a/MovieBooking/Data/Repository/MovieRepository.swift +++ b/MovieBooking/Data/Repository/MovieRepository.swift @@ -30,4 +30,8 @@ struct MovieRepository: MovieRepositoryProtocol { func fetchPopularMovies() async throws -> [Movie] { try await fetchMovies(for: .popular) } + + func fetchMovieDetail(id: String) async throws -> MovieDetail { + try await dataSource.movieDetail(id).toDomain() + } } diff --git a/MovieBooking/Domain/Repository/MovieRepositoryProtocol.swift b/MovieBooking/Domain/Repository/MovieRepositoryProtocol.swift index 04050c4..62d7877 100644 --- a/MovieBooking/Domain/Repository/MovieRepositoryProtocol.swift +++ b/MovieBooking/Domain/Repository/MovieRepositoryProtocol.swift @@ -12,6 +12,7 @@ protocol MovieRepositoryProtocol { func fetchNowPlayingMovies() async throws -> [Movie] func fetchUpcomingMovies() async throws -> [Movie] func fetchPopularMovies() async throws -> [Movie] + func fetchMovieDetail(id: String) async throws -> MovieDetail } private enum MovieRepositoryKey: DependencyKey { diff --git a/MovieBooking/Domain/UseCase/Movie/FetchMovieDetailUseCase.swift b/MovieBooking/Domain/UseCase/Movie/FetchMovieDetailUseCase.swift new file mode 100644 index 0000000..f2acf1c --- /dev/null +++ b/MovieBooking/Domain/UseCase/Movie/FetchMovieDetailUseCase.swift @@ -0,0 +1,40 @@ +// +// FetchMovieDetailUseCase.swift +// MovieBooking +// +// Created by 홍석현 on 10/17/25. +// + +import Foundation +import Dependencies + +protocol FetchMovieDetailUseCaseProtocol { + func execute(_ id: String) async throws -> MovieDetail +} + +struct FetchMovieDetailUseCase: FetchMovieDetailUseCaseProtocol { + @Dependency(\.movieRepository) var repository: MovieRepositoryProtocol + + func execute(_ id: String) async throws -> MovieDetail { + return MovieDetail.mockData + } +} + +private enum FetchMovieDetailUseCaseKey: DependencyKey { + static let liveValue: any FetchMovieDetailUseCaseProtocol = FetchMovieDetailUseCase() + static let previewValue: any FetchMovieDetailUseCaseProtocol = MockFetchMovieDetailUseCase() + static let testValue: any FetchMovieDetailUseCaseProtocol = MockFetchMovieDetailUseCase() +} + +extension DependencyValues { + var fetchMovieDetailUseCase: FetchMovieDetailUseCaseProtocol { + get { self[FetchMovieDetailUseCaseKey.self] } + set { self[FetchMovieDetailUseCaseKey.self] = newValue } + } +} + +struct MockFetchMovieDetailUseCase: FetchMovieDetailUseCaseProtocol { + func execute(_ id: String) async throws -> MovieDetail { + return MovieDetail.mockData + } +} diff --git a/MovieBooking/Feature/MovieDetail/Components/StarRatingView.swift b/MovieBooking/Feature/MovieDetail/Components/DetailStarRatingView.swift similarity index 89% rename from MovieBooking/Feature/MovieDetail/Components/StarRatingView.swift rename to MovieBooking/Feature/MovieDetail/Components/DetailStarRatingView.swift index 7b59b48..211df86 100644 --- a/MovieBooking/Feature/MovieDetail/Components/StarRatingView.swift +++ b/MovieBooking/Feature/MovieDetail/Components/DetailStarRatingView.swift @@ -7,7 +7,7 @@ import SwiftUI -struct StarRatingView: View { +struct DetailStarRatingView: View { private let rating: Double // 0.0 ~ 10.0 범위 private let maxStars: Int = 5 @@ -72,15 +72,15 @@ struct StarRatingView: View { #Preview { VStack(spacing: 20) { - StarRatingView(rating: 8.7) + DetailStarRatingView(rating: 8.7) - StarRatingView(rating: 7.5) + DetailStarRatingView(rating: 7.5) - StarRatingView(rating: 9.2) + DetailStarRatingView(rating: 9.2) - StarRatingView(rating: 5.0) + DetailStarRatingView(rating: 5.0) - StarRatingView(rating: 10.0) + DetailStarRatingView(rating: 10.0) } .padding() } From 3d20bd6b83b9e36558f2bf2e3a034ab5ab07d90a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Fri, 17 Oct 2025 22:50:30 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat.=20Genre=20=EB=B0=B0=EC=97=B4=EB=A1=9C?= =?UTF-8?q?=20=EB=B0=9B=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20=EB=B0=8F?= =?UTF-8?q?=20=EC=9E=A5=EB=A5=B4=EB=B3=84=20=EC=83=89=EC=83=81=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DTO/Response/MovieDetailResponseDTO.swift | 48 +++++++++++++------ MovieBooking/Domain/Entity/MovieDetail.swift | 27 +++++++---- .../MovieDetail/Components/GenreLabel.swift | 37 +++++++++++--- 3 files changed, 83 insertions(+), 29 deletions(-) diff --git a/MovieBooking/Data/DTO/Response/MovieDetailResponseDTO.swift b/MovieBooking/Data/DTO/Response/MovieDetailResponseDTO.swift index 9db973f..a3a294c 100644 --- a/MovieBooking/Data/DTO/Response/MovieDetailResponseDTO.swift +++ b/MovieBooking/Data/DTO/Response/MovieDetailResponseDTO.swift @@ -8,21 +8,39 @@ import Foundation struct MovieDetailResponseDTO: Decodable { - let id: Int - let title: String - let originalTitle: String - let overview: String? - let posterPath: String? - let backdropPath: String? - let releaseDate: String - let runtime: Int? - let genres: [Genre] - let voteAverage: Double - let voteCount: Int - let popularity: Double + let id: Int + let title: String + let originalTitle: String + let overview: String? + let posterPath: String? + let backdropPath: String? + let releaseDate: String + let runtime: Int + let genres: [GenreDTO] + let voteAverage: Double + let voteCount: Int + let popularity: Double } -struct Genre: Decodable { - let id: Int - let name: String +struct GenreDTO: Decodable { + let id: Int + let name: String + + func toDomain() -> Genre { + return Genre(id: String(self.id), name: self.name) + } +} + +extension MovieDetailResponseDTO { + func toDomain() -> MovieDetail { + return MovieDetail( + title: self.title, + genres: self.genres.map { $0.toDomain() }, // 배열 전체 매핑 + releaseDate: releaseDate, + runningTime: runtime * 60, // minutes to seconds + rating: voteAverage, + posterPath: posterPath, + summary: overview ?? "No overview available." + ) + } } diff --git a/MovieBooking/Domain/Entity/MovieDetail.swift b/MovieBooking/Domain/Entity/MovieDetail.swift index 8dd01cf..06ccbe5 100644 --- a/MovieBooking/Domain/Entity/MovieDetail.swift +++ b/MovieBooking/Domain/Entity/MovieDetail.swift @@ -9,25 +9,27 @@ import Foundation struct MovieDetail { let title: String - let genres: String + let genres: [Genre] // 배열로 변경 let releaseDate: Date? let runningTime: Int let rating: Double let posterPath: String? let summary: String - + let ticketPrice: Int // 티켓 가격 + init( title: String, - genres: String, + genres: [Genre], // 배열로 변경 releaseDate: String, runningTime: Int, rating: Double, posterPath: String?, - summary: String + summary: String, + ticketPrice: Int = 13000 // 기본값 13,000원 ) { let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd." - + dateFormatter.dateFormat = "yyyy-MM-dd" + self.title = title self.genres = genres self.releaseDate = dateFormatter.date(from: releaseDate) @@ -35,14 +37,23 @@ struct MovieDetail { self.rating = rating self.posterPath = posterPath self.summary = summary + self.ticketPrice = ticketPrice } } +struct Genre { + let id: String + let name: String +} + extension MovieDetail { static let mockData: MovieDetail = MovieDetail( title: "The Shawshank Redemption", - genres: "Drama", - releaseDate: "1994-9-23.", + genres: [ + Genre(id: "18", name: "드라마"), + Genre(id: "80", name: "범죄") + ], + releaseDate: "1994-09-23", runningTime: 142 * 60, rating: 8.7, posterPath: "/bUrReoZFLGti6ehkBW0xw8f12MT.jpg", diff --git a/MovieBooking/Feature/MovieDetail/Components/GenreLabel.swift b/MovieBooking/Feature/MovieDetail/Components/GenreLabel.swift index 2918d9e..388f0a2 100644 --- a/MovieBooking/Feature/MovieDetail/Components/GenreLabel.swift +++ b/MovieBooking/Feature/MovieDetail/Components/GenreLabel.swift @@ -9,26 +9,51 @@ import SwiftUI struct GenreLabel: View { private let genre: Genre - + init(genre: Genre) { self.genre = genre } - + + private var genreColor: Color { + switch genre.id { + case "28": return Color(red: 0.95, green: 0.26, blue: 0.21) // 액션 - Vibrant Red + case "12": return Color(red: 0.13, green: 0.59, blue: 0.95) // 모험 - Sky Blue + case "16": return Color(red: 1.0, green: 0.49, blue: 0.78) // 애니메이션 - Bright Pink + case "35": return Color(red: 1.0, green: 0.76, blue: 0.03) // 코미디 - Golden Yellow + case "80": return Color(red: 0.29, green: 0.29, blue: 0.29) // 범죄 - Charcoal + case "99": return Color(red: 0.6, green: 0.4, blue: 0.2) // 다큐멘터리 - Brown + case "18": return Color(red: 0.61, green: 0.35, blue: 0.71) // 드라마 - Medium Purple + case "10751": return Color(red: 0.3, green: 0.69, blue: 0.31) // 가족 - Fresh Green + case "14": return Color(red: 0.82, green: 0.41, blue: 0.88) // 판타지 - Orchid + case "36": return Color(red: 0.71, green: 0.55, blue: 0.39) // 역사 - Antique Gold + case "27": return Color(red: 0.18, green: 0.05, blue: 0.21) // 공포 - Deep Purple + case "10402": return Color(red: 0.91, green: 0.12, blue: 0.39) // 음악 - Deep Pink + case "9648": return Color(red: 0.4, green: 0.23, blue: 0.72) // 미스터리 - Royal Purple + case "10749": return Color(red: 1.0, green: 0.31, blue: 0.48) // 로맨스 - Rose + case "878": return Color(red: 0.0, green: 0.74, blue: 0.83) // SF - Cyan + case "10770": return Color(red: 0.61, green: 0.64, blue: 0.69) // TV 영화 - Slate Gray + case "53": return Color(red: 0.9, green: 0.38, blue: 0.18) // 스릴러 - Burnt Orange + case "10752": return Color(red: 0.47, green: 0.53, blue: 0.27) // 전쟁 - Military Green + case "37": return Color(red: 0.82, green: 0.61, blue: 0.36) // 서부 - Desert Sand + default: return Color.basicPurple // 기본값 + } + } + var body: some View { Text(genre.name) .font(.caption) - .foregroundColor(.primary) + .foregroundColor(.white) .padding(.vertical, 4) .padding(.horizontal, 8) - .background(Color.purple.opacity(0.2)) + .background(genreColor) .clipShape(Capsule()) .overlay( Capsule() - .stroke(Color.purple.opacity(0.5), lineWidth: 1) + .stroke(genreColor.opacity(0.5), lineWidth: 1) ) } } #Preview { - GenreLabel(genre: Genre(id: 0, name: "Drama")) + GenreLabel(genre: Genre(id: "0", name: "Drama")) } From be6c1aa2cf9ab96b7a3e2d826067c6bfad13bd2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Fri, 17 Oct 2025 22:50:45 +0900 Subject: [PATCH 3/5] =?UTF-8?q?bugfix.=20=EB=B9=8C=EB=93=9C=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../View/AuthCoordinatorView.swift | 2 +- .../Components/DetailStarRatingView.swift | 2 +- .../Components/MovieDetailCardView.swift | 24 ++++++++++++++----- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/MovieBooking/Feature/Auth/Coordinator/View/AuthCoordinatorView.swift b/MovieBooking/Feature/Auth/Coordinator/View/AuthCoordinatorView.swift index 2f773f7..6172424 100644 --- a/MovieBooking/Feature/Auth/Coordinator/View/AuthCoordinatorView.swift +++ b/MovieBooking/Feature/Auth/Coordinator/View/AuthCoordinatorView.swift @@ -19,7 +19,7 @@ public struct AuthCoordinatorView: View { self.store = store } - var body: some View { + public var body: some View { TCARouter(store.scope(state: \.routes, action: \.router)) { screens in switch screens.case { case .login(let loginStore): diff --git a/MovieBooking/Feature/MovieDetail/Components/DetailStarRatingView.swift b/MovieBooking/Feature/MovieDetail/Components/DetailStarRatingView.swift index 211df86..94bde0c 100644 --- a/MovieBooking/Feature/MovieDetail/Components/DetailStarRatingView.swift +++ b/MovieBooking/Feature/MovieDetail/Components/DetailStarRatingView.swift @@ -1,5 +1,5 @@ // -// StarRatingView.swift +// DetailStarRatingView.swift // MovieBooking // // Created by 홍석현 on 10/17/25. diff --git a/MovieBooking/Feature/MovieDetail/Components/MovieDetailCardView.swift b/MovieBooking/Feature/MovieDetail/Components/MovieDetailCardView.swift index 4c75345..522a3a8 100644 --- a/MovieBooking/Feature/MovieDetail/Components/MovieDetailCardView.swift +++ b/MovieBooking/Feature/MovieDetail/Components/MovieDetailCardView.swift @@ -16,16 +16,17 @@ struct MovieDetailCardView: View { var body: some View { VStack(alignment: .leading, spacing: 24) { - GenreLabel(genre: Genre(id: 0, name: "Drama")) - + // 장르 라벨들을 가로로 나열 + genreLabelsView + VStack(alignment: .leading, spacing: 12) { titleView - - StarRatingView(rating: model.rating) - + + DetailStarRatingView(rating: model.rating) + HStack(spacing: 24) { ReleaseDateView(date: model.releaseDate) - + RunningTimeView(model.runningTime) } } @@ -62,6 +63,17 @@ struct MovieDetailCardView: View { } extension MovieDetailCardView { + // 장르 라벨들 + var genreLabelsView: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(model.genres, id: \.id) { genre in + GenreLabel(genre: genre) + } + } + } + } + // 영화 제목 var titleView: some View { Text(model.title) From 790778146c46c4eee028a15c2b6623ad292aa059 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Fri, 17 Oct 2025 23:14:54 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat.=20=EC=98=81=ED=99=94=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20Feature=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20?= =?UTF-8?q?=ED=95=9C=EA=B5=AD=EC=96=B4=EB=A1=9C=20=ED=8C=8C=EC=8B=B1?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MovieBooking.xcodeproj/project.pbxproj | 1 + .../DTO/Request/MovieDetailRequestDTO.swift | 12 +++ .../Data/DataSources/MovieDataSource.swift | 13 +-- .../Data/Repository/MovieRepository.swift | 2 +- MovieBooking/Data/Request/MovieTarget.swift | 4 +- .../Repository/MovieRepositoryProtocol.swift | 2 +- .../Movie/FetchMovieDetailUseCase.swift | 4 +- .../Components/MovieDetailCardView.swift | 3 +- .../MovieDetail/MovieDetailFeature.swift | 102 ++++++++++++++++++ .../Feature/MovieDetail/MovieDetailView.swift | 38 +++++-- 10 files changed, 159 insertions(+), 22 deletions(-) create mode 100644 MovieBooking/Data/DTO/Request/MovieDetailRequestDTO.swift create mode 100644 MovieBooking/Feature/MovieDetail/MovieDetailFeature.swift diff --git a/MovieBooking.xcodeproj/project.pbxproj b/MovieBooking.xcodeproj/project.pbxproj index 9566074..c8411fd 100644 --- a/MovieBooking.xcodeproj/project.pbxproj +++ b/MovieBooking.xcodeproj/project.pbxproj @@ -51,6 +51,7 @@ Data/DataSources/APIConfiguration.swift, Data/DataSources/MovieDataSource.swift, Data/DTO/Response/MovieDetailResponseDTO.swift, + Feature/MovieDetail/MovieDetailFeature.swift, NetworkService/Core/ContentType.swift, NetworkService/Core/HTTPHeader.swift, NetworkService/Core/HTTPHeaders.swift, diff --git a/MovieBooking/Data/DTO/Request/MovieDetailRequestDTO.swift b/MovieBooking/Data/DTO/Request/MovieDetailRequestDTO.swift new file mode 100644 index 0000000..e1a4b0f --- /dev/null +++ b/MovieBooking/Data/DTO/Request/MovieDetailRequestDTO.swift @@ -0,0 +1,12 @@ +// +// MovieDetailRequestDTO.swift +// MovieBooking +// +// Created by 홍석현 on 10/17/25. +// + +import Foundation + +struct MovieDetailRequestDTO: Encodable { + private let language: String = "ko" +} diff --git a/MovieBooking/Data/DataSources/MovieDataSource.swift b/MovieBooking/Data/DataSources/MovieDataSource.swift index b6a2635..de2ca26 100644 --- a/MovieBooking/Data/DataSources/MovieDataSource.swift +++ b/MovieBooking/Data/DataSources/MovieDataSource.swift @@ -8,7 +8,7 @@ import Foundation protocol MovieDataSource { - func movieDetail(_ id: String) async throws -> MovieDetailResponseDTO + func movieDetail(_ id: String, _ request: MovieDetailRequestDTO) async throws -> MovieDetailResponseDTO func movieList(category: MovieCategory, page: Int) async throws -> MovieListResponseDTO } @@ -20,9 +20,10 @@ struct DefaultMovieDataSource: MovieDataSource { } func movieDetail( - _ id: String + _ id: String, + _ request: MovieDetailRequestDTO = MovieDetailRequestDTO() ) async throws -> MovieDetailResponseDTO { - try await provider.request(MovieTarget.movieDetail(id: id)) + try await provider.request(MovieTarget.movieDetail(id: id, request: request)) } func movieList( @@ -40,7 +41,7 @@ extension MovieTarget: TargetType { var path: String { switch self { - case .movieDetail(let id): + case .movieDetail(let id, _): return "/\(id)" case .movieList(let category, _): return "/\(category.rawValue)" @@ -58,8 +59,8 @@ extension MovieTarget: TargetType { var parameters: RequestParameter? { switch self { - case .movieDetail: - return nil + case .movieDetail(_, let request): + return .query(request) case .movieList(_ , let page): return .query(["page": page]) diff --git a/MovieBooking/Data/Repository/MovieRepository.swift b/MovieBooking/Data/Repository/MovieRepository.swift index fecd096..4c56114 100644 --- a/MovieBooking/Data/Repository/MovieRepository.swift +++ b/MovieBooking/Data/Repository/MovieRepository.swift @@ -32,6 +32,6 @@ struct MovieRepository: MovieRepositoryProtocol { } func fetchMovieDetail(id: String) async throws -> MovieDetail { - try await dataSource.movieDetail(id).toDomain() + try await dataSource.movieDetail(id, MovieDetailRequestDTO()).toDomain() } } diff --git a/MovieBooking/Data/Request/MovieTarget.swift b/MovieBooking/Data/Request/MovieTarget.swift index 0c05005..c507890 100644 --- a/MovieBooking/Data/Request/MovieTarget.swift +++ b/MovieBooking/Data/Request/MovieTarget.swift @@ -8,6 +8,6 @@ import Foundation enum MovieTarget { - case movieDetail(id: String) - case movieList(category: MovieCategory, page: Int = 1) + case movieDetail(id: String, request: MovieDetailRequestDTO) + case movieList(category: MovieCategory, page: Int = 1) } diff --git a/MovieBooking/Domain/Repository/MovieRepositoryProtocol.swift b/MovieBooking/Domain/Repository/MovieRepositoryProtocol.swift index 62d7877..6d3f600 100644 --- a/MovieBooking/Domain/Repository/MovieRepositoryProtocol.swift +++ b/MovieBooking/Domain/Repository/MovieRepositoryProtocol.swift @@ -17,7 +17,7 @@ protocol MovieRepositoryProtocol { private enum MovieRepositoryKey: DependencyKey { static let liveValue: any MovieRepositoryProtocol = MovieRepository() - static let previewValue: any MovieRepositoryProtocol = MockMovieRepository() + static let previewValue: any MovieRepositoryProtocol = MovieRepository() static let testValue: any MovieRepositoryProtocol = MockMovieRepository() } diff --git a/MovieBooking/Domain/UseCase/Movie/FetchMovieDetailUseCase.swift b/MovieBooking/Domain/UseCase/Movie/FetchMovieDetailUseCase.swift index f2acf1c..67cee31 100644 --- a/MovieBooking/Domain/UseCase/Movie/FetchMovieDetailUseCase.swift +++ b/MovieBooking/Domain/UseCase/Movie/FetchMovieDetailUseCase.swift @@ -16,13 +16,13 @@ struct FetchMovieDetailUseCase: FetchMovieDetailUseCaseProtocol { @Dependency(\.movieRepository) var repository: MovieRepositoryProtocol func execute(_ id: String) async throws -> MovieDetail { - return MovieDetail.mockData + try await repository.fetchMovieDetail(id: id) } } private enum FetchMovieDetailUseCaseKey: DependencyKey { static let liveValue: any FetchMovieDetailUseCaseProtocol = FetchMovieDetailUseCase() - static let previewValue: any FetchMovieDetailUseCaseProtocol = MockFetchMovieDetailUseCase() + static let previewValue: any FetchMovieDetailUseCaseProtocol = FetchMovieDetailUseCase() static let testValue: any FetchMovieDetailUseCaseProtocol = MockFetchMovieDetailUseCase() } diff --git a/MovieBooking/Feature/MovieDetail/Components/MovieDetailCardView.swift b/MovieBooking/Feature/MovieDetail/Components/MovieDetailCardView.swift index 522a3a8..a9474f6 100644 --- a/MovieBooking/Feature/MovieDetail/Components/MovieDetailCardView.swift +++ b/MovieBooking/Feature/MovieDetail/Components/MovieDetailCardView.swift @@ -48,12 +48,11 @@ struct MovieDetailCardView: View { .frame(maxWidth: .infinity) .font(.system(size: 16, weight: .bold)) .foregroundStyle(.white) - .background(.basicPurple) .padding(.vertical, 16) + .background(.basicPurple) .clipShape(Capsule()) .shadow(color: .black.opacity(0.2), radius: 5, y: 5) } - } .padding(24) .background(Color.white) diff --git a/MovieBooking/Feature/MovieDetail/MovieDetailFeature.swift b/MovieBooking/Feature/MovieDetail/MovieDetailFeature.swift new file mode 100644 index 0000000..dedc919 --- /dev/null +++ b/MovieBooking/Feature/MovieDetail/MovieDetailFeature.swift @@ -0,0 +1,102 @@ +// +// MovieDetailFeature.swift +// MovieBooking +// +// Created by 홍석현 on 10/17/25. +// + +import Foundation +import ComposableArchitecture + +@Reducer +struct MovieDetailFeature { + @Dependency(\.fetchMovieDetailUseCase) var fetchMovieDetailUseCase + + @ObservableState + struct State { + let movieId: Int + var movieDetail: MovieDetail? + var isLoading: Bool = false + } + + enum Action: ViewAction { + case view(ViewAction) + case async(AsyncAction) + case inner(InnerAction) + + enum ViewAction { + case onAppear + } + + enum AsyncAction { + case fetchMovieDetail + } + + enum InnerAction { + case fetchSuccess(MovieDetail) + case fetchFailure(Error) + } + } + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .view(let viewAction): + return handleViewAction(state: &state, action: viewAction) + + case .async(let asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case .inner(let innerAction): + return handleInnerAction(state: &state, action: innerAction) + } + } + } +} + +extension MovieDetailFeature { + private func handleViewAction( + state: inout State, + action: Action.ViewAction + ) -> Effect { + switch action { + case .onAppear: + return .send(.async(.fetchMovieDetail)) + } + } + + private func handleAsyncAction( + state: inout State, + action: Action.AsyncAction + ) -> Effect { + switch action { + case .fetchMovieDetail: + state.isLoading = true + + return .run { [movieId = state.movieId] send in + do { + let movieDetail = try await fetchMovieDetailUseCase.execute(String(movieId)) + await send(.inner(.fetchSuccess(movieDetail))) + } catch { + await send(.inner(.fetchFailure(error))) + } + } + } + } + + private func handleInnerAction( + state: inout State, + action: Action.InnerAction + ) -> Effect { + switch action { + case .fetchSuccess(let movieDetail): + state.isLoading = false + state.movieDetail = movieDetail + return .none + + case .fetchFailure(let error): + state.isLoading = false + return .none + } + } +} diff --git a/MovieBooking/Feature/MovieDetail/MovieDetailView.swift b/MovieBooking/Feature/MovieDetail/MovieDetailView.swift index 787aa38..69c908c 100644 --- a/MovieBooking/Feature/MovieDetail/MovieDetailView.swift +++ b/MovieBooking/Feature/MovieDetail/MovieDetailView.swift @@ -6,22 +6,44 @@ // import SwiftUI +import ComposableArchitecture +@ViewAction(for: MovieDetailFeature.self) struct MovieDetailView: View { + @Perception.Bindable var store: StoreOf + var body: some View { - ScrollView { - VStack(spacing: 24) { - if let path = MovieDetail.mockData.posterPath { - MoviePosterView(posterPath: path) + Group { + if store.isLoading { + ProgressView("영화 정보 로딩 중...") + } else if let movieDetail = store.movieDetail { + ScrollView { + VStack(spacing: 24) { + if let path = movieDetail.posterPath { + MoviePosterView(posterPath: path) + } + + MovieDetailCardView(model: movieDetail) + .padding(.horizontal, 24) + } } - - MovieDetailCardView(model: .mockData) - .padding(.horizontal, 24) + } else { + Text("영화 정보를 불러올 수 없습니다") + .foregroundColor(.secondary) } } + .onAppear { + send(.onAppear) + } } } #Preview { - MovieDetailView() + MovieDetailView( + store: Store( + initialState: MovieDetailFeature.State(movieId: 2) + ) { + MovieDetailFeature() + } + ) } From 3e637e93b04a7a34aae1614d6960180dcbf69913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Fri, 17 Oct 2025 23:39:19 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat.=20MovieDetailError=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20=EB=B0=8F=20alert=EB=A1=9C=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MovieBooking.xcodeproj/project.pbxproj | 1 + .../Data/Repository/MockMovieRepository.swift | 2 +- .../Entity/Error/Movie/MovieDetailError.swift | 68 +++++++++++++++++++ .../Movie/FetchMovieDetailUseCase.swift | 8 ++- .../MovieDetail/MovieDetailFeature.swift | 39 +++++++++++ .../Feature/MovieDetail/MovieDetailView.swift | 1 + 6 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 MovieBooking/Domain/Entity/Error/Movie/MovieDetailError.swift diff --git a/MovieBooking.xcodeproj/project.pbxproj b/MovieBooking.xcodeproj/project.pbxproj index c8411fd..b568ff4 100644 --- a/MovieBooking.xcodeproj/project.pbxproj +++ b/MovieBooking.xcodeproj/project.pbxproj @@ -51,6 +51,7 @@ Data/DataSources/APIConfiguration.swift, Data/DataSources/MovieDataSource.swift, Data/DTO/Response/MovieDetailResponseDTO.swift, + Domain/Entity/Error/Movie/MovieDetailError.swift, Feature/MovieDetail/MovieDetailFeature.swift, NetworkService/Core/ContentType.swift, NetworkService/Core/HTTPHeader.swift, diff --git a/MovieBooking/Data/Repository/MockMovieRepository.swift b/MovieBooking/Data/Repository/MockMovieRepository.swift index fe16308..0095dcf 100644 --- a/MovieBooking/Data/Repository/MockMovieRepository.swift +++ b/MovieBooking/Data/Repository/MockMovieRepository.swift @@ -22,6 +22,6 @@ struct MockMovieRepository: MovieRepositoryProtocol { } func fetchMovieDetail(id: String) async throws -> MovieDetail { - return MovieDetail.mockData + throw NetworkError.httpError(statusCode: 404, response: nil, data: nil) } } diff --git a/MovieBooking/Domain/Entity/Error/Movie/MovieDetailError.swift b/MovieBooking/Domain/Entity/Error/Movie/MovieDetailError.swift new file mode 100644 index 0000000..25ed329 --- /dev/null +++ b/MovieBooking/Domain/Entity/Error/Movie/MovieDetailError.swift @@ -0,0 +1,68 @@ +// +// MovieDetailError.swift +// MovieBooking +// +// Created by 홍석현 on 10/17/25. +// + +import Foundation + +enum MovieDetailError: Error { + case networkError(NetworkError) + case movieNotFound + case unknown(Error) + + init(from error: Error) { + if let networkError = error as? NetworkError { + switch networkError { + case .httpError(let statusCode, _, _) where statusCode == 404: + self = .movieNotFound + default: + self = .networkError(networkError) + } + } else { + self = .unknown(error) + } + } +} + +extension MovieDetailError: LocalizedError { + var errorDescription: String? { + switch self { + case .networkError(let networkError): + return networkError.errorDescription + case .movieNotFound: + return "영화 정보를 찾을 수 없습니다" + case .unknown(let error): + return "알 수 없는 오류: \(error.localizedDescription)" + } + } + + var title: String { + switch self { + case .networkError(let networkError): + switch networkError { + case .invalidURL, .invalidResponse: + return "잘못된 요청" + case .noData: + return "데이터 없음" + case .httpError(let statusCode, _, _): + if statusCode >= 500 { + return "서버 오류" + } else { + return "네트워크 오류" + } + case .decodingError: + return "데이터 오류" + case .encodingError: + return "요청 오류" + case .unknown: + return "알 수 없는 오류" + } + case .movieNotFound: + return "영화 없음" + case .unknown: + return "오류 발생" + } + } +} diff --git a/MovieBooking/Domain/UseCase/Movie/FetchMovieDetailUseCase.swift b/MovieBooking/Domain/UseCase/Movie/FetchMovieDetailUseCase.swift index 67cee31..3a3ff87 100644 --- a/MovieBooking/Domain/UseCase/Movie/FetchMovieDetailUseCase.swift +++ b/MovieBooking/Domain/UseCase/Movie/FetchMovieDetailUseCase.swift @@ -14,9 +14,13 @@ protocol FetchMovieDetailUseCaseProtocol { struct FetchMovieDetailUseCase: FetchMovieDetailUseCaseProtocol { @Dependency(\.movieRepository) var repository: MovieRepositoryProtocol - + func execute(_ id: String) async throws -> MovieDetail { - try await repository.fetchMovieDetail(id: id) + do { + return try await repository.fetchMovieDetail(id: id) + } catch { + throw MovieDetailError(from: error) + } } } diff --git a/MovieBooking/Feature/MovieDetail/MovieDetailFeature.swift b/MovieBooking/Feature/MovieDetail/MovieDetailFeature.swift index dedc919..a301899 100644 --- a/MovieBooking/Feature/MovieDetail/MovieDetailFeature.swift +++ b/MovieBooking/Feature/MovieDetail/MovieDetailFeature.swift @@ -7,6 +7,7 @@ import Foundation import ComposableArchitecture +internal import SwiftUICore @Reducer struct MovieDetailFeature { @@ -17,12 +18,14 @@ struct MovieDetailFeature { let movieId: Int var movieDetail: MovieDetail? var isLoading: Bool = false + @Presents var alert: AlertState? } enum Action: ViewAction { case view(ViewAction) case async(AsyncAction) case inner(InnerAction) + case alert(PresentationAction) enum ViewAction { case onAppear @@ -36,6 +39,10 @@ struct MovieDetailFeature { case fetchSuccess(MovieDetail) case fetchFailure(Error) } + + enum Alert { + case confirmDismiss + } } var body: some ReducerOf { @@ -49,6 +56,12 @@ struct MovieDetailFeature { case .inner(let innerAction): return handleInnerAction(state: &state, action: innerAction) + + case .alert(.presented(let alertAction)): + return handleAlertAction(state: &state, action: alertAction) + + case .alert(.dismiss): + return .none } } } @@ -96,7 +109,33 @@ extension MovieDetailFeature { case .fetchFailure(let error): state.isLoading = false + + let movieDetailError = error as? MovieDetailError ?? MovieDetailError(from: error) + let title = movieDetailError.title + let message = movieDetailError.errorDescription ?? "오류가 발생했습니다" + + state.alert = AlertState { + TextState(title) + } actions: { + ButtonState(action: .confirmDismiss) { + TextState("확인") + } + } message: { + TextState(message) + } + return .none } } + + private func handleAlertAction( + state: inout State, + action: Action.Alert + ) -> Effect { + switch action { + case .confirmDismiss: + // TODO: 뒤로가기 액션 (DismissEffect 등) + return .none + } + } } diff --git a/MovieBooking/Feature/MovieDetail/MovieDetailView.swift b/MovieBooking/Feature/MovieDetail/MovieDetailView.swift index 69c908c..f677753 100644 --- a/MovieBooking/Feature/MovieDetail/MovieDetailView.swift +++ b/MovieBooking/Feature/MovieDetail/MovieDetailView.swift @@ -35,6 +35,7 @@ struct MovieDetailView: View { .onAppear { send(.onAppear) } + .alert($store.scope(state: \.alert, action: \.alert)) } }