diff --git a/WhereAreYou/WhereAreYou.xcodeproj/project.pbxproj b/WhereAreYou/WhereAreYou.xcodeproj/project.pbxproj index ab634ae..4b5589a 100644 --- a/WhereAreYou/WhereAreYou.xcodeproj/project.pbxproj +++ b/WhereAreYou/WhereAreYou.xcodeproj/project.pbxproj @@ -178,6 +178,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = WhereAreYou/WhereAreYou.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 2ZF894QLGK; @@ -218,6 +219,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = WhereAreYou/WhereAreYou.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 2ZF894QLGK; diff --git a/WhereAreYou/WhereAreYou/Core/AppDelegate.swift b/WhereAreYou/WhereAreYou/Core/AppDelegate.swift index f667e40..96ee880 100644 --- a/WhereAreYou/WhereAreYou/Core/AppDelegate.swift +++ b/WhereAreYou/WhereAreYou/Core/AppDelegate.swift @@ -26,16 +26,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } + private static var emulatorHost: String { + ProcessInfo.processInfo.environment["FIREBASE_EMULATOR_HOST"] ?? "localhost" + } + private func configureFirebaseEmulators() { #if DEBUG - Auth.auth().useEmulator(withHost: "localhost", port: 9099) + let host = Self.emulatorHost let firestoreSettings = Firestore.firestore().settings - firestoreSettings.host = "localhost:8080" + firestoreSettings.host = "\(host):8080" firestoreSettings.isSSLEnabled = false firestoreSettings.cacheSettings = MemoryCacheSettings() Firestore.firestore().settings = firestoreSettings - Database.database().useEmulator(withHost: "localhost", port: 9000) - Storage.storage().useEmulator(withHost: "localhost", port: 9199) + Database.database().useEmulator(withHost: host, port: 9000) + Storage.storage().useEmulator(withHost: host, port: 9199) #endif } diff --git a/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/AppCoordinator.swift b/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/AppCoordinator.swift index c8e0e24..4c069bc 100644 --- a/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/AppCoordinator.swift +++ b/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/AppCoordinator.swift @@ -11,7 +11,7 @@ import UIKit final class AppCoordinator: Coordinator { private let window: UIWindow - private let screenFactory: AppScreenFactory + private let screenFactory: ScreenFactory private var tabBarCoordinator: TabBarCoordinator? init(window: UIWindow, screenFactory: ScreenFactory = .shared) { @@ -20,21 +20,75 @@ final class AppCoordinator: Coordinator { } func start() { - window.rootViewController = makeLoginViewController() + let authRepository: AuthRepository = screenFactory.container.resolve() + if authRepository.currentUserID != nil { + showHome() + validateUser() + } else { + showLogin() + } window.makeKeyAndVisible() } - private func makeLoginViewController() -> UIViewController { - let loginViewController = screenFactory.makeLoginViewController() - loginViewController.onAppleLoginTap = { [weak self] in - self?.switchToHome() + // MARK: - 자동 로그인 검증 + + private func validateUser() { + let authRepository: AuthRepository = screenFactory.container.resolve() + let userRepository: UserRepository = screenFactory.container.resolve() + guard let userID = authRepository.currentUserID else { + switchToLogin() + return } - return loginViewController + Task { @MainActor in + do { + _ = try await userRepository.fetchUser(userID: userID) + } catch AppError.network { + self.showNetworkErrorAlert() + } catch { + self.switchToLogin() + } + } + } + + private func showNetworkErrorAlert() { + let alert = UIAlertController( + title: "연결 오류", + message: "네트워크 연결을 확인 후 다시 시도해 주세요.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "재시도", style: .default) { [weak self] _ in + self?.validateUser() + }) + alert.addAction(UIAlertAction(title: "로그아웃", style: .destructive) { [weak self] _ in + self?.switchToLogin() + }) + window.rootViewController?.present(alert, animated: true) + } + + // MARK: - 초기 화면 설정 + + private func showLogin() { + window.rootViewController = makeLoginViewController() + } + + private func showHome() { + let coordinator = TabBarCoordinator() + tabBarCoordinator = coordinator + coordinator.onSignOut = { [weak self] in + self?.switchToLogin() + } + coordinator.start() + window.rootViewController = coordinator.tabBarController } + // MARK: - 화면 전환 + private func switchToHome() { let coordinator = TabBarCoordinator() tabBarCoordinator = coordinator + coordinator.onSignOut = { [weak self] in + self?.switchToLogin() + } coordinator.start() UIView.transition(with: window, duration: 0.3, options: .transitionCrossDissolve) { @@ -42,4 +96,21 @@ final class AppCoordinator: Coordinator { } } + private func switchToLogin() { + tabBarCoordinator = nil + let loginViewController = makeLoginViewController() + + UIView.transition(with: window, duration: 0.3, options: .transitionCrossDissolve) { + self.window.rootViewController = loginViewController + } + } + + private func makeLoginViewController() -> LoginViewController { + let loginViewController = screenFactory.makeLoginViewController() + loginViewController.onLoginSuccess = { [weak self] _ in + self?.switchToHome() + } + return loginViewController + } + } diff --git a/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/MyPageCoordinator.swift b/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/MyPageCoordinator.swift index b6d0592..0e91c3d 100644 --- a/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/MyPageCoordinator.swift +++ b/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/MyPageCoordinator.swift @@ -11,6 +11,7 @@ final class MyPageCoordinator: NavigationCoordinator, MyPageCoordinating { let navigationController: UINavigationController private let screenFactory: MyPageScreenFactory + var onSignOut: (() -> Void)? init( navigationController: UINavigationController, @@ -55,6 +56,12 @@ final class MyPageCoordinator: NavigationCoordinator, MyPageCoordinating { push(screenFactory.makeLocationPermissionViewController()) } + // MARK: - Sign Out + + func signOut() { + onSignOut?() + } + // MARK: - Appointment Notification List func showAppointmentNotificationList( diff --git a/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/TabBarCoordinator.swift b/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/TabBarCoordinator.swift index 1bba90f..217090c 100644 --- a/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/TabBarCoordinator.swift +++ b/WhereAreYou/WhereAreYou/Core/Coordinator/Implementation/TabBarCoordinator.swift @@ -12,6 +12,7 @@ import UIKit final class TabBarCoordinator: Coordinator { let tabBarController = UITabBarController() + var onSignOut: (() -> Void)? private var homeCoordinator: HomeCoordinator? private var appointmentListCoordinator: AppointmentListCoordinator? @@ -37,8 +38,12 @@ final class TabBarCoordinator: Coordinator { ) let myPageNav = UINavigationController() - myPageCoordinator = MyPageCoordinator(navigationController: myPageNav) - myPageCoordinator?.start() + let myPageCoord = MyPageCoordinator(navigationController: myPageNav) + myPageCoord.onSignOut = { [weak self] in + self?.onSignOut?() + } + myPageCoordinator = myPageCoord + myPageCoord.start() myPageNav.tabBarItem = UITabBarItem( title: "마이페이지", image: UIImage(systemName: "person"), diff --git a/WhereAreYou/WhereAreYou/Core/Coordinator/Protocol/MyPageCoordinating.swift b/WhereAreYou/WhereAreYou/Core/Coordinator/Protocol/MyPageCoordinating.swift index 5e42cec..f08ea4a 100644 --- a/WhereAreYou/WhereAreYou/Core/Coordinator/Protocol/MyPageCoordinating.swift +++ b/WhereAreYou/WhereAreYou/Core/Coordinator/Protocol/MyPageCoordinating.swift @@ -24,5 +24,6 @@ protocol MyPageCoordinating: AnyObject { fetchItems: @escaping () -> [AppointmentListItem], onToggle: @escaping (String) -> Void ) + func signOut() } diff --git a/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift b/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift index b0a9a0e..474d391 100644 --- a/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift +++ b/WhereAreYou/WhereAreYou/Core/DI/Implementation/AppDIContainer+Register.swift @@ -5,9 +5,23 @@ // Created by 김성훈 on 8/26/26. // +import UIKit + extension DIContainer { func registerDependencies() { + // 인증 + register(AuthRepository.self, instance: FirebaseAuthRepository()) + register(UserRepository.self, instance: FirestoreUserRepository()) + register(SignInService.self, instance: AppleSignInProvider( + windowProvider: { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } + } + )) + // 위치 let coreLocationRepository = CoreLocationRepository() register(LocationRepository.self, instance: coreLocationRepository) diff --git a/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+Login.swift b/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+Login.swift index 627f1e7..f51b0cc 100644 --- a/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+Login.swift +++ b/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+Login.swift @@ -8,7 +8,16 @@ extension ScreenFactory { func makeLoginViewController() -> LoginViewController { - LoginViewController() + let signInService: SignInService = container.resolve() + let authRepository: AuthRepository = container.resolve() + let userRepository: UserRepository = container.resolve() + let useCase = SignInUseCase( + signInService: signInService, + authRepository: authRepository, + userRepository: userRepository + ) + let viewModel = LoginViewModel(signInUseCase: useCase) + return LoginViewController(viewModel: viewModel) } } diff --git a/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+MyPage.swift b/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+MyPage.swift index 642a26b..e638830 100644 --- a/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+MyPage.swift +++ b/WhereAreYou/WhereAreYou/Core/DI/Implementation/ScreenFactory+MyPage.swift @@ -11,6 +11,9 @@ extension ScreenFactory { let viewModel = MyPageViewModel( observeLocationPermissionUseCase: ObserveLocationPermissionUseCase( repository: container.resolve(LocationPermissionRepository.self) + ), + signOutUseCase: SignOutUseCase( + authRepository: container.resolve(AuthRepository.self) ) ) return MyPageViewController(viewModel: viewModel) diff --git a/WhereAreYou/WhereAreYou/Data/AppleSignInProvider.swift b/WhereAreYou/WhereAreYou/Data/AppleSignInProvider.swift new file mode 100644 index 0000000..d3e129d --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/AppleSignInProvider.swift @@ -0,0 +1,146 @@ +// +// AppleSignInProvider.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-06. +// + +import Foundation +import AuthenticationServices +import CryptoKit + +/// SignInService의 Apple Sign In 구현체 — nonce 관리 및 Apple 인증 흐름 처리 +final class AppleSignInProvider: NSObject, SignInService { + + private let windowProvider: () -> ASPresentationAnchor? + private var currentNonce: String? + private var continuation: CheckedContinuation? + private var authController: ASAuthorizationController? + + init(windowProvider: @escaping () -> ASPresentationAnchor?) { + self.windowProvider = windowProvider + } + + // MARK: - SignInService + + func signIn() async throws -> SignInCredential { + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + + let nonce = Self.randomNonceString() + self.currentNonce = nonce + + DispatchQueue.main.async { [self] in + let request = ASAuthorizationAppleIDProvider().createRequest() + request.requestedScopes = [.fullName] + request.nonce = Self.sha256(nonce) + + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + self.authController = controller + controller.performRequests() + } + } + } + +} + +// MARK: - ASAuthorizationControllerDelegate + +extension AppleSignInProvider: ASAuthorizationControllerDelegate { + + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization + ) { + guard let appleCredential = authorization.credential as? ASAuthorizationAppleIDCredential, + let idTokenData = appleCredential.identityToken, + let idToken = String(data: idTokenData, encoding: .utf8), + let nonce = currentNonce else { + consumeContinuation { $0.resume(throwing: AppError.notAuthenticated) } + return + } + + let nickname = [appleCredential.fullName?.familyName, appleCredential.fullName?.givenName] + .compactMap { $0 } + .joined() + + let credential = SignInCredential( + idToken: idToken, + nonce: nonce, + nickname: nickname.isEmpty ? nil : nickname + ) + consumeContinuation { $0.resume(returning: credential) } + } + + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithError error: Error + ) { + let code = (error as NSError).code + if code == ASAuthorizationError.canceled.rawValue { + consumeContinuation { $0.resume(throwing: SignInError.cancelled) } + } else { + consumeContinuation { $0.resume(throwing: AppError.unknown(error)) } + } + } + + /// continuation을 nil로 교체한 뒤 resume — 비정상적 이중 콜백 시 중복 resume 방지 + private func consumeContinuation( + _ body: (CheckedContinuation) -> Void + ) { + guard let captured = continuation else { return } + continuation = nil + authController = nil + body(captured) + } + +} + +// MARK: - ASAuthorizationControllerPresentationContextProviding + +extension AppleSignInProvider: ASAuthorizationControllerPresentationContextProviding { + + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + guard let window = windowProvider() else { + preconditionFailure("로그인 시점에 window가 존재해야 합니다") + } + return window + } + +} + +// MARK: - Nonce 생성 + +private extension AppleSignInProvider { + + static func randomNonceString(length: Int = 32) -> String { + let charset = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._") + let maxValue = (256 / charset.count) * charset.count + + var result = [Character]() + result.reserveCapacity(length) + + while result.count < length { + var randomBytes = [UInt8](repeating: 0, count: 16) + let status = SecRandomCopyBytes(kSecRandomDefault, randomBytes.count, &randomBytes) + precondition(status == errSecSuccess) + + for byte in randomBytes where result.count < length { + if Int(byte) < maxValue { + result.append(charset[Int(byte) % charset.count]) + } + } + } + + return String(result) + } + + static func sha256(_ input: String) -> String { + let data = Data(input.utf8) + let hash = SHA256.hash(data: data) + return hash.compactMap { String(format: "%02x", $0) }.joined() + } + +} diff --git a/WhereAreYou/WhereAreYou/Data/FirebaseAuthRepository.swift b/WhereAreYou/WhereAreYou/Data/FirebaseAuthRepository.swift new file mode 100644 index 0000000..120f68b --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/FirebaseAuthRepository.swift @@ -0,0 +1,36 @@ +// +// FirebaseAuthRepository.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-03. +// + +import Foundation +import FirebaseAuth + +/// AuthRepository의 Firebase Auth 구현체 +final class FirebaseAuthRepository: AuthRepository { + + var currentUserID: String? { + Auth.auth().currentUser?.uid + } + + func signIn(idToken: String, nonce: String) async throws -> String { + let credential = OAuthProvider.appleCredential( + withIDToken: idToken, + rawNonce: nonce, + fullName: nil + ) + do { + let authResult = try await Auth.auth().signIn(with: credential) + return authResult.user.uid + } catch { + throw FirebaseErrorMapper.map(error) + } + } + + func signOut() throws { + try Auth.auth().signOut() + } + +} diff --git a/WhereAreYou/WhereAreYou/Data/FirestoreUserRepository.swift b/WhereAreYou/WhereAreYou/Data/FirestoreUserRepository.swift new file mode 100644 index 0000000..f014b9a --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/FirestoreUserRepository.swift @@ -0,0 +1,87 @@ +// +// FirestoreUserRepository.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-03. +// + +import Foundation +import FirebaseFirestore + +/// UserRepository의 Firestore 구현체 +final class FirestoreUserRepository: UserRepository { + + private let db = Firestore.firestore() + + func createUserIfNeeded(userID: String, nickname: String) async throws -> User { + let docRef = db.collection("users").document(userID) + + do { + let result = try await db.runTransaction { transaction, errorPointer in + let snapshot: DocumentSnapshot + do { + snapshot = try transaction.getDocument(docRef) + } catch { + errorPointer?.pointee = error as NSError + return nil + } + + if let data = snapshot.data(), snapshot.exists, + let dto = UserDTO(data: data) { + return dto.toDomain(id: userID) + } + + let userData: [String: Any] = [ + "nickname": nickname, + // TODO: Firebase Storage 전환 시 실제 URL로 교체 + "profileImage": "basic", + "defaultTransportMode": "TRANSIT", + "locationSharingScope": "ONLY_DURING_APPOINTMENT", + "isNotificationEnabled": true, + "appointmentsNotification": [String: Bool](), + "createdAt": FieldValue.serverTimestamp() + ] + + transaction.setData(userData, forDocument: docRef) + + return User( + id: userID, + nickname: nickname, + // TODO: Firebase Storage 전환 시 실제 URL로 교체 + profileImage: URL(string: "basic")!, + defaultTransportMode: .transit, + locationSharingScope: .onlyDuringAppointment, + isNotificationEnabled: true, + appointmentsNotification: [:] + ) + } + + guard let user = result as? User else { + throw AppError.notFound + } + return user + } catch let error as AppError { + throw error + } catch { + throw FirebaseErrorMapper.map(error) + } + } + + func fetchUser(userID: String) async throws -> User { + do { + let snapshot = try await db.collection("users").document(userID).getDocument() + + guard let data = snapshot.data(), snapshot.exists, + let dto = UserDTO(data: data) else { + throw AppError.notFound + } + return dto.toDomain(id: userID) + } catch let error as AppError { + throw error + } catch { + throw FirebaseErrorMapper.map(error) + } + } + + +} diff --git a/WhereAreYou/WhereAreYou/Data/UserDTO.swift b/WhereAreYou/WhereAreYou/Data/UserDTO.swift new file mode 100644 index 0000000..73d2cda --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/UserDTO.swift @@ -0,0 +1,62 @@ +// +// UserDTO.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-10. +// + +import Foundation + +/// Firestore users 문서를 Domain User로 변환하는 DTO +struct UserDTO { + + let nickname: String + let profileImage: String + let defaultTransportMode: String? + let locationSharingScope: String? + let isNotificationEnabled: Bool + let appointmentsNotification: [String: Bool] + + init?(data: [String: Any]) { + guard let nickname = data["nickname"] as? String, + let profileImage = data["profileImage"] as? String else { + return nil + } + self.nickname = nickname + self.profileImage = profileImage + self.defaultTransportMode = data["defaultTransportMode"] as? String + self.locationSharingScope = data["locationSharingScope"] as? String + self.isNotificationEnabled = data["isNotificationEnabled"] as? Bool ?? true + self.appointmentsNotification = data["appointmentsNotification"] as? [String: Bool] ?? [:] + } + + func toDomain(id: String) -> User { + let transportMode: TransportType = { + switch defaultTransportMode { + case "WALK": return .walk + case "CAR": return .car + default: return .transit + } + }() + + let sharingScope: LocationSharingScope = { + switch locationSharingScope { + case "ALWAYS": return .always + case "NEVER": return .never + default: return .onlyDuringAppointment + } + }() + + return User( + id: id, + nickname: nickname, + // TODO: Firebase Storage 전환 시 실제 URL로 교체 + profileImage: URL(string: profileImage)!, + defaultTransportMode: transportMode, + locationSharingScope: sharingScope, + isNotificationEnabled: isNotificationEnabled, + appointmentsNotification: appointmentsNotification + ) + } + +} diff --git a/WhereAreYou/WhereAreYou/Domain/Entity/AppError.swift b/WhereAreYou/WhereAreYou/Domain/Entity/AppError.swift index 712da3c..3631135 100644 --- a/WhereAreYou/WhereAreYou/Domain/Entity/AppError.swift +++ b/WhereAreYou/WhereAreYou/Domain/Entity/AppError.swift @@ -8,7 +8,7 @@ import Foundation /// 앱 공통 에러 타입 — Firebase 등 외부 에러를 앱 수준으로 변환한 결과 -enum AppError: Error { +enum AppError: LocalizedError { case network case notAuthenticated @@ -17,4 +17,21 @@ enum AppError: Error { case alreadyExists case unknown(Error) + var errorDescription: String? { + switch self { + case .network: + return "네트워크 연결을 확인해 주세요." + case .notAuthenticated: + return "인증에 실패했습니다. 다시 시도해 주세요." + case .permissionDenied: + return "접근 권한이 없습니다." + case .notFound: + return "요청한 정보를 찾을 수 없습니다." + case .alreadyExists: + return "이미 존재하는 데이터입니다." + case .unknown: + return "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + } + } + } diff --git a/WhereAreYou/WhereAreYou/Domain/Repository/AuthRepository.swift b/WhereAreYou/WhereAreYou/Domain/Repository/AuthRepository.swift new file mode 100644 index 0000000..9507253 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/Repository/AuthRepository.swift @@ -0,0 +1,19 @@ +// +// AuthRepository.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-03. +// + +import Foundation + +/// 인증 저장소 — Apple 로그인, 로그아웃, 현재 로그인 상태 확인 +protocol AuthRepository { + + var currentUserID: String? { get } + + func signIn(idToken: String, nonce: String) async throws -> String + + func signOut() throws + +} diff --git a/WhereAreYou/WhereAreYou/Domain/Repository/UserRepository.swift b/WhereAreYou/WhereAreYou/Domain/Repository/UserRepository.swift new file mode 100644 index 0000000..34c3775 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/Repository/UserRepository.swift @@ -0,0 +1,17 @@ +// +// UserRepository.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-03. +// + +import Foundation + +/// 사용자 저장소 — Firestore 유저 문서 생성, 조회 +protocol UserRepository { + + func createUserIfNeeded(userID: String, nickname: String) async throws -> User + + func fetchUser(userID: String) async throws -> User + +} diff --git a/WhereAreYou/WhereAreYou/Domain/Service/SignInCredential.swift b/WhereAreYou/WhereAreYou/Domain/Service/SignInCredential.swift new file mode 100644 index 0000000..4bc9b5a --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/Service/SignInCredential.swift @@ -0,0 +1,17 @@ +// +// SignInCredential.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-06. +// + +import Foundation + +/// 로그인 결과에서 추출한 인증 정보 +struct SignInCredential { + + let idToken: String + let nonce: String + let nickname: String? + +} diff --git a/WhereAreYou/WhereAreYou/Domain/Service/SignInError.swift b/WhereAreYou/WhereAreYou/Domain/Service/SignInError.swift new file mode 100644 index 0000000..5590dac --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/Service/SignInError.swift @@ -0,0 +1,15 @@ +// +// SignInError.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-06. +// + +import Foundation + +/// 로그인 흐름 중 발생할 수 있는 에러 +enum SignInError: Error { + + case cancelled + +} diff --git a/WhereAreYou/WhereAreYou/Domain/Service/SignInService.swift b/WhereAreYou/WhereAreYou/Domain/Service/SignInService.swift new file mode 100644 index 0000000..b3db0ea --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/Service/SignInService.swift @@ -0,0 +1,15 @@ +// +// SignInService.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-06. +// + +import Foundation + +/// 로그인 서비스 — 인증 제공자에 독립적인 로그인 추상화 +protocol SignInService { + + func signIn() async throws -> SignInCredential + +} diff --git a/WhereAreYou/WhereAreYou/Domain/UseCase/SignInUseCase.swift b/WhereAreYou/WhereAreYou/Domain/UseCase/SignInUseCase.swift new file mode 100644 index 0000000..6b7e6f7 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/UseCase/SignInUseCase.swift @@ -0,0 +1,39 @@ +// +// SignInUseCase.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-06. +// + +import Foundation + +/// 로그인 유스케이스 — 인증 → 유저 문서 생성 흐름을 조합 +final class SignInUseCase { + + private let signInService: SignInService + private let authRepository: AuthRepository + private let userRepository: UserRepository + + init( + signInService: SignInService, + authRepository: AuthRepository, + userRepository: UserRepository + ) { + self.signInService = signInService + self.authRepository = authRepository + self.userRepository = userRepository + } + + func execute() async throws -> User { + let credential = try await signInService.signIn() + let userID = try await authRepository.signIn( + idToken: credential.idToken, + nonce: credential.nonce + ) + return try await userRepository.createUserIfNeeded( + userID: userID, + nickname: credential.nickname ?? "유저" + ) + } + +} diff --git a/WhereAreYou/WhereAreYou/Domain/UseCase/SignOutUseCase.swift b/WhereAreYou/WhereAreYou/Domain/UseCase/SignOutUseCase.swift new file mode 100644 index 0000000..dc17694 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/UseCase/SignOutUseCase.swift @@ -0,0 +1,23 @@ +// +// SignOutUseCase.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-03. +// + +import Foundation + +/// 로그아웃 — Firebase Auth 세션 해제 +final class SignOutUseCase { + + private let authRepository: AuthRepository + + init(authRepository: AuthRepository) { + self.authRepository = authRepository + } + + func execute() throws { + try authRepository.signOut() + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/Login/LoginViewController.swift b/WhereAreYou/WhereAreYou/Presentation/Login/LoginViewController.swift index 2c56817..73dcdfd 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Login/LoginViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Login/LoginViewController.swift @@ -6,22 +6,27 @@ // import UIKit +import Combine /// 로그인 화면 — 앱 진입 시 최초로 표시되는 화면 final class LoginViewController: UIViewController { // MARK: - Properties - var onAppleLoginTap: (() -> Void)? + var onLoginSuccess: ((User) -> Void)? + + private let viewModel: LoginViewModel + private var cancellables = Set() // MARK: - Init - init() { + init(viewModel: LoginViewModel) { + self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + fatalError("init(coder:) has not been implemented — use init(viewModel:)") } // MARK: - UI @@ -71,6 +76,7 @@ final class LoginViewController: UIViewController { setUpView() setUpLayout() setUpActions() + bindViewModel() } // MARK: - Set Up @@ -114,9 +120,40 @@ final class LoginViewController: UIViewController { private func setUpActions() { appleLoginButton.onTap = { [weak self] in - // TODO: Apple 로그인 인증 로직 연결 필요 - self?.onAppleLoginTap?() + self?.viewModel.signIn() } } + private func bindViewModel() { + viewModel.$loginResult + .compactMap { $0 } + .sink { [weak self] result in + switch result { + case .success(let user): + self?.onLoginSuccess?(user) + case .failure(let error): + self?.showLoginError(error) + } + } + .store(in: &cancellables) + + viewModel.$isSigningIn + .sink { [weak self] isSigningIn in + self?.appleLoginButton.isUserInteractionEnabled = !isSigningIn + } + .store(in: &cancellables) + } + + // MARK: - Error + + private func showLoginError(_ error: AppError) { + let alert = UIAlertController( + title: "로그인 실패", + message: error.localizedDescription, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "확인", style: .default)) + present(alert, animated: true) + } + } diff --git a/WhereAreYou/WhereAreYou/Presentation/Login/LoginViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/Login/LoginViewModel.swift new file mode 100644 index 0000000..a528a4a --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/Login/LoginViewModel.swift @@ -0,0 +1,45 @@ +// +// LoginViewModel.swift +// WhereAreYou +// +// Created by 이상유 on 2026-09-03. +// + +import Foundation +import Combine + +/// 로그인 화면의 데이터 — UI 상태 관리 및 UseCase 위임 +@MainActor +final class LoginViewModel { + + @Published private(set) var loginResult: Result? + @Published private(set) var isSigningIn = false + + private let signInUseCase: SignInUseCase + + init(signInUseCase: SignInUseCase) { + self.signInUseCase = signInUseCase + } + + // MARK: - 로그인 + + func signIn() { + guard !isSigningIn else { return } + isSigningIn = true + + Task { + defer { isSigningIn = false } + do { + let user = try await signInUseCase.execute() + loginResult = .success(user) + } catch is SignInError { + return + } catch let error as AppError { + loginResult = .failure(error) + } catch { + loginResult = .failure(.unknown(error)) + } + } + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift index ef4be4e..1cfb83a 100644 --- a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift @@ -6,6 +6,7 @@ // import UIKit +import Combine final class MyPageViewController: UIViewController { @@ -13,6 +14,7 @@ final class MyPageViewController: UIViewController { private let viewModel: MyPageViewModel weak var coordinator: MyPageCoordinating? + private var cancellables = Set() init(viewModel: MyPageViewModel) { self.viewModel = viewModel @@ -92,12 +94,14 @@ final class MyPageViewController: UIViewController { private let etcCard = MyPageCardView() private let appInfoRow = MyPageRow(icon: UIImage(systemName: "info.circle"), title: "앱 정보") + private let signOutRow = MyPageRow(icon: UIImage(systemName: "rectangle.portrait.and.arrow.right"), title: "로그아웃") override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground setUpLayout() setUpActions() + bindViewModel() } override func viewWillAppear(_ animated: Bool) { @@ -118,7 +122,7 @@ final class MyPageViewController: UIViewController { setUpCard(profileCard, rows: [profileRow]) setUpCard(locationCard, rows: [locationSharingRow, locationPermissionRow]) setUpCard(notificationCard, rows: [notificationRow, appointmentNotificationRow]) - setUpCard(etcCard, rows: [appInfoRow]) + setUpCard(etcCard, rows: [appInfoRow, signOutRow]) [profileCard, locationCard, notificationCard, etcCard].forEach { contentStack.addArrangedSubview($0) @@ -182,6 +186,9 @@ final class MyPageViewController: UIViewController { appInfoRow.onTap = { print("앱 정보 탭") } + signOutRow.onTap = { [weak self] in + self?.confirmSignOut() + } notificationSwitch.addAction(UIAction { [weak self] _ in guard let self else { return } @@ -203,6 +210,7 @@ final class MyPageViewController: UIViewController { appointmentNotificationRow.value = "\(enabledCount)개의 약속 알림 켜짐" appInfoRow.value = "버전 정보" + signOutRow.value = "현재 계정에서 나가기" } private func presentProfileEdit() { @@ -228,6 +236,40 @@ final class MyPageViewController: UIViewController { coordinator?.showLocationPermission() } + private func bindViewModel() { + viewModel.$signOutResult + .compactMap { $0 } + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + switch result { + case .success: + self?.coordinator?.signOut() + case .failure(let error): + self?.showSignOutError(error) + } + } + .store(in: &cancellables) + } + + private func confirmSignOut() { + let alert = UIAlertController(title: "로그아웃", message: "정말 로그아웃하시겠습니까?", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "취소", style: .cancel)) + alert.addAction(UIAlertAction(title: "로그아웃", style: .destructive) { [weak self] _ in + self?.viewModel.signOut() + }) + present(alert, animated: true) + } + + private func showSignOutError(_ error: Error) { + let alert = UIAlertController( + title: "로그아웃 실패", + message: error.localizedDescription, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "확인", style: .default)) + present(alert, animated: true) + } + private func presentAppointmentNotificationList() { coordinator?.showAppointmentNotificationList( fetchItems: { [weak self] in self?.viewModel.appointmentNotifications ?? [] }, diff --git a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewModel.swift index 561062f..98b9c19 100644 --- a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewModel.swift +++ b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewModel.swift @@ -20,19 +20,35 @@ final class MyPageViewModel { private(set) var appointmentNotifications: [AppointmentListItem] = [] @Published private(set) var locationPermissionState: LocationPermissionState + @Published private(set) var signOutResult: Result? private let observeLocationPermissionUseCase: ObserveLocationPermissionUseCase + private let signOutUseCase: SignOutUseCase private var cancellables = Set() - init(profile: MyPageProfile? = nil, observeLocationPermissionUseCase: ObserveLocationPermissionUseCase) { + init( + profile: MyPageProfile? = nil, + observeLocationPermissionUseCase: ObserveLocationPermissionUseCase, + signOutUseCase: SignOutUseCase + ) { self.profile = profile ?? Self.makeDummyProfile() self.observeLocationPermissionUseCase = observeLocationPermissionUseCase + self.signOutUseCase = signOutUseCase self.locationPermissionState = LocationPermissionState(observeLocationPermissionUseCase.currentStatus) loadDummyAppointmentNotifications() bindLocationPermission() } + func signOut() { + do { + try signOutUseCase.execute() + signOutResult = .success(()) + } catch { + signOutResult = .failure(error) + } + } + } // MARK: - 프로필 diff --git a/WhereAreYou/WhereAreYou/WhereAreYou.entitlements b/WhereAreYou/WhereAreYou/WhereAreYou.entitlements new file mode 100644 index 0000000..a812db5 --- /dev/null +++ b/WhereAreYou/WhereAreYou/WhereAreYou.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.applesignin + + Default + + + diff --git a/firebase.json b/firebase.json index 8b4c91a..93b4c19 100644 --- a/firebase.json +++ b/firebase.json @@ -24,15 +24,19 @@ }, "emulators": { "auth": { + "host": "0.0.0.0", "port": 9099 }, "firestore": { + "host": "0.0.0.0", "port": 8080 }, "database": { + "host": "0.0.0.0", "port": 9000 }, "storage": { + "host": "0.0.0.0", "port": 9199 }, "ui": {