From 8fa810fc4f49983b2f6cf21eda1ede2f55508636 Mon Sep 17 00:00:00 2001 From: minneee Date: Fri, 17 Oct 2025 14:27:42 +0900 Subject: [PATCH 1/7] =?UTF-8?q?[feat]=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyPage/Components/LogoutButton.swift | 34 ++++++++ .../MyPage/Components/ProfileBoxView.swift | 83 +++++++++++++++++++ MovieBooking/Feature/MyPage/MyPageView.swift | 48 +++++++++++ 3 files changed, 165 insertions(+) create mode 100644 MovieBooking/Feature/MyPage/Components/LogoutButton.swift create mode 100644 MovieBooking/Feature/MyPage/Components/ProfileBoxView.swift create mode 100644 MovieBooking/Feature/MyPage/MyPageView.swift diff --git a/MovieBooking/Feature/MyPage/Components/LogoutButton.swift b/MovieBooking/Feature/MyPage/Components/LogoutButton.swift new file mode 100644 index 0000000..be8a423 --- /dev/null +++ b/MovieBooking/Feature/MyPage/Components/LogoutButton.swift @@ -0,0 +1,34 @@ +// +// LogoutButton.swift +// MovieBooking +// +// Created by 김민희 on 10/17/25. +// + +import SwiftUI + +struct LogoutButton: View { + var body: some View { + Button { + print("로그아웃 버튼이 눌렸습니다!") + } label: { + HStack(spacing: 8) { + Image(systemName: "rectangle.portrait.and.arrow.right") + .font(.system(size: 14, weight: .semibold)) + + Text("로그아웃") + .font(.system(size: 14, weight: .semibold)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(.basicPurple) + .foregroundColor(.white) + .clipShape(Capsule()) + } + .padding(.vertical, 16) + } +} + +#Preview { + LogoutButton() +} diff --git a/MovieBooking/Feature/MyPage/Components/ProfileBoxView.swift b/MovieBooking/Feature/MyPage/Components/ProfileBoxView.swift new file mode 100644 index 0000000..e44ce0d --- /dev/null +++ b/MovieBooking/Feature/MyPage/Components/ProfileBoxView.swift @@ -0,0 +1,83 @@ +// +// ProfileBoxView.swift +// MovieBooking +// +// Created by 김민희 on 10/17/25. +// + +import SwiftUI + +struct ProfileBoxView: View { + let user: User + + var body: some View { + VStack(spacing: 12) { + Image(user.profileImage) + .resizable() + .scaledToFit() + .frame(width: 100, height: 100) + .background(.basicPurple) + .clipShape(Circle()) + + Text(user.nickname) + .font(.system(size: 16)) + + Text(user.email) + .font(.system(size: 14)) + .foregroundStyle(.gray) + + LoginTypeView(type: user.loginType) + + Divider() + .padding(.horizontal, 24) + .padding(.vertical, 26) + + HStack(spacing: 32) { + BookingCountView(count: 2, state: "총 예매") + BookingCountView(count: 0, state: "관람 예정") + } + } + .padding(.vertical, 24) + .frame(maxWidth: .infinity) + .background(.white) + .cornerRadius(16) + .shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 2) + } +} + +struct LoginTypeView: View { + let type: LoginType + + var body: some View { + Text(type.rawValue) + .font(.system(size: 14)) + .fontWeight(.bold) + .foregroundColor(.white) + .padding(.vertical, 6) + .padding(.horizontal, 15) + .background(Color.black) + .clipShape(Capsule()) + } +} + +struct BookingCountView: View { + let count: Int + let state: String + + var body: some View { + VStack(spacing: 4) { + Text("\(count)") + .font(.system(size: 20)) + .foregroundStyle(.basicPurple) + + Text(state) + .font(.system(size: 12)) + .foregroundStyle(.gray) + + } + } +} + +#Preview { + ProfileBoxView(user: User(nickname: "11", email: "11@11.com", profileImage: "", loginType: .apple)) +} diff --git a/MovieBooking/Feature/MyPage/MyPageView.swift b/MovieBooking/Feature/MyPage/MyPageView.swift new file mode 100644 index 0000000..64d6bb7 --- /dev/null +++ b/MovieBooking/Feature/MyPage/MyPageView.swift @@ -0,0 +1,48 @@ +// +// MyPageView.swift +// MovieBooking +// +// Created by 김민희 on 10/17/25. +// + +import SwiftUI +//TODO: 임시 타입 제거 +struct User { + let nickname: String + let email: String + let profileImage: String + let loginType: LoginType +} + +enum LoginType: String { + case google = "Google" + case apple = "Apple" + case email = "Email" + case kakao = "Kakao" +} + +struct MyPageView: View { + var body: some View { + VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 4){ + Text("내정보") + .font(.system(size: 16)) + Text("프로필을 확인하세요") + .font(.system(size: 14)) + .foregroundStyle(.gray) + } + + ProfileBoxView(user: User(nickname: "11", email: "111@11.com", profileImage: "", loginType: .email)) + + LogoutButton() + + Spacer() + } + .padding(.horizontal, 20) + .padding(.top, 24) + } +} + +#Preview { + MyPageView() +} From b3915a52df637b6e2e5600db5ea2d3a878c2f8e4 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 17 Oct 2025 20:04:57 +0900 Subject: [PATCH 2/7] =?UTF-8?q?[feat]:=20=20tabbar=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20#14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MovieBooking/App/Reducer/AppReducer.swift | 6 +- .../Domain/UseCase/Auth/AuthUseCase.swift | 17 +--- .../UseCase/Auth/AuthUseCaseProtocol.swift | 12 +-- .../View/AuthCoordinatorView.swift | 2 +- .../Feature/MovieList/MovieListFeature.swift | 8 +- .../Feature/Search/MovieSearchFeature.swift | 8 +- ...plashReducer.swift => SplashFeature.swift} | 10 +-- .../Feature/Tab/Reducer/MainTabFeature.swift | 85 +++++++++++++++++++ .../Feature/Tab/Reducer/MainTabReducer.swift | 52 ------------ .../Feature/Tab/View/MainTabView.swift | 10 +-- .../Monitoring/NetworkLogger.swift | 18 ++-- 11 files changed, 115 insertions(+), 113 deletions(-) rename MovieBooking/Feature/Splash/Reducer/{SplashReducer.swift => SplashFeature.swift} (94%) create mode 100644 MovieBooking/Feature/Tab/Reducer/MainTabFeature.swift delete mode 100644 MovieBooking/Feature/Tab/Reducer/MainTabReducer.swift diff --git a/MovieBooking/App/Reducer/AppReducer.swift b/MovieBooking/App/Reducer/AppReducer.swift index ca718f7..0aafbc6 100644 --- a/MovieBooking/App/Reducer/AppReducer.swift +++ b/MovieBooking/App/Reducer/AppReducer.swift @@ -15,7 +15,7 @@ struct AppReducer { enum State { case splash(SplashFeature.State) case auth(AuthCoordinator.State) - case mainTab(MainTabReducer.State) + case mainTab(MainTabFeature.State) @@ -40,7 +40,7 @@ struct AppReducer { enum ScopeAction { case splash(SplashFeature.Action) case auth(AuthCoordinator.Action) - case mainTab(MainTabReducer.Action) + case mainTab(MainTabFeature.Action) } @Dependency(\.continuousClock) var clock @@ -62,7 +62,7 @@ struct AppReducer { AuthCoordinator() } .ifCaseLet(\.mainTab, action: \.scope.mainTab) { - MainTabReducer() + MainTabFeature() } } } diff --git a/MovieBooking/Domain/UseCase/Auth/AuthUseCase.swift b/MovieBooking/Domain/UseCase/Auth/AuthUseCase.swift index 37c3ce1..0e2a9b4 100644 --- a/MovieBooking/Domain/UseCase/Auth/AuthUseCase.swift +++ b/MovieBooking/Domain/UseCase/Auth/AuthUseCase.swift @@ -9,8 +9,8 @@ import Foundation import WeaveDI import ComposableArchitecture import AuthenticationServices -import Supabase import LogMacro +import Supabase public struct AuthUseCase: AuthUseCaseProtocol { private let repository: AuthRepositoryProtocol @@ -101,28 +101,13 @@ public struct AuthUseCase: AuthUseCaseProtocol { // MARK: - 세션 관리 - public func checkSession() async throws -> UserEntity { - return try await sessionUseCase.checkSession() - } - public func checkUserExists(userId: UUID) async throws -> Bool { return try await repository.checkUserExists(userId: userId) } - public func isTokenExpiringSoon( - _ session: Session, - threshold: TimeInterval = 60 - ) -> Bool { - return sessionUseCase.isTokenExpiringSoon(session, threshold: threshold) - } - public func sessionLogOut() async throws { try await repository.signOut() } - - public func refreshSession() async throws -> UserEntity { - return try await sessionUseCase.refreshSession() - } } // MARK: - DI 설정 diff --git a/MovieBooking/Domain/UseCase/Auth/AuthUseCaseProtocol.swift b/MovieBooking/Domain/UseCase/Auth/AuthUseCaseProtocol.swift index d58bbd9..a5ab43e 100644 --- a/MovieBooking/Domain/UseCase/Auth/AuthUseCaseProtocol.swift +++ b/MovieBooking/Domain/UseCase/Auth/AuthUseCaseProtocol.swift @@ -7,7 +7,6 @@ import Foundation import AuthenticationServices -import Supabase /// UseCase 레벨: 비즈니스 로직 담당 (복합적인 플로우, 도메인 규칙) public protocol AuthUseCaseProtocol: Sendable { @@ -34,18 +33,9 @@ public protocol AuthUseCaseProtocol: Sendable { // MARK: - 세션 관리 - /// 현재 세션 체크 플로우 - func checkSession() async throws -> UserEntity - /// 사용자 존재 여부 확인 func checkUserExists(userId: UUID) async throws -> Bool - /// 토큰 만료 임박 확인 - func isTokenExpiringSoon(_ session: Session, threshold: TimeInterval) -> Bool - /// 세션 로그아웃 func sessionLogOut() async throws - - /// 세션 새로고침 플로우 - func refreshSession() async throws -> UserEntity -} \ No newline at end of file +} 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/MovieList/MovieListFeature.swift b/MovieBooking/Feature/MovieList/MovieListFeature.swift index 760e986..c438963 100644 --- a/MovieBooking/Feature/MovieList/MovieListFeature.swift +++ b/MovieBooking/Feature/MovieList/MovieListFeature.swift @@ -10,7 +10,7 @@ import ComposableArchitecture import SwiftUI @Reducer -struct MovieListFeature { +public struct MovieListFeature { @Dependency(\.movieRepository) var movieRepository @ObservableState @@ -22,7 +22,7 @@ struct MovieListFeature { @Presents var alert: AlertState? } - enum Action { + public enum Action { case onAppear case fetchMovie case fetchNowPlayingResponse(Result<[Movie], Error>) @@ -31,12 +31,12 @@ struct MovieListFeature { case selectMovie case alert(PresentationAction) - enum Alert: Equatable { + public enum Alert: Equatable { case retry } } - var body: some Reducer { + public var body: some Reducer { Reduce { state, action in switch action { case .onAppear: diff --git a/MovieBooking/Feature/Search/MovieSearchFeature.swift b/MovieBooking/Feature/Search/MovieSearchFeature.swift index d86d541..6284ee5 100644 --- a/MovieBooking/Feature/Search/MovieSearchFeature.swift +++ b/MovieBooking/Feature/Search/MovieSearchFeature.swift @@ -9,21 +9,21 @@ import ComposableArchitecture import Foundation @Reducer -struct MovieSearchFeature { +public struct MovieSearchFeature { @ObservableState - struct State: Equatable { + public struct State: Equatable { var nowPlayingMovies: [Movie] = [] var upcomingMovies: [Movie] = [] var popularMovies: [Movie] = [] var searchText: String = "" } - enum Action: BindableAction { + public enum Action: BindableAction { case updateMovieLists(nowPlaying: [Movie], upcoming: [Movie], popular: [Movie]) case binding(BindingAction) } - var body: some Reducer { + public var body: some Reducer { BindingReducer() Reduce { state, action in switch action { diff --git a/MovieBooking/Feature/Splash/Reducer/SplashReducer.swift b/MovieBooking/Feature/Splash/Reducer/SplashFeature.swift similarity index 94% rename from MovieBooking/Feature/Splash/Reducer/SplashReducer.swift rename to MovieBooking/Feature/Splash/Reducer/SplashFeature.swift index 69d44b0..81ed0f0 100644 --- a/MovieBooking/Feature/Splash/Reducer/SplashReducer.swift +++ b/MovieBooking/Feature/Splash/Reducer/SplashFeature.swift @@ -74,6 +74,7 @@ public struct SplashFeature { @Dependency(\.continuousClock) var clock @Injected(AuthUseCase.self) var authUseCase + @Injected(SessionUseCase.self) var sessionUseCase public var body: some Reducer { BindingReducer() @@ -127,7 +128,7 @@ extension SplashFeature { case .checkSession: return .run { send in let checkSessionResult = await Result { - try await authUseCase.checkSession() + try await sessionUseCase.checkSession() } switch checkSessionResult { @@ -146,7 +147,7 @@ extension SplashFeature { await send(.async(.checkSession)) if let session = superbase.auth.currentSession { - if await authUseCase.isTokenExpiringSoon(session, threshold: 60) { + if await sessionUseCase.isTokenExpiringSoon(session, threshold: 60) { #logDebug("토근 만료 입박 ") await send(.async(.refreshSession)) } else { @@ -154,7 +155,7 @@ extension SplashFeature { } } - if let user = try? await authUseCase.checkSession() { + if let user = try? await sessionUseCase.checkSession() { let uuid = UUID(uuidString: user.id)! let exists = try? await authUseCase.checkUserExists(userId: uuid) #logDebug(exists == true ? "✅ 메인으로 이동" : "⚠️ 프로필 등록 필요") @@ -170,7 +171,7 @@ extension SplashFeature { case .refreshSession: return .run { send in let sessionResult = await Result { - try await authUseCase.refreshSession() + try await sessionUseCase.refreshSession() } switch sessionResult { @@ -237,4 +238,3 @@ extension SplashFeature.State: Hashable { hasher.combine(userEntity) } } - diff --git a/MovieBooking/Feature/Tab/Reducer/MainTabFeature.swift b/MovieBooking/Feature/Tab/Reducer/MainTabFeature.swift new file mode 100644 index 0000000..2a0c91a --- /dev/null +++ b/MovieBooking/Feature/Tab/Reducer/MainTabFeature.swift @@ -0,0 +1,85 @@ +// +// MainTabFeature.swift +// MovieBooking +// +// Created by Wonji Suh on 10/16/25. +// + +import Foundation +import ComposableArchitecture + + +@Reducer +public struct MainTabFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + + var selectTab: MainTab = .home + + + var movieList = MovieListFeature.State() + var movieSearch = MovieSearchFeature.State() + + public init() {} + } + + public enum Action : BindableAction{ + case binding(BindingAction) + case selectTab(MainTab) + case scope(ScopeAction) + + } + + @CasePathable + public enum ScopeAction { + case movieList(MovieListFeature.Action) + case movieSearch(MovieSearchFeature.Action) + } + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding(_): + return .none + + case .selectTab(let tab): + state.selectTab = tab + return .none + + case .scope(let scopeAction): + return handleScopeAction(state: &state, action: scopeAction) + } + } + Scope(state: \.movieList, action: \.scope.movieList) { + MovieListFeature() + } + Scope(state: \.movieSearch, action: \.scope.movieSearch) { + MovieSearchFeature() + } + } +} + +extension MainTabFeature { + private func handleScopeAction( + state: inout State, + action: ScopeAction + ) -> Effect { + switch action { + default: + return .none + } + } +} + +extension MainTabFeature.State: Hashable { + public static func == (lhs: MainTabFeature.State, rhs: MainTabFeature.State) -> Bool { + lhs.selectTab == rhs.selectTab + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(selectTab) + } +} diff --git a/MovieBooking/Feature/Tab/Reducer/MainTabReducer.swift b/MovieBooking/Feature/Tab/Reducer/MainTabReducer.swift deleted file mode 100644 index 11da662..0000000 --- a/MovieBooking/Feature/Tab/Reducer/MainTabReducer.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// MainTabReducer.swift -// MovieBooking -// -// Created by Wonji Suh on 10/16/25. -// - -import Foundation -import ComposableArchitecture - - -@Reducer -public struct MainTabReducer { - public init() {} - - @ObservableState - public struct State: Equatable { - - var selectTab: MainTab = .home - public init() {} - } - - public enum Action : BindableAction{ - case binding(BindingAction) - case selectTab(MainTab) - - } - - public var body: some Reducer { - BindingReducer() - Reduce { state, action in - switch action { - case .binding(_): - return .none - - case .selectTab(let tab): - state.selectTab = tab - return .none - } - } - } -} - -extension MainTabReducer.State: Hashable { - public static func == (lhs: MainTabReducer.State, rhs: MainTabReducer.State) -> Bool { - lhs.selectTab == rhs.selectTab - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(selectTab) - } -} diff --git a/MovieBooking/Feature/Tab/View/MainTabView.swift b/MovieBooking/Feature/Tab/View/MainTabView.swift index 8ba7728..db6be52 100644 --- a/MovieBooking/Feature/Tab/View/MainTabView.swift +++ b/MovieBooking/Feature/Tab/View/MainTabView.swift @@ -9,16 +9,16 @@ import SwiftUI import ComposableArchitecture struct MainTabView: View { - @Perception.Bindable var store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { WithPerceptionTracking { TabView(selection: $store.selectTab) { - ContentView() + MovieListView(store: self.store.scope(state: \.movieList, action: \.scope.movieList)) .tabItem { Label("홈", systemImage: "house") } .tag(MainTab.home) - ContentView() + MovieSearchView(store: self.store.scope(state: \.movieSearch, action: \.scope.movieSearch)) .tabItem { Label("search", systemImage: "magnifyingglass") } .tag(MainTab.book) @@ -38,7 +38,7 @@ struct MainTabView: View { #Preview { - MainTabView(store: Store(initialState: MainTabReducer.State(), reducer: { - MainTabReducer() + MainTabView(store: Store(initialState: MainTabFeature.State(), reducer: { + MainTabFeature() })) } diff --git a/MovieBooking/NetworkService/Monitoring/NetworkLogger.swift b/MovieBooking/NetworkService/Monitoring/NetworkLogger.swift index 5e7651d..7087009 100644 --- a/MovieBooking/NetworkService/Monitoring/NetworkLogger.swift +++ b/MovieBooking/NetworkService/Monitoring/NetworkLogger.swift @@ -11,9 +11,6 @@ struct NetworkLog { let id: String var requestLog: String? var responseLog: String? - var isComplete: Bool { - responseLog != nil - } } actor NetworkLogger { @@ -37,7 +34,7 @@ actor NetworkLogger { log.responseLog = buildResponseLog(response, data: data, duration: duration, id: id) logBuffer[id] = log - if log.isComplete { + if log.responseLog != nil { printCompleteLog(log) logBuffer.removeValue(forKey: id) } @@ -52,7 +49,7 @@ actor NetworkLogger { log.responseLog = buildErrorLog(error, duration: duration, id: id) logBuffer[id] = log - if log.isComplete { + if log.responseLog != nil { printCompleteLog(log) logBuffer.removeValue(forKey: id) } @@ -85,7 +82,7 @@ actor NetworkLogger { if let body = request.httpBody { // 🎯 전체 JSON 출력 (제한 없음!) - if let jsonString = body.prettyPrintedJSON { + if let jsonString = prettyPrintedJSON(from: body) { log += jsonString + "\n" } else if let bodyString = String(data: body, encoding: .utf8) { log += bodyString + "\n" @@ -113,7 +110,7 @@ actor NetworkLogger { log += "4️⃣ Data 확인하기\n" // 🎯 전체 JSON 출력 (제한 없음!) - if let jsonString = data.prettyPrintedJSON { + if let jsonString = prettyPrintedJSON(from: data) { log += jsonString + "\n" } else if let bodyString = String(data: data, encoding: .utf8) { log += bodyString + "\n" @@ -184,12 +181,9 @@ actor NetworkLogger { default: return "❓" } } -} - -extension Data { - var prettyPrintedJSON: String? { - guard let jsonObject = try? JSONSerialization.jsonObject(with: self, options: []), + private func prettyPrintedJSON(from data: Data) -> String? { + guard let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []), let prettyData = try? JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted]), let prettyString = String(data: prettyData, encoding: .utf8) else { return nil From fc6bc988267e5b319096a220172ef961163b985a Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 18 Oct 2025 04:37:06 +0900 Subject: [PATCH 3/7] =?UTF-8?q?[feat]:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84=20#14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyPage/Components/LoginTypeView.swift | 42 +++++ .../MyPage/Components/LogoutButton.swift | 21 ++- .../MyPage/Components/ProfileBoxView.swift | 117 ++++++------- MovieBooking/Feature/MyPage/MyPageView.swift | 48 ------ .../MyPage/Reducer/MyPageFeature.swift | 158 ++++++++++++++++++ .../Feature/MyPage/View/MyPageView.swift | 81 +++++++++ 6 files changed, 357 insertions(+), 110 deletions(-) create mode 100644 MovieBooking/Feature/MyPage/Components/LoginTypeView.swift delete mode 100644 MovieBooking/Feature/MyPage/MyPageView.swift create mode 100644 MovieBooking/Feature/MyPage/Reducer/MyPageFeature.swift create mode 100644 MovieBooking/Feature/MyPage/View/MyPageView.swift diff --git a/MovieBooking/Feature/MyPage/Components/LoginTypeView.swift b/MovieBooking/Feature/MyPage/Components/LoginTypeView.swift new file mode 100644 index 0000000..c3e3f24 --- /dev/null +++ b/MovieBooking/Feature/MyPage/Components/LoginTypeView.swift @@ -0,0 +1,42 @@ +// +// LoginTypeView.swift +// MovieBooking +// +// Created by Wonji Suh on 10/17/25. +// + +import SwiftUI + +struct LoginTypeView: View { + let type: SocialType + + var body: some View { + HStack { + if type == .kakao || type == .google { + Image(type.image) + .resizable() + .scaledToFit() + .frame(width: 15, height: 15) + } else { + Image(systemName: type.image) + .resizable() + .scaledToFit() + .frame(width: 15, height: 15) + .foregroundStyle(.white) + } + + Text(type.description) + .font(.pretendardFont(family:.medium, size: 14)) + .fontWeight(.bold) + .foregroundColor(.white) + } + .padding(.vertical, 6) + .padding(.horizontal, 15) + .background(.black) + .clipShape(Capsule()) + } +} + +#Preview() { + LoginTypeView(type: .apple) +} diff --git a/MovieBooking/Feature/MyPage/Components/LogoutButton.swift b/MovieBooking/Feature/MyPage/Components/LogoutButton.swift index be8a423..d9d13e5 100644 --- a/MovieBooking/Feature/MyPage/Components/LogoutButton.swift +++ b/MovieBooking/Feature/MyPage/Components/LogoutButton.swift @@ -7,17 +7,26 @@ import SwiftUI -struct LogoutButton: View { - var body: some View { +public struct LogoutButton: View { + var action: () -> Void = { } + + init( + action: @escaping () -> Void + ) { + self.action = action + } + + public var body: some View { Button { - print("로그아웃 버튼이 눌렸습니다!") + action() } label: { HStack(spacing: 8) { Image(systemName: "rectangle.portrait.and.arrow.right") - .font(.system(size: 14, weight: .semibold)) + .font(.pretendardFont(family: .semiBold, size: 14)) Text("로그아웃") - .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + .font(.pretendardFont(family: .semiBold, size: 16)) } .frame(maxWidth: .infinity) .padding(.vertical, 16) @@ -30,5 +39,5 @@ struct LogoutButton: View { } #Preview { - LogoutButton() + LogoutButton(action: {}) } diff --git a/MovieBooking/Feature/MyPage/Components/ProfileBoxView.swift b/MovieBooking/Feature/MyPage/Components/ProfileBoxView.swift index e44ce0d..53c1b2c 100644 --- a/MovieBooking/Feature/MyPage/Components/ProfileBoxView.swift +++ b/MovieBooking/Feature/MyPage/Components/ProfileBoxView.swift @@ -8,76 +8,81 @@ import SwiftUI struct ProfileBoxView: View { - let user: User - + let user: UserEntity + var body: some View { - VStack(spacing: 12) { - Image(user.profileImage) - .resizable() - .scaledToFit() - .frame(width: 100, height: 100) - .background(.basicPurple) - .clipShape(Circle()) + HStack { + VStack(alignment: .leading ,spacing: 12) { + HStack { + if user.provider == .kakao || user.provider == .google { + Circle() + .fill(.lavenderPurple) + .frame(width: 64, height: 64) + .overlay { + Image(user.provider.image) + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + .foregroundStyle(.white) + } - Text(user.nickname) - .font(.system(size: 16)) + } else { + Circle() + .fill(.lavenderPurple) + .frame(width: 64, height: 64) + .overlay { + Image(systemName: user.provider.image) + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + .foregroundStyle(.white) + } + } - Text(user.email) - .font(.system(size: 14)) - .foregroundStyle(.gray) + VStack { + HStack { + Text(user.displayName ?? "") + .font(.pretendardFont(family: .medium, size: 16)) + .foregroundStyle(.textPrimary) - LoginTypeView(type: user.loginType) + Spacer() + } - Divider() - .padding(.horizontal, 24) - .padding(.vertical, 26) + HStack { + Text(user.email ?? "") + .font(.pretendardFont(family: .medium, size: 14)) + .foregroundStyle(.gray500) - HStack(spacing: 32) { - BookingCountView(count: 2, state: "총 예매") - BookingCountView(count: 0, state: "관람 예정") - } - } - .padding(.vertical, 24) - .frame(maxWidth: .infinity) - .background(.white) - .cornerRadius(16) - .shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 2) - } -} + Spacer() + } -struct LoginTypeView: View { - let type: LoginType + HStack { + LoginTypeView(type: user.provider) - var body: some View { - Text(type.rawValue) - .font(.system(size: 14)) - .fontWeight(.bold) - .foregroundColor(.white) - .padding(.vertical, 6) - .padding(.horizontal, 15) - .background(Color.black) - .clipShape(Capsule()) - } -} - -struct BookingCountView: View { - let count: Int - let state: String + Spacer() + } + } - var body: some View { - VStack(spacing: 4) { - Text("\(count)") - .font(.system(size: 20)) - .foregroundStyle(.basicPurple) + Spacer() + } + .padding(.horizontal, 24) - Text(state) - .font(.system(size: 12)) - .foregroundStyle(.gray) +// Divider() +// .padding(.horizontal, 24) +// .padding(.vertical, 26) + } + .padding(.vertical, 24) + .frame(maxWidth: .infinity) + .background(.white) + .cornerRadius(16) + .shadow(color: .black.opacity(0.1), radius: 5, x: 0, y: 2) } } } #Preview { - ProfileBoxView(user: User(nickname: "11", email: "11@11.com", profileImage: "", loginType: .apple)) + ProfileBoxView( + user: .mockAppleUser + ) } diff --git a/MovieBooking/Feature/MyPage/MyPageView.swift b/MovieBooking/Feature/MyPage/MyPageView.swift deleted file mode 100644 index 64d6bb7..0000000 --- a/MovieBooking/Feature/MyPage/MyPageView.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// MyPageView.swift -// MovieBooking -// -// Created by 김민희 on 10/17/25. -// - -import SwiftUI -//TODO: 임시 타입 제거 -struct User { - let nickname: String - let email: String - let profileImage: String - let loginType: LoginType -} - -enum LoginType: String { - case google = "Google" - case apple = "Apple" - case email = "Email" - case kakao = "Kakao" -} - -struct MyPageView: View { - var body: some View { - VStack(alignment: .leading, spacing: 24) { - VStack(alignment: .leading, spacing: 4){ - Text("내정보") - .font(.system(size: 16)) - Text("프로필을 확인하세요") - .font(.system(size: 14)) - .foregroundStyle(.gray) - } - - ProfileBoxView(user: User(nickname: "11", email: "111@11.com", profileImage: "", loginType: .email)) - - LogoutButton() - - Spacer() - } - .padding(.horizontal, 20) - .padding(.top, 24) - } -} - -#Preview { - MyPageView() -} diff --git a/MovieBooking/Feature/MyPage/Reducer/MyPageFeature.swift b/MovieBooking/Feature/MyPage/Reducer/MyPageFeature.swift new file mode 100644 index 0000000..a659347 --- /dev/null +++ b/MovieBooking/Feature/MyPage/Reducer/MyPageFeature.swift @@ -0,0 +1,158 @@ +// +// MyPageFeature.swift +// MovieBooking +// +// Created by Wonji Suh on 10/17/25. +// + +import Foundation +import ComposableArchitecture +import WeaveDI + + +@Reducer +public struct MyPageFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + @Shared(.inMemory("UserEntity")) var userEntity: UserEntity = .mockEmailUser + var appearPopUp: Bool = false + + public init() {} + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case navigation(NavigationAction) + + } + + //MARK: - ViewAction + @CasePathable + public enum View { + case tapLogOut + case appearPopUp(Bool) + case closePopUp + } + + //MARK: - AsyncAction 비동기 처리 액션 + public enum AsyncAction: Equatable { + case logOutUser + } + + //MARK: - 앱내에서 사용하는 액션 + public enum InnerAction: Equatable { + } + + //MARK: - NavigationAction + public enum NavigationAction: Equatable { + case logOutComplete + + } + + nonisolated enum MyPageCancelID: Hashable, Sendable { + case logOut + } + + @Injected(AuthUseCase.self) var authUseCase + @Dependency(\.continuousClock) var clock + @Dependency(\.mainQueue) var mainQueue + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding(_): + return .none + + 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) + + case .navigation(let navigationAction): + return handleNavigationAction(state: &state, action: navigationAction) + } + } + } +} + +extension MyPageFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .tapLogOut: + return .run { send in + await send(.view(.appearPopUp(true))) + } + + case .appearPopUp(let bool): + state.appearPopUp = bool + return .none + + case .closePopUp: + state.appearPopUp = false + return .none + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .logOutUser: + return .run { send in + try await authUseCase.sessionLogOut() + try await clock.sleep(for: .seconds(0.3)) + await send(.view(.closePopUp)) + try await clock.sleep(for: .seconds(0.4)) + await send(.navigation(.logOutComplete)) + + } + .debounce(id: MyPageCancelID.logOut, for: 0.1, scheduler: mainQueue) + } + } + + private func handleNavigationAction( + state: inout State, + action: NavigationAction + ) -> Effect { + switch action { + case .logOutComplete: + return .none + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + + } + } +} + + +extension MyPageFeature.State: Hashable { + public static func == (lhs: MyPageFeature.State, rhs: MyPageFeature.State) -> Bool { + lhs.userEntity == rhs.userEntity && + rhs.appearPopUp == lhs.appearPopUp + } + public func hash(into hasher: inout Hasher) { + hasher.combine(userEntity) + hasher.combine(appearPopUp) + + } +} diff --git a/MovieBooking/Feature/MyPage/View/MyPageView.swift b/MovieBooking/Feature/MyPage/View/MyPageView.swift new file mode 100644 index 0000000..5ecfc7c --- /dev/null +++ b/MovieBooking/Feature/MyPage/View/MyPageView.swift @@ -0,0 +1,81 @@ +// +// MyPageView.swift +// MovieBooking +// +// Created by 김민희 on 10/17/25. +// + +import SwiftUI +import ComposableArchitecture + +@ViewAction(for: MyPageFeature.self) +struct MyPageView: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 4){ + Text("내정보") + .font(.pretendardFont(family: .semiBold, size: 16)) + .foregroundStyle(.black) + + Text("프로필을 확인하세요") + .font(.pretendardFont(family: .medium, size: 14)) + .foregroundStyle(.textPrimary) + } + + ProfileBoxView(user: store.userEntity) + + LogoutButton{ + send(.tapLogOut) + } + + Spacer() + + appVersionText() + + Spacer() + .frame(height: 10) + } + .padding(.top, 24) + } + .customAlert( + isPresented: store.appearPopUp, + title: "르그아웃", + message: "정말 로그아웃하시겠어요?", + isUseConfirmButton: false, + onConfirm: { + Task { + store.send(.async(.logOutUser)) + } + }, + onCancel: { + send(.closePopUp) + } + ) + .padding(.horizontal, 24) + } +} + +#Preview { + MyPageView(store: Store(initialState: MyPageFeature.State(), reducer: { + MyPageFeature() + })) +} + + +extension MyPageView { + @ViewBuilder + fileprivate func appVersionText() -> some View { + VStack { + HStack{ + Spacer() + Text("앱 버전 1.0.0") + .font(.pretendardFont(family: .semiBold, size: 16)) + .foregroundStyle(.textSecondary) + Spacer() + } + } + } +} From 081e22377abe4466966b545e5bf3123a4ab80da5 Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 18 Oct 2025 04:38:19 +0900 Subject: [PATCH 4/7] =?UTF-8?q?[feat]:=ED=83=AD=EB=B0=94=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EB=B0=8F=20=20=EC=95=A1=EC=85=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95#14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{ => Movie}/MockMovieRepository.swift | 0 .../{ => Movie}/MovieRepository.swift | 0 MovieBooking/DesignSystem/Color/Colors.swift | 1 + .../Feature/MovieList/MovieListFeature.swift | 1 + .../Splash/Reducer/SplashFeature.swift | 4 +-- .../Feature/Tab/Reducer/MainTabFeature.swift | 31 +++++++++++++++++-- .../Feature/Tab/View/MainTabView.swift | 2 +- 7 files changed, 33 insertions(+), 6 deletions(-) rename MovieBooking/Data/Repository/{ => Movie}/MockMovieRepository.swift (100%) rename MovieBooking/Data/Repository/{ => Movie}/MovieRepository.swift (100%) diff --git a/MovieBooking/Data/Repository/MockMovieRepository.swift b/MovieBooking/Data/Repository/Movie/MockMovieRepository.swift similarity index 100% rename from MovieBooking/Data/Repository/MockMovieRepository.swift rename to MovieBooking/Data/Repository/Movie/MockMovieRepository.swift diff --git a/MovieBooking/Data/Repository/MovieRepository.swift b/MovieBooking/Data/Repository/Movie/MovieRepository.swift similarity index 100% rename from MovieBooking/Data/Repository/MovieRepository.swift rename to MovieBooking/Data/Repository/Movie/MovieRepository.swift diff --git a/MovieBooking/DesignSystem/Color/Colors.swift b/MovieBooking/DesignSystem/Color/Colors.swift index 2801584..a105dd1 100644 --- a/MovieBooking/DesignSystem/Color/Colors.swift +++ b/MovieBooking/DesignSystem/Color/Colors.swift @@ -18,4 +18,5 @@ public extension ShapeStyle where Self == Color { static var indigo500: Color { .init(hex: "6C4EFF") } static var statusError: Color { .init(hex: "D32F2F")} static var gray500: Color { .init(hex: "9E9E9E")} + static var lavenderPurple: Color { .init(hex: "C4B5FD")} } diff --git a/MovieBooking/Feature/MovieList/MovieListFeature.swift b/MovieBooking/Feature/MovieList/MovieListFeature.swift index c438963..61cbfba 100644 --- a/MovieBooking/Feature/MovieList/MovieListFeature.swift +++ b/MovieBooking/Feature/MovieList/MovieListFeature.swift @@ -36,6 +36,7 @@ public struct MovieListFeature { } } + public var body: some Reducer { Reduce { state, action in switch action { diff --git a/MovieBooking/Feature/Splash/Reducer/SplashFeature.swift b/MovieBooking/Feature/Splash/Reducer/SplashFeature.swift index 81ed0f0..01da854 100644 --- a/MovieBooking/Feature/Splash/Reducer/SplashFeature.swift +++ b/MovieBooking/Feature/Splash/Reducer/SplashFeature.swift @@ -20,7 +20,7 @@ public struct SplashFeature { @ObservableState public struct State: Equatable { - + var fadeOut: Bool = false var pulse: Bool = false @Shared(.inMemory("UserEntity")) var userEntity: UserEntity = .init() @@ -114,7 +114,7 @@ extension SplashFeature { try await clock.sleep(for: .seconds(1.3)) await send(.inner(.setFadeOut(true))) - await send(.navigation(.presentLogin)) + await send(.async(.runAuthCheck)) } } } diff --git a/MovieBooking/Feature/Tab/Reducer/MainTabFeature.swift b/MovieBooking/Feature/Tab/Reducer/MainTabFeature.swift index 2a0c91a..93189c5 100644 --- a/MovieBooking/Feature/Tab/Reducer/MainTabFeature.swift +++ b/MovieBooking/Feature/Tab/Reducer/MainTabFeature.swift @@ -7,7 +7,7 @@ import Foundation import ComposableArchitecture - +import SwiftUI @Reducer public struct MainTabFeature { @@ -18,10 +18,10 @@ public struct MainTabFeature { var selectTab: MainTab = .home - var movieList = MovieListFeature.State() var movieSearch = MovieSearchFeature.State() - + var myPage = MyPageFeature.State() + public init() {} } @@ -29,15 +29,21 @@ public struct MainTabFeature { case binding(BindingAction) case selectTab(MainTab) case scope(ScopeAction) + case navigation(NavigationAction) + } + public enum NavigationAction { + case backToLogin } @CasePathable public enum ScopeAction { case movieList(MovieListFeature.Action) case movieSearch(MovieSearchFeature.Action) + case myPage(MyPageFeature.Action) } + public var body: some Reducer { BindingReducer() Reduce { state, action in @@ -51,6 +57,9 @@ public struct MainTabFeature { case .scope(let scopeAction): return handleScopeAction(state: &state, action: scopeAction) + + case .navigation(let navigationAction): + return handleNavigationAction(state: &state, action: navigationAction) } } Scope(state: \.movieList, action: \.scope.movieList) { @@ -59,6 +68,9 @@ public struct MainTabFeature { Scope(state: \.movieSearch, action: \.scope.movieSearch) { MovieSearchFeature() } + Scope(state: \.myPage, action: \.scope.myPage) { + MyPageFeature() + } } } @@ -68,10 +80,23 @@ extension MainTabFeature { action: ScopeAction ) -> Effect { switch action { + case .myPage(.navigation(.logOutComplete)): + return .send(.navigation(.backToLogin), animation: .easeIn) + default: return .none } } + + private func handleNavigationAction( + state: inout State, + action: NavigationAction + ) -> Effect { + switch action { + case .backToLogin: + return .none + } + } } extension MainTabFeature.State: Hashable { diff --git a/MovieBooking/Feature/Tab/View/MainTabView.swift b/MovieBooking/Feature/Tab/View/MainTabView.swift index db6be52..4a104ea 100644 --- a/MovieBooking/Feature/Tab/View/MainTabView.swift +++ b/MovieBooking/Feature/Tab/View/MainTabView.swift @@ -26,7 +26,7 @@ struct MainTabView: View { .tabItem { Label("티켓", systemImage: "qrcode") } .tag(MainTab.tickets) - ContentView() + MyPageView(store: self.store.scope(state: \.myPage, action: \.scope.myPage)) .tabItem { Label("마이", systemImage: "person") } .tag(MainTab.my) From 2add00fdaf9a1777ee4e5435d15ce89027208ef2 Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 18 Oct 2025 04:39:27 +0900 Subject: [PATCH 5/7] =?UTF-8?q?[feat]:=20=20error=20=EA=B3=BC=EC=8B=AC?= =?UTF-8?q?=EC=82=AC=20=EB=B6=84=EB=A6=AC=20=20=EB=B0=8F=20=20shared=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=ED=95=B4=EC=84=9C=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=95=84=EC=9B=83=EC=9D=84=20=ED=95=B4=EB=8F=84=20=EB=A7=88?= =?UTF-8?q?=EC=A7=80=EB=A7=89=EC=97=90=20=EC=A0=80=EC=9E=A5=20=ED=95=9C?= =?UTF-8?q?=EA=B1=B8=20=20=EC=A0=80=EC=9E=A5=20=ED=95=A0=EC=88=98=EC=9E=88?= =?UTF-8?q?=EA=B2=8C=20=EA=B5=AC=ED=98=84=20#14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Entity/Error/Common/CommonError.swift | 43 +++--- .../Domain/Entity/Error/Data/DataError.swift | 42 +++++ .../Entity/Error/System/SystemError.swift | 51 ++++++ .../Error/Validation/ValidationError.swift | 74 +++++++++ .../Domain/Entity/Social/SocialType.swift | 4 +- .../Entity/Social/UserEntity+Mock.swift | 146 ++++++++++++++++++ .../Domain/UseCase/Auth/AuthUseCase.swift | 13 +- .../UseCase/Auth/AuthUseCaseProtocol.swift | 1 + .../Domain/UseCase/MovieUseCase.swift | 7 - .../Domain/UseCase/OAuth/OAuthUseCase.swift | 17 +- .../UseCase/OAuth/OAuthUseCaseProtocol.swift | 6 +- .../Auth/Login/Reducer/LoginFeature.swift | 59 ++++--- .../Auth/Login/View/LoginFormView.swift | 4 +- .../Feature/Auth/Login/View/LoginView.swift | 6 +- .../Auth/SignUp/Redcuer/SignUpFeature.swift | 19 ++- 15 files changed, 409 insertions(+), 83 deletions(-) create mode 100644 MovieBooking/Domain/Entity/Error/Data/DataError.swift create mode 100644 MovieBooking/Domain/Entity/Error/System/SystemError.swift create mode 100644 MovieBooking/Domain/Entity/Error/Validation/ValidationError.swift create mode 100644 MovieBooking/Domain/Entity/Social/UserEntity+Mock.swift delete mode 100644 MovieBooking/Domain/UseCase/MovieUseCase.swift diff --git a/MovieBooking/Domain/Entity/Error/Common/CommonError.swift b/MovieBooking/Domain/Entity/Error/Common/CommonError.swift index cf1baf7..c9813bb 100644 --- a/MovieBooking/Domain/Entity/Error/Common/CommonError.swift +++ b/MovieBooking/Domain/Entity/Error/Common/CommonError.swift @@ -7,39 +7,42 @@ import Foundation -/// 공통 에러 (네트워크 등 전역에서 공유되는 경우에만 사용) +/// 공통 에러 (정말 공통으로 사용되는 에러만) +/// +/// ⚠️ 주의: 특정 도메인에 속하는 에러는 각 도메인 에러를 사용하세요 +/// - 네트워크 에러 → NetworkError +/// - 데이터 에러 → DataError +/// - 유효성 검사 에러 → ValidationError +/// - 시스템 에러 → SystemError public enum CommonError: Error, Equatable { - case networkUnavailable - case requestTimeout - case serverError(statusCode: Int) - case rateLimited(retryAfter: TimeInterval?) + /// 알 수 없는 에러 (최후의 수단) case unknown(message: String? = nil) + + /// 취소된 작업 + case cancelled + + /// 지원되지 않는 기능 + case unsupported(feature: String? = nil) } // MARK: - 사용자 메시지 (UI friendly) extension CommonError: LocalizedError { public var errorDescription: String? { switch self { - case .networkUnavailable: - return "네트워크 연결을 확인해주세요." - - case .requestTimeout: - return "요청 시간이 초과되었습니다. 다시 시도해주세요." - - case .serverError(let statusCode): - return "서버 오류가 발생했습니다(코드: \(statusCode)). 잠시 후 다시 시도해주세요." - - case .rateLimited(let retryAfter): - if let seconds = retryAfter { - return "요청이 너무 많습니다. \(Int(seconds))초 후 다시 시도해주세요." - } - return "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." - case .unknown(let message): if let message = message { return "알 수 없는 오류가 발생했습니다: \(message)" } return "알 수 없는 오류가 발생했습니다. 잠시 후 다시 시도해주세요." + + case .cancelled: + return "작업이 취소되었습니다." + + case .unsupported(let feature): + if let feature = feature { + return "\(feature) 기능은 지원되지 않습니다." + } + return "지원되지 않는 기능입니다." } } } diff --git a/MovieBooking/Domain/Entity/Error/Data/DataError.swift b/MovieBooking/Domain/Entity/Error/Data/DataError.swift new file mode 100644 index 0000000..9d349ae --- /dev/null +++ b/MovieBooking/Domain/Entity/Error/Data/DataError.swift @@ -0,0 +1,42 @@ +// +// DataError.swift +// MovieBooking +// +// Created by Wonji Suh on 10/17/25. +// + +import Foundation + +/// 데이터 관련 에러 +public enum DataError: Error, Equatable { + case notFound(resource: String? = nil) + case dataCorrupted + case serializationFailed + case decodingFailed + case encodingFailed +} + +// MARK: - 사용자 메시지 (UI friendly) +extension DataError: LocalizedError { + public var errorDescription: String? { + switch self { + case .notFound(let resource): + if let resource = resource { + return "\(resource)을(를) 찾을 수 없습니다." + } + return "요청한 데이터를 찾을 수 없습니다." + + case .dataCorrupted: + return "데이터가 손상되었습니다. 다시 시도해주세요." + + case .serializationFailed: + return "데이터 변환 중 오류가 발생했습니다." + + case .decodingFailed: + return "데이터 처리 중 오류가 발생했습니다." + + case .encodingFailed: + return "데이터 저장 중 오류가 발생했습니다." + } + } +} \ No newline at end of file diff --git a/MovieBooking/Domain/Entity/Error/System/SystemError.swift b/MovieBooking/Domain/Entity/Error/System/SystemError.swift new file mode 100644 index 0000000..9a00309 --- /dev/null +++ b/MovieBooking/Domain/Entity/Error/System/SystemError.swift @@ -0,0 +1,51 @@ +// +// SystemError.swift +// MovieBooking +// +// Created by Wonji Suh on 10/17/25. +// + +import Foundation + +/// 시스템 관련 에러 +public enum SystemError: Error, Equatable { + case dependencyUnavailable(service: String? = nil) + case configurationError + case insufficientPermissions(permission: String? = nil) + case resourceExhausted(resource: String? = nil) + case serviceUnavailable(service: String? = nil) +} + +// MARK: - 사용자 메시지 (UI friendly) +extension SystemError: LocalizedError { + public var errorDescription: String? { + switch self { + case .dependencyUnavailable(let service): + if let service = service { + return "\(service) 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해주세요." + } + return "서비스를 사용할 수 없습니다. 잠시 후 다시 시도해주세요." + + case .configurationError: + return "앱 설정에 문제가 있습니다. 앱을 재시작해주세요." + + case .insufficientPermissions(let permission): + if let permission = permission { + return "\(permission) 권한이 필요합니다. 설정에서 권한을 허용해주세요." + } + return "권한이 필요합니다. 설정에서 권한을 허용해주세요." + + case .resourceExhausted(let resource): + if let resource = resource { + return "\(resource) 리소스가 부족합니다. 잠시 후 다시 시도해주세요." + } + return "시스템 리소스가 부족합니다. 잠시 후 다시 시도해주세요." + + case .serviceUnavailable(let service): + if let service = service { + return "\(service) 서비스가 일시적으로 사용할 수 없습니다." + } + return "서비스가 일시적으로 사용할 수 없습니다." + } + } +} \ No newline at end of file diff --git a/MovieBooking/Domain/Entity/Error/Validation/ValidationError.swift b/MovieBooking/Domain/Entity/Error/Validation/ValidationError.swift new file mode 100644 index 0000000..afbb66b --- /dev/null +++ b/MovieBooking/Domain/Entity/Error/Validation/ValidationError.swift @@ -0,0 +1,74 @@ +// +// ValidationError.swift +// MovieBooking +// +// Created by Wonji Suh on 10/17/25. +// + +import Foundation + +/// 유효성 검사 관련 에러 +public enum ValidationError: Error, Equatable { + case validationFailed(fields: [String]) + case invalidInput(field: String, reason: String? = nil) + case requiredFieldMissing(field: String) + case invalidFormat(field: String, expectedFormat: String? = nil) + case outOfRange(field: String, min: Any? = nil, max: Any? = nil) + + // Equatable 구현을 위해 Any 대신 String으로 처리 + public static func == (lhs: ValidationError, rhs: ValidationError) -> Bool { + switch (lhs, rhs) { + case (.validationFailed(let lFields), .validationFailed(let rFields)): + return lFields == rFields + case (.invalidInput(let lField, let lReason), .invalidInput(let rField, let rReason)): + return lField == rField && lReason == rReason + case (.requiredFieldMissing(let lField), .requiredFieldMissing(let rField)): + return lField == rField + case (.invalidFormat(let lField, let lFormat), .invalidFormat(let rField, let rFormat)): + return lField == rField && lFormat == rFormat + case (.outOfRange(let lField, _, _), .outOfRange(let rField, _, _)): + return lField == rField // min, max는 비교에서 제외 + default: + return false + } + } +} + +// MARK: - 사용자 메시지 (UI friendly) +extension ValidationError: LocalizedError { + public var errorDescription: String? { + switch self { + case .validationFailed(let fields): + if fields.isEmpty { + return "입력값이 올바르지 않습니다." + } + return "\(fields.joined(separator: ", ")) 항목을 확인해주세요." + + case .invalidInput(let field, let reason): + if let reason = reason { + return "\(field): \(reason)" + } + return "\(field) 항목이 올바르지 않습니다." + + case .requiredFieldMissing(let field): + return "\(field)은(는) 필수 입력 항목입니다." + + case .invalidFormat(let field, let expectedFormat): + if let format = expectedFormat { + return "\(field)의 형식이 올바르지 않습니다. (\(format) 형식으로 입력해주세요)" + } + return "\(field)의 형식이 올바르지 않습니다." + + case .outOfRange(let field, let min, let max): + var message = "\(field)의 값이 범위를 벗어났습니다." + if let min = min, let max = max { + message += " (\(min) ~ \(max) 사이의 값을 입력해주세요)" + } else if let min = min { + message += " (\(min) 이상의 값을 입력해주세요)" + } else if let max = max { + message += " (\(max) 이하의 값을 입력해주세요)" + } + return message + } + } +} \ No newline at end of file diff --git a/MovieBooking/Domain/Entity/Social/SocialType.swift b/MovieBooking/Domain/Entity/Social/SocialType.swift index 149c3e1..df86c17 100644 --- a/MovieBooking/Domain/Entity/Social/SocialType.swift +++ b/MovieBooking/Domain/Entity/Social/SocialType.swift @@ -22,7 +22,7 @@ public enum SocialType: String, CaseIterable, Identifiable, Hashable { var description: String { switch self { case .none, .email: - return "" + return "email" case .apple: return "Apple" case .google: @@ -41,7 +41,7 @@ public enum SocialType: String, CaseIterable, Identifiable, Hashable { case .kakao: return "kakao" case .none, .email: - return "" + return "mail" } } diff --git a/MovieBooking/Domain/Entity/Social/UserEntity+Mock.swift b/MovieBooking/Domain/Entity/Social/UserEntity+Mock.swift new file mode 100644 index 0000000..83f7370 --- /dev/null +++ b/MovieBooking/Domain/Entity/Social/UserEntity+Mock.swift @@ -0,0 +1,146 @@ +// +// UserEntity+Mock.swift +// MovieBooking +// +// Created by Wonji Suh on 10/17/25. +// + +import Foundation + +// MARK: - UserEntity Mock Data + +extension UserEntity { + + // MARK: - 개별 Mock 데이터 + + /// Apple 로그인 사용자 Mock + public static let mockAppleUser = UserEntity( + id: "550e8400-e29b-41d4-a716-446655440001", + userId: "apple_user", + email: "apple.user@icloud.com", + displayName: "Apple 사용자", + provider: .apple, + tokens: AuthTokens( + accessToken: "mock_apple_access_token_12345", + refreshToken: "mock_apple_refresh_token_12345" + ) + ) + + /// Google 로그인 사용자 Mock + public static let mockGoogleUser = UserEntity( + id: "550e8400-e29b-41d4-a716-446655440002", + userId: "google_user", + email: "google.user@gmail.com", + displayName: "구글 사용자", + provider: .google, + tokens: AuthTokens( + accessToken: "mock_google_access_token_12345", + refreshToken: "mock_google_refresh_token_12345" + ) + ) + + /// 이메일 로그인 사용자 Mock + public static let mockEmailUser = UserEntity( + id: "550e8400-e29b-41d4-a716-446655440003", + userId: "test1", + email: "test1@test.com", + displayName: "테스터", + provider: .email, + tokens: AuthTokens( + accessToken: "mock_email_access_token_12345", + refreshToken: "mock_email_refresh_token_12345" + ) + ) + + /// Kakao 로그인 사용자 Mock + public static let mockKakaoUser = UserEntity( + id: "550e8400-e29b-41d4-a716-446655440004", + userId: "kakao_user", + email: "kakao.user@kakao.com", + displayName: "카카오 사용자", + provider: .kakao, + tokens: AuthTokens( + accessToken: "mock_kakao_access_token_12345", + refreshToken: "mock_kakao_refresh_token_12345" + ) + ) + + /// 빈 사용자 Mock (초기값) + public static let mockEmptyUser = UserEntity( + id: "", + userId: "", + email: nil, + displayName: nil, + provider: .none, + tokens: AuthTokens(accessToken: "", refreshToken: "") + ) + + // MARK: - 다양한 시나리오별 Mock 배열 + + /// 모든 제공자별 사용자 Mock 배열 + public static let mockUsers: [UserEntity] = [ + mockAppleUser, + mockGoogleUser, + mockEmailUser, + mockKakaoUser + ] + + /// 소셜 로그인 사용자들만 + public static let mockSocialUsers: [UserEntity] = [ + mockAppleUser, + mockGoogleUser, + mockKakaoUser + ] + + // MARK: - 동적 Mock 생성 함수 + + /// 사용자 정의 Mock 생성 + public static func createMockUser( + provider: SocialType, + userId: String = "mock_user", + email: String? = "mock@test.com", + displayName: String? = "Mock User" + ) -> UserEntity { + return UserEntity( + id: UUID().uuidString, + userId: userId, + email: email, + displayName: displayName, + provider: provider, + tokens: AuthTokens( + accessToken: "mock_\(provider.rawValue)_access_token", + refreshToken: "mock_\(provider.rawValue)_refresh_token" + ) + ) + } + + /// 랜덤 Mock 사용자 생성 + public static func createRandomMockUser() -> UserEntity { + let providers = SocialType.allCases.filter { $0 != .none } + let randomProvider = providers.randomElement() ?? .email + let randomId = Int.random(in: 1000...9999) + + return createMockUser( + provider: randomProvider, + userId: "user_\(randomId)", + email: "user\(randomId)@test.com", + displayName: "테스트 사용자 \(randomId)" + ) + } +} + +// MARK: - 테스트용 편의 Mock + +#if DEBUG +extension UserEntity { + /// 요청하신 원본 Mock (동일한 형태) + public static let mockOriginalRequest = UserEntity( + id: UUID().uuidString, + userId: "test1", + email: "test1@test.com", + displayName: "테스터", + provider: .apple, + tokens: AuthTokens(accessToken: "", refreshToken: "") + ) +} +#endif \ No newline at end of file diff --git a/MovieBooking/Domain/UseCase/Auth/AuthUseCase.swift b/MovieBooking/Domain/UseCase/Auth/AuthUseCase.swift index 0e2a9b4..cb0147f 100644 --- a/MovieBooking/Domain/UseCase/Auth/AuthUseCase.swift +++ b/MovieBooking/Domain/UseCase/Auth/AuthUseCase.swift @@ -44,7 +44,6 @@ public struct AuthUseCase: AuthUseCaseProtocol { // 3. 로깅 (비즈니스 관심사) #logDebug("회원가입 완료 → \(userEntity.email ?? "unknown")") - // 4. 도메인 객체 반환 return userEntity } @@ -92,6 +91,7 @@ public struct AuthUseCase: AuthUseCaseProtocol { // 5. 비즈니스 규칙: userId 주입 var userEntity = session userEntity.userId = overrideLoginId + return userEntity } @@ -118,7 +118,10 @@ extension AuthUseCase: DependencyKey { let sessionRepository = UnifiedDI.resolve(SessionRepositoryProtocol.self) ?? SessionRepository() let sessionUseCase = UnifiedDI.resolve(SessionUseCaseProtocol.self) ?? SessionUseCase(repository: sessionRepository) - return AuthUseCase(repository: repository, sessionUseCase: sessionUseCase) + return AuthUseCase( + repository: repository, + sessionUseCase: sessionUseCase + ) }() } @@ -144,7 +147,10 @@ extension RegisterModule { repository: UnifiedDI.resolve(SessionRepositoryProtocol.self) ?? SessionRepository() ) - return AuthUseCase(repository: repo, sessionUseCase: sessionUseCase) + return AuthUseCase( + repository: repo, + sessionUseCase: sessionUseCase + ) } ) } @@ -154,4 +160,5 @@ extension RegisterModule { AuthRepository() } } + } diff --git a/MovieBooking/Domain/UseCase/Auth/AuthUseCaseProtocol.swift b/MovieBooking/Domain/UseCase/Auth/AuthUseCaseProtocol.swift index a5ab43e..8ae463e 100644 --- a/MovieBooking/Domain/UseCase/Auth/AuthUseCaseProtocol.swift +++ b/MovieBooking/Domain/UseCase/Auth/AuthUseCaseProtocol.swift @@ -38,4 +38,5 @@ public protocol AuthUseCaseProtocol: Sendable { /// 세션 로그아웃 func sessionLogOut() async throws + } diff --git a/MovieBooking/Domain/UseCase/MovieUseCase.swift b/MovieBooking/Domain/UseCase/MovieUseCase.swift deleted file mode 100644 index 7626f5d..0000000 --- a/MovieBooking/Domain/UseCase/MovieUseCase.swift +++ /dev/null @@ -1,7 +0,0 @@ -// -// MovieUseCase.swift -// MovieBooking -// -// Created by 김민희 on 10/13/25. -// - diff --git a/MovieBooking/Domain/UseCase/OAuth/OAuthUseCase.swift b/MovieBooking/Domain/UseCase/OAuth/OAuthUseCase.swift index 82ccd02..be4db50 100644 --- a/MovieBooking/Domain/UseCase/OAuth/OAuthUseCase.swift +++ b/MovieBooking/Domain/UseCase/OAuth/OAuthUseCase.swift @@ -66,13 +66,6 @@ public struct OAuthUseCase: OAuthUseCaseProtocol { return userEntity } - // MARK: - 세션 관리 - - public func fetchCurrentSocialType() async throws -> SocialType? { - let raw = try await repository.getCurrentProvider() ?? "unknown" - return SocialType(rawValue: raw) - } - } // MARK: - DI 설정 @@ -83,7 +76,10 @@ extension OAuthUseCase: DependencyKey { let sessionRepository = UnifiedDI.resolve(SessionRepositoryProtocol.self) ?? SessionRepository() let sessionUseCase = UnifiedDI.resolve(SessionUseCaseProtocol.self) ?? SessionUseCase(repository: sessionRepository) - return OAuthUseCase(repository: repository, sessionUseCase: sessionUseCase) + return OAuthUseCase( + repository: repository, + sessionUseCase: sessionUseCase + ) }() } @@ -109,7 +105,10 @@ extension RegisterModule { repository: UnifiedDI.resolve(SessionRepositoryProtocol.self) ?? SessionRepository() ) - return OAuthUseCase(repository: repo, sessionUseCase: sessionUseCase) + return OAuthUseCase( + repository: repo, + sessionUseCase: sessionUseCase + ) } ) } diff --git a/MovieBooking/Domain/UseCase/OAuth/OAuthUseCaseProtocol.swift b/MovieBooking/Domain/UseCase/OAuth/OAuthUseCaseProtocol.swift index 21b18e3..af8e393 100644 --- a/MovieBooking/Domain/UseCase/OAuth/OAuthUseCaseProtocol.swift +++ b/MovieBooking/Domain/UseCase/OAuth/OAuthUseCaseProtocol.swift @@ -24,8 +24,4 @@ public protocol OAuthUseCaseProtocol: Sendable { /// - 소셜 로그인 → 세션 대기 → 로깅 → 사용자 정보 반환 func signInWithSocial(type: SocialType) async throws -> UserEntity - // MARK: - 세션 관리 - - /// 현재 로그인된 소셜 타입 조회 (기존과 동일한 동작) - func fetchCurrentSocialType() async throws -> SocialType? -} \ No newline at end of file +} diff --git a/MovieBooking/Feature/Auth/Login/Reducer/LoginFeature.swift b/MovieBooking/Feature/Auth/Login/Reducer/LoginFeature.swift index 269ad73..a32bfdd 100644 --- a/MovieBooking/Feature/Auth/Login/Reducer/LoginFeature.swift +++ b/MovieBooking/Feature/Auth/Login/Reducer/LoginFeature.swift @@ -30,6 +30,13 @@ public struct LoginFeature { var loginId : String = "" var loginPassword: String = "" + @Shared(.appStorage("lastLoginUserId")) + var lastLoginUserId: String = "" + @Shared(.appStorage("lastLoginEmail")) + var lastLoginEmail: String = "" + @Shared(.appStorage("lastLoginProvider")) + var lastLoginProviderRaw: String = SocialType.none.rawValue + @Shared var userEntity: UserEntity @Shared(.appStorage("isSaveUserId")) @@ -41,6 +48,11 @@ public struct LoginFeature { !loginId.isEmpty && !loginPassword.isEmpty } + var lastLoginProvider: SocialType { + get { SocialType(rawValue: lastLoginProviderRaw) ?? .none } + set { $lastLoginProviderRaw.withLock { $0 = newValue.rawValue } } + } + public init( userEntity: UserEntity @@ -53,6 +65,9 @@ public struct LoginFeature { } else { self.$loginId.withLock { $0 = "" } } + + let provider = lastLoginProvider + self.socialType = provider == .none ? nil : provider } } @@ -63,9 +78,6 @@ public struct LoginFeature { case async(AsyncAction) case inner(InnerAction) case navigation(NavigationAction) - case showErrorPopUp(Bool) - case loginId(String) - case loginPassword(String) } @@ -102,7 +114,6 @@ public struct LoginFeature { nonisolated enum LoginCancelID: Hashable, Sendable { - case session case apple case social case normal @@ -124,14 +135,6 @@ public struct LoginFeature { case .binding(_): return .none - case let .loginId(id): - state.$loginId.withLock { $0 = id } - return .none - - case let .loginPassword(password): - state.loginPassword = password - return .none - case .view(let viewAction): return handleViewAction(state: &state, action: viewAction) @@ -144,9 +147,6 @@ public struct LoginFeature { case .navigation(let navigationAction): return handleNavigationAction(state: &state, action: navigationAction) - case let .showErrorPopUp(popup): - state.showErrorPopUp = popup - return .none } } } @@ -220,20 +220,9 @@ extension LoginFeature { .debounce(id: LoginCancelID.social, for: 0.1, scheduler: mainQueue) case .fetchLastLoginSession: - return .run { send in - let sessionResult = await Result { - try await oAuthUseCase.fetchCurrentSocialType() - } - - switch sessionResult { - case .success(let sessionData): - await send(.inner(.setSocialType(sessionData))) - - case .failure(let error): - #logDebug("세션 정보 가져오기 실패", error.localizedDescription) - } - } - .debounce(id: LoginCancelID.session, for: 0.1, scheduler: mainQueue) + let provider = state.lastLoginProvider + state.socialType = provider == .none ? nil : provider + return .none case .normalLogin: return .run { [ @@ -286,7 +275,11 @@ extension LoginFeature { return .none case .setUser(let userEnity): - state.$userEntity.withLock { $0 = userEnity} + state.$userEntity.withLock { $0 = userEnity } + state.$lastLoginUserId.withLock { $0 = userEnity.userId } + state.$lastLoginEmail.withLock { $0 = userEnity.email ?? "" } + state.lastLoginProvider = userEnity.provider + state.socialType = userEnity.provider == .none ? state.socialType : userEnity.provider #logDebug("로그인 성공", state.userEntity) return .none @@ -313,6 +306,9 @@ extension LoginFeature.State: Hashable { lhs.loginPassword == rhs.loginPassword && lhs.saveUserId == rhs.saveUserId && lhs.authErrorMesage == rhs.authErrorMesage && + lhs.lastLoginUserId == rhs.lastLoginUserId && + lhs.lastLoginEmail == rhs.lastLoginEmail && + lhs.lastLoginProviderRaw == rhs.lastLoginProviderRaw && lhs.isEnable == rhs.isEnable } public func hash(into hasher: inout Hasher) { @@ -325,6 +321,9 @@ extension LoginFeature.State: Hashable { hasher.combine(loginPassword) hasher.combine(saveUserId) hasher.combine(authErrorMesage) + hasher.combine(lastLoginUserId) + hasher.combine(lastLoginEmail) + hasher.combine(lastLoginProviderRaw) hasher.combine(isEnable) } } diff --git a/MovieBooking/Feature/Auth/Login/View/LoginFormView.swift b/MovieBooking/Feature/Auth/Login/View/LoginFormView.swift index 6564a87..bdecc7b 100644 --- a/MovieBooking/Feature/Auth/Login/View/LoginFormView.swift +++ b/MovieBooking/Feature/Auth/Login/View/LoginFormView.swift @@ -20,7 +20,7 @@ struct LoginFormView: View { VStack(spacing: 16) { FormTextField( placeholder: "아이디를 입력하세요", - text: $store.loginId.sending(\.loginId), + text: $store.loginId, kind: .email, submitLabel: .next, onSubmit: { focus = .password }, @@ -29,7 +29,7 @@ struct LoginFormView: View { FormTextField( placeholder: "비밀번호를 입력하세요", - text: $store.loginPassword.sending(\.loginPassword), + text: $store.loginPassword, kind: .password, submitLabel: .done, ) diff --git a/MovieBooking/Feature/Auth/Login/View/LoginView.swift b/MovieBooking/Feature/Auth/Login/View/LoginView.swift index aa34161..306cc80 100644 --- a/MovieBooking/Feature/Auth/Login/View/LoginView.swift +++ b/MovieBooking/Feature/Auth/Login/View/LoginView.swift @@ -47,15 +47,13 @@ struct LoginView: View { .scrollIndicators(.hidden) .scrollBounceBehavior(.basedOnSize) .ignoresSafeArea(.keyboard) - .task { - send(.onAppear) - } .onAppear { UIScrollView.appearance().bounces = false + send(.onAppear) } .floatingPopup( - isPresented: $store.showErrorPopUp.sending(\.showErrorPopUp), + isPresented: $store.showErrorPopUp, alignment: .top ) { WithPerceptionTracking { diff --git a/MovieBooking/Feature/Auth/SignUp/Redcuer/SignUpFeature.swift b/MovieBooking/Feature/Auth/SignUp/Redcuer/SignUpFeature.swift index ff2ada2..22b7b3f 100644 --- a/MovieBooking/Feature/Auth/SignUp/Redcuer/SignUpFeature.swift +++ b/MovieBooking/Feature/Auth/SignUp/Redcuer/SignUpFeature.swift @@ -19,6 +19,9 @@ public struct SignUpFeature { public struct State: Equatable { @Shared(.inMemory("UserEntity")) var userEntity: UserEntity = .init() + @Shared(.appStorage("lastLoginUserId")) var lastLoginUserId: String = "" + @Shared(.appStorage("lastLoginEmail")) var lastLoginEmail: String = "" + @Shared(.appStorage("lastLoginProvider")) var lastLoginProviderRaw: String = SocialType.none.rawValue var userPassword: String = "" var checkPassword: String = "" @@ -42,6 +45,11 @@ public struct SignUpFeature { !userEmail.isEmpty && !userName.isEmpty && !userPassword.isEmpty && userPassword == checkPassword } + var lastLoginProvider: SocialType { + get { SocialType(rawValue: lastLoginProviderRaw) ?? .none } + set { $lastLoginProviderRaw.withLock { $0 = newValue.rawValue } } + } + public init() { } @@ -193,6 +201,9 @@ extension SignUpFeature { case .setUser(let userEntity): state.$userEntity.withLock { $0 = userEntity } + state.$lastLoginUserId.withLock { $0 = userEntity.userId } + state.$lastLoginEmail.withLock { $0 = userEntity.email ?? "" } + state.lastLoginProvider = userEntity.provider return .none } } @@ -211,13 +222,19 @@ extension SignUpFeature.State: Hashable { lhs.passwordError == rhs.passwordError && lhs.checkPasswordError == rhs.checkPasswordError && lhs.showAlert == rhs.showAlert && - lhs.isSuccessSignUp == rhs.isSuccessSignUp + lhs.isSuccessSignUp == rhs.isSuccessSignUp && + lhs.lastLoginUserId == rhs.lastLoginUserId && + lhs.lastLoginEmail == rhs.lastLoginEmail && + lhs.lastLoginProviderRaw == rhs.lastLoginProviderRaw } public func hash(into hasher: inout Hasher) { hasher.combine(userEmail) hasher.combine(userPassword) hasher.combine(checkPassword) hasher.combine(userName) + hasher.combine(lastLoginUserId) + hasher.combine(lastLoginEmail) + hasher.combine(lastLoginProviderRaw) hasher.combine(authError) hasher.combine(authErrorMesage) hasher.combine(userNameError) From 0d1ace136e4cb389ef0a04296cc54ac278e7487d Mon Sep 17 00:00:00 2001 From: Roy Date: Sun, 19 Oct 2025 23:32:26 +0900 Subject: [PATCH 6/7] =?UTF-8?q?[chore]:=20=EC=BD=94=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=ED=94=84=EB=A1=9C=ED=95=84=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MovieBooking/App/Reducer/AppReducer.swift | 54 +++++++++---------- MovieBooking/App/View/AppView.swift | 38 +++++++------ .../{ => Movie}/MovieRepositoryProtocol.swift | 0 .../MyPage/Components/ProfileBoxView.swift | 2 +- .../MyPage/Reducer/MyPageFeature.swift | 1 - .../Feature/MyPage/View/MyPageView.swift | 2 +- .../SessionTest/SessionMapperTest.swift | 16 ++++++ 7 files changed, 63 insertions(+), 50 deletions(-) rename MovieBooking/Domain/Repository/{ => Movie}/MovieRepositoryProtocol.swift (100%) create mode 100644 MovieBookingTests/SessionTest/SessionMapperTest.swift diff --git a/MovieBooking/App/Reducer/AppReducer.swift b/MovieBooking/App/Reducer/AppReducer.swift index 0aafbc6..3b4808b 100644 --- a/MovieBooking/App/Reducer/AppReducer.swift +++ b/MovieBooking/App/Reducer/AppReducer.swift @@ -17,8 +17,6 @@ struct AppReducer { case auth(AuthCoordinator.State) case mainTab(MainTabFeature.State) - - init() { self = .splash(.init()) } @@ -35,7 +33,6 @@ struct AppReducer { case presentMain } - @CasePathable enum ScopeAction { case splash(SplashFeature.Action) @@ -43,12 +40,13 @@ struct AppReducer { case mainTab(MainTabFeature.Action) } + @Dependency(\.continuousClock) var clock public var body: some Reducer { Reduce { state, action in switch action { - case .view(let viewAction): + case .view(let viewAction): return handleViewAction(&state, action: viewAction) case .scope(let scopeAction): @@ -73,15 +71,14 @@ extension AppReducer { action: View ) -> Effect { switch action { - // MARK: - 로그인 화면으로 - case .presentAuth: - state = .auth(.init()) - return .none + // MARK: - 로그인 화면으로 + case .presentAuth: + state = .auth(.init()) + return .none - case .presentMain: + case .presentMain: state = .mainTab(.init()) - return .none - + return .send(.scope(.mainTab(.scope(.movieList(.fetchMovie))))) } } @@ -92,23 +89,26 @@ extension AppReducer { ) -> Effect { switch action { case .splash(.navigation(.presentLogin)): - return .run { send in - try await clock.sleep(for: .seconds(1)) - await send(.view(.presentAuth)) - } - - case .splash(.navigation(.presentMain)): - return .run { send in - try await clock.sleep(for: .seconds(1)) - await send(.view(.presentMain)) - } - - - case .auth(.navigation(.presentMain)): - return .send(.view(.presentMain), animation: .easeIn) + return .run { send in + try await clock.sleep(for: .seconds(1)) + await send(.view(.presentAuth)) + } + + case .splash(.navigation(.presentMain)): + return .run { send in + try await clock.sleep(for: .seconds(1)) + await send(.view(.presentMain)) + } + + case .auth(.navigation(.presentMain)): + return .send(.view(.presentMain), animation: .easeIn) + + case .mainTab(.navigation(.backToLogin)): + return .run { send in + await send(.view(.presentAuth), animation: .easeIn) + } + default: return .none - default: - return .none } } } diff --git a/MovieBooking/App/View/AppView.swift b/MovieBooking/App/View/AppView.swift index 0c683cc..64ed711 100644 --- a/MovieBooking/App/View/AppView.swift +++ b/MovieBooking/App/View/AppView.swift @@ -10,27 +10,25 @@ import SwiftUI import ComposableArchitecture struct AppView: View { - @Perception.Bindable var store: StoreOf - + var store: StoreOf + var body: some View { - WithPerceptionTracking { - SwitchStore(store) { state in - switch state { - case .splash: - if let store = store.scope(state: \.splash, action: \.scope.splash) { - SplashView(store: store) - } - - case .auth: - if let store = store.scope(state: \.auth, action: \.scope.auth) { - AuthCoordinatorView(store: store) - } - - case .mainTab: - if let store = store.scope(state: \.mainTab, action: \.scope.mainTab) { - MainTabView(store: store) - } - } + SwitchStore(store) { state in + switch state { + case .splash: + if let store = store.scope(state: \.splash, action: \.scope.splash) { + SplashView(store: store) + } + + case .auth: + if let store = store.scope(state: \.auth, action: \.scope.auth) { + AuthCoordinatorView(store: store) + } + + case .mainTab: + if let store = store.scope(state: \.mainTab, action: \.scope.mainTab) { + MainTabView(store: store) + } } } } diff --git a/MovieBooking/Domain/Repository/MovieRepositoryProtocol.swift b/MovieBooking/Domain/Repository/Movie/MovieRepositoryProtocol.swift similarity index 100% rename from MovieBooking/Domain/Repository/MovieRepositoryProtocol.swift rename to MovieBooking/Domain/Repository/Movie/MovieRepositoryProtocol.swift diff --git a/MovieBooking/Feature/MyPage/Components/ProfileBoxView.swift b/MovieBooking/Feature/MyPage/Components/ProfileBoxView.swift index 53c1b2c..4058d75 100644 --- a/MovieBooking/Feature/MyPage/Components/ProfileBoxView.swift +++ b/MovieBooking/Feature/MyPage/Components/ProfileBoxView.swift @@ -65,7 +65,7 @@ struct ProfileBoxView: View { Spacer() } - .padding(.horizontal, 24) + .padding(.horizontal, 20) // Divider() // .padding(.horizontal, 24) diff --git a/MovieBooking/Feature/MyPage/Reducer/MyPageFeature.swift b/MovieBooking/Feature/MyPage/Reducer/MyPageFeature.swift index a659347..fe3bc70 100644 --- a/MovieBooking/Feature/MyPage/Reducer/MyPageFeature.swift +++ b/MovieBooking/Feature/MyPage/Reducer/MyPageFeature.swift @@ -114,7 +114,6 @@ extension MyPageFeature { case .logOutUser: return .run { send in try await authUseCase.sessionLogOut() - try await clock.sleep(for: .seconds(0.3)) await send(.view(.closePopUp)) try await clock.sleep(for: .seconds(0.4)) await send(.navigation(.logOutComplete)) diff --git a/MovieBooking/Feature/MyPage/View/MyPageView.swift b/MovieBooking/Feature/MyPage/View/MyPageView.swift index 5ecfc7c..e9bca1f 100644 --- a/MovieBooking/Feature/MyPage/View/MyPageView.swift +++ b/MovieBooking/Feature/MyPage/View/MyPageView.swift @@ -42,7 +42,7 @@ struct MyPageView: View { } .customAlert( isPresented: store.appearPopUp, - title: "르그아웃", + title: "로그아웃", message: "정말 로그아웃하시겠어요?", isUseConfirmButton: false, onConfirm: { diff --git a/MovieBookingTests/SessionTest/SessionMapperTest.swift b/MovieBookingTests/SessionTest/SessionMapperTest.swift new file mode 100644 index 0000000..0f773b2 --- /dev/null +++ b/MovieBookingTests/SessionTest/SessionMapperTest.swift @@ -0,0 +1,16 @@ +// +// SessionMapperTest.swift +// MovieBookingTests +// +// Created by Wonji Suh on 10/19/25. +// + +import Testing + +struct SessionMapperTest { + + @Test func <#test function name#>() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } + +} From 49c7a7a2ead38b0504fc11f8cba33833f2bf6dce Mon Sep 17 00:00:00 2001 From: Roy Date: Mon, 20 Oct 2025 10:31:02 +0900 Subject: [PATCH 7/7] =?UTF-8?q?[chore]:=20=EC=BB=A8=EB=B2=A4=EC=85=98=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 --- MovieBooking/App/View/AppView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/MovieBooking/App/View/AppView.swift b/MovieBooking/App/View/AppView.swift index 64ed711..3926fb0 100644 --- a/MovieBooking/App/View/AppView.swift +++ b/MovieBooking/App/View/AppView.swift @@ -11,7 +11,7 @@ import ComposableArchitecture struct AppView: View { var store: StoreOf - + var body: some View { SwitchStore(store) { state in switch state { @@ -19,12 +19,12 @@ struct AppView: View { if let store = store.scope(state: \.splash, action: \.scope.splash) { SplashView(store: store) } - + case .auth: if let store = store.scope(state: \.auth, action: \.scope.auth) { AuthCoordinatorView(store: store) } - + case .mainTab: if let store = store.scope(state: \.mainTab, action: \.scope.mainTab) { MainTabView(store: store)