From df603674256c639fcc0df09b826869a5b1c56824 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Wed, 26 Aug 2026 03:33:51 +0900 Subject: [PATCH 01/21] =?UTF-8?q?CHORE:=20=EC=8B=A4=EA=B8=B0=EA=B8=B0=20+?= =?UTF-8?q?=20Firebase=20=EC=97=90=EB=AE=AC=EB=A0=88=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B0=80=EB=8A=A5=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WhereAreYou/WhereAreYou/Core/AppDelegate.swift | 11 +++++++---- firebase.json | 4 ++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Core/AppDelegate.swift b/WhereAreYou/WhereAreYou/Core/AppDelegate.swift index f667e40..e8e7bd3 100644 --- a/WhereAreYou/WhereAreYou/Core/AppDelegate.swift +++ b/WhereAreYou/WhereAreYou/Core/AppDelegate.swift @@ -26,16 +26,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } + private static let emulatorHost = "192.168.45.166" + private func configureFirebaseEmulators() { #if DEBUG - Auth.auth().useEmulator(withHost: "localhost", port: 9099) + let host = Self.emulatorHost + Auth.auth().useEmulator(withHost: host, port: 9099) 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/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": { From 81563823026daff43ba6973b9426e7e9b0c8ea4f Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Tue, 8 Sep 2026 01:35:38 +0900 Subject: [PATCH 02/21] =?UTF-8?q?FEAT:=20=EC=9D=B8=EC=A6=9D/=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=86=A0=EC=BD=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/Repository/AuthRepository.swift | 19 +++++++++++++++++++ .../Domain/Repository/UserRepository.swift | 17 +++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/Domain/Repository/AuthRepository.swift create mode 100644 WhereAreYou/WhereAreYou/Domain/Repository/UserRepository.swift 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 + +} From 05822049f4f7eb59524b87168bb60fbff3a08ae7 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Tue, 8 Sep 2026 01:35:45 +0900 Subject: [PATCH 03/21] =?UTF-8?q?FEAT:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94=EC=83=81=ED=99=94=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/Service/SignInCredential.swift | 17 +++++++++++++++++ .../Domain/Service/SignInError.swift | 15 +++++++++++++++ .../Domain/Service/SignInService.swift | 15 +++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/Domain/Service/SignInCredential.swift create mode 100644 WhereAreYou/WhereAreYou/Domain/Service/SignInError.swift create mode 100644 WhereAreYou/WhereAreYou/Domain/Service/SignInService.swift 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 + +} From 1f23f8ff8f489855d8d6cd411503708d4e5a10eb Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Tue, 8 Sep 2026 01:35:52 +0900 Subject: [PATCH 04/21] =?UTF-8?q?FEAT:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8/?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20=EC=9C=A0=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/UseCase/SignInUseCase.swift | 39 +++++++++++++++++++ .../Domain/UseCase/SignOutUseCase.swift | 23 +++++++++++ 2 files changed, 62 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/Domain/UseCase/SignInUseCase.swift create mode 100644 WhereAreYou/WhereAreYou/Domain/UseCase/SignOutUseCase.swift 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() + } + +} From dc8c6c626ae889dd499042faf3126fab38c32600 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Tue, 8 Sep 2026 01:35:59 +0900 Subject: [PATCH 05/21] =?UTF-8?q?FEAT:=20Firebase=20Auth=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/FirebaseAuthRepository.swift | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/Data/FirebaseAuthRepository.swift 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() + } + +} From ad670ee9021199f7222c980f517e6f34f1018752 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Tue, 8 Sep 2026 01:36:21 +0900 Subject: [PATCH 06/21] =?UTF-8?q?FEAT:=20Firestore=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=86=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/FirestoreUserRepository.swift | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/Data/FirestoreUserRepository.swift diff --git a/WhereAreYou/WhereAreYou/Data/FirestoreUserRepository.swift b/WhereAreYou/WhereAreYou/Data/FirestoreUserRepository.swift new file mode 100644 index 0000000..97f375e --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/FirestoreUserRepository.swift @@ -0,0 +1,122 @@ +// +// 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 user = self.makeUser(id: userID, data: data) { + return user + } + + 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 user = makeUser(id: userID, data: data) else { + throw AppError.notFound + } + return user + } catch let error as AppError { + throw error + } catch { + throw FirebaseErrorMapper.map(error) + } + } + + // MARK: - Firestore → User 변환 + + private func makeUser(id: String, data: [String: Any]) -> User? { + guard let nickname = data["nickname"] as? String, + let profileImageString = data["profileImage"] as? String else { + return nil + } + + let transportMode: TransportType = { + switch data["defaultTransportMode"] as? String { + case "WALK": return .walk + case "CAR": return .car + default: return .transit + } + }() + + let sharingScope: LocationSharingScope = { + switch data["locationSharingScope"] as? String { + case "ALWAYS": return .always + case "NEVER": return .never + default: return .onlyDuringAppointment + } + }() + + return User( + id: id, + nickname: nickname, + // TODO: Firebase Storage 전환 시 실제 URL로 교체 + profileImage: URL(string: profileImageString)!, + defaultTransportMode: transportMode, + locationSharingScope: sharingScope, + isNotificationEnabled: data["isNotificationEnabled"] as? Bool ?? true, + appointmentsNotification: data["appointmentsNotification"] as? [String: Bool] ?? [:] + ) + } + +} From cdce39a518f8c7afb238e2b515a9eac8107dfe56 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Tue, 8 Sep 2026 01:36:29 +0900 Subject: [PATCH 07/21] =?UTF-8?q?FEAT:=20Apple=20Sign=20In=20=EC=A0=9C?= =?UTF-8?q?=EA=B3=B5=EC=9E=90=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/AppleSignInProvider.swift | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/Data/AppleSignInProvider.swift diff --git a/WhereAreYou/WhereAreYou/Data/AppleSignInProvider.swift b/WhereAreYou/WhereAreYou/Data/AppleSignInProvider.swift new file mode 100644 index 0000000..7885df8 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/AppleSignInProvider.swift @@ -0,0 +1,132 @@ +// +// 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 + ) { + defer { cleanUp() } + + guard let appleCredential = authorization.credential as? ASAuthorizationAppleIDCredential, + let idTokenData = appleCredential.identityToken, + let idToken = String(data: idTokenData, encoding: .utf8), + let nonce = currentNonce else { + continuation?.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 + ) + continuation?.resume(returning: credential) + } + + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithError error: Error + ) { + defer { cleanUp() } + + let code = (error as NSError).code + if code == ASAuthorizationError.canceled.rawValue { + continuation?.resume(throwing: SignInError.cancelled) + } else { + continuation?.resume(throwing: AppError.unknown(error)) + } + } + + private func cleanUp() { + continuation = nil + authController = nil + } + +} + +// 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 { + var randomBytes = [UInt8](repeating: 0, count: length) + let status = SecRandomCopyBytes(kSecRandomDefault, randomBytes.count, &randomBytes) + precondition(status == errSecSuccess) + + let charset = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._") + return String(randomBytes.map { charset[Int($0) % charset.count] }) + } + + 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() + } + +} From 2f44e0847c591877a9e813e542fbd80d6a396b03 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Tue, 8 Sep 2026 01:36:36 +0900 Subject: [PATCH 08/21] =?UTF-8?q?FEAT:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20Vie?= =?UTF-8?q?wModel=20=EB=B0=8F=20ViewController=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Login/LoginViewController.swift | 47 +++++++++++++++++-- .../Presentation/Login/LoginViewModel.swift | 40 ++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 WhereAreYou/WhereAreYou/Presentation/Login/LoginViewModel.swift diff --git a/WhereAreYou/WhereAreYou/Presentation/Login/LoginViewController.swift b/WhereAreYou/WhereAreYou/Presentation/Login/LoginViewController.swift index cb9f9b9..dd854c7 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Login/LoginViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Login/LoginViewController.swift @@ -6,13 +6,17 @@ // 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: - UI @@ -54,6 +58,17 @@ final class LoginViewController: UIViewController { private let appleLoginButton: AppleLoginButton = AppleLoginButton() + // MARK: - Init + + init(viewModel: LoginViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented — use init(viewModel:)") + } + // MARK: - Life Cycle override func viewDidLoad() { @@ -61,6 +76,7 @@ final class LoginViewController: UIViewController { setUpView() setUpLayout() setUpActions() + bindViewModel() } // MARK: - Set Up @@ -104,9 +120,34 @@ 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) + } + + // MARK: - Error + + private func showLoginError(_ error: AppError) { + let alert = UIAlertController( + title: "로그인 실패", + message: "다시 시도해 주세요.", + 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..d5e2e2e --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/Login/LoginViewModel.swift @@ -0,0 +1,40 @@ +// +// 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? + + private let signInUseCase: SignInUseCase + + init(signInUseCase: SignInUseCase) { + self.signInUseCase = signInUseCase + } + + // MARK: - 로그인 + + func signIn() { + Task { + 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)) + } + } + } + +} From 34763f9b0e06fb06e1207ccb27eff244e53011d1 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Tue, 8 Sep 2026 01:36:44 +0900 Subject: [PATCH 09/21] =?UTF-8?q?FEAT:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EB=93=B1=EB=A1=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/DI/AppDIContainer+Register.swift | 14 ++++++++ .../WhereAreYou/Core/SceneDelegate.swift | 32 +++++++++++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Core/DI/AppDIContainer+Register.swift b/WhereAreYou/WhereAreYou/Core/DI/AppDIContainer+Register.swift index b0a9a0e..474d391 100644 --- a/WhereAreYou/WhereAreYou/Core/DI/AppDIContainer+Register.swift +++ b/WhereAreYou/WhereAreYou/Core/DI/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/SceneDelegate.swift b/WhereAreYou/WhereAreYou/Core/SceneDelegate.swift index aae2210..ff51a80 100644 --- a/WhereAreYou/WhereAreYou/Core/SceneDelegate.swift +++ b/WhereAreYou/WhereAreYou/Core/SceneDelegate.swift @@ -16,16 +16,44 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { window = UIWindow(windowScene: windowScene) window?.rootViewController = makeRootLoginViewController() window?.makeKeyAndVisible() + + attemptAutoLogin() } + // MARK: - 자동 로그인 + + private func attemptAutoLogin() { + let authRepository: AuthRepository = DIContainer.shared.resolve() + guard let userID = authRepository.currentUserID else { return } + + let userRepository: UserRepository = DIContainer.shared.resolve() + Task { @MainActor in + guard let _ = try? await userRepository.fetchUser(userID: userID) else { return } + switchToHome() + } + } + + // MARK: - 로그인 화면 생성 + private func makeRootLoginViewController() -> UIViewController { - let loginViewController = LoginViewController() - loginViewController.onAppleLoginTap = { [weak self] in + let signInService: SignInService = DIContainer.shared.resolve() + let authRepository: AuthRepository = DIContainer.shared.resolve() + let userRepository: UserRepository = DIContainer.shared.resolve() + let useCase = SignInUseCase( + signInService: signInService, + authRepository: authRepository, + userRepository: userRepository + ) + let viewModel = LoginViewModel(signInUseCase: useCase) + let loginViewController = LoginViewController(viewModel: viewModel) + loginViewController.onLoginSuccess = { [weak self] _ in self?.switchToHome() } return loginViewController } + // MARK: - 화면 전환 + private func switchToHome() { guard let window else { return } let tabBarController = makeRootTabBarController() From 8c600f3cceac519a35894dc9a0b8ff2bf96f7a4c Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Tue, 8 Sep 2026 01:36:51 +0900 Subject: [PATCH 10/21] =?UTF-8?q?CHORE:=20Sign=20In=20with=20Apple=20capab?= =?UTF-8?q?ility=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WhereAreYou/WhereAreYou.xcodeproj/project.pbxproj | 2 ++ WhereAreYou/WhereAreYou/WhereAreYou.entitlements | 10 ++++++++++ 2 files changed, 12 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/WhereAreYou.entitlements 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/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 + + + From b61aec91844600bd9f88c7a0fd6b6bcaab8f12d8 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Tue, 8 Sep 2026 01:36:58 +0900 Subject: [PATCH 11/21] =?UTF-8?q?CHORE:=20Firebase=20Auth=20=EC=97=90?= =?UTF-8?q?=EB=AE=AC=EB=A0=88=EC=9D=B4=ED=84=B0=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20=ED=98=B8=EC=8A=A4=ED=8A=B8=20IP=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WhereAreYou/WhereAreYou/Core/AppDelegate.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Core/AppDelegate.swift b/WhereAreYou/WhereAreYou/Core/AppDelegate.swift index e8e7bd3..7551977 100644 --- a/WhereAreYou/WhereAreYou/Core/AppDelegate.swift +++ b/WhereAreYou/WhereAreYou/Core/AppDelegate.swift @@ -26,12 +26,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } - private static let emulatorHost = "192.168.45.166" + // 로컬 네트워크 IP — 기기마다 다르므로 본인의 IP로 변경하여 사용 (터미널: ipconfig getifaddr en0) + private static let emulatorHost = "192.168.45.234" private func configureFirebaseEmulators() { #if DEBUG let host = Self.emulatorHost - Auth.auth().useEmulator(withHost: host, port: 9099) let firestoreSettings = Firestore.firestore().settings firestoreSettings.host = "\(host):8080" firestoreSettings.isSSLEnabled = false From 42d5a673d9ae1376d45059fad8b12fc9a8c1863e Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Tue, 8 Sep 2026 01:41:29 +0900 Subject: [PATCH 12/21] =?UTF-8?q?FIX:=20=EC=9E=90=EB=8F=99=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B9=9C=EB=B9=A1=EC=9E=84=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WhereAreYou/Core/SceneDelegate.swift | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Core/SceneDelegate.swift b/WhereAreYou/WhereAreYou/Core/SceneDelegate.swift index ff51a80..13d0c36 100644 --- a/WhereAreYou/WhereAreYou/Core/SceneDelegate.swift +++ b/WhereAreYou/WhereAreYou/Core/SceneDelegate.swift @@ -14,22 +14,27 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { guard let windowScene = (scene as? UIWindowScene) else { return } window = UIWindow(windowScene: windowScene) - window?.rootViewController = makeRootLoginViewController() - window?.makeKeyAndVisible() - attemptAutoLogin() + let authRepository: AuthRepository = DIContainer.shared.resolve() + if let userID = authRepository.currentUserID { + window?.rootViewController = makeRootTabBarController() + window?.makeKeyAndVisible() + validateUser(userID: userID) + } else { + window?.rootViewController = makeRootLoginViewController() + window?.makeKeyAndVisible() + } } - // MARK: - 자동 로그인 - - private func attemptAutoLogin() { - let authRepository: AuthRepository = DIContainer.shared.resolve() - guard let userID = authRepository.currentUserID else { return } + // MARK: - 자동 로그인 검증 + private func validateUser(userID: String) { let userRepository: UserRepository = DIContainer.shared.resolve() Task { @MainActor in - guard let _ = try? await userRepository.fetchUser(userID: userID) else { return } - switchToHome() + guard let _ = try? await userRepository.fetchUser(userID: userID) else { + switchToLogin() + return + } } } @@ -63,6 +68,15 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } } + private func switchToLogin() { + guard let window else { return } + let loginViewController = makeRootLoginViewController() + + UIView.transition(with: window, duration: 0.3, options: .transitionCrossDissolve) { + window.rootViewController = loginViewController + } + } + private func makeRootTabBarController() -> UITabBarController { let homeNav = UINavigationController(rootViewController: HomeViewController()) homeNav.tabBarItem = UITabBarItem( From b1a71ad4ac8ad7b36f056e33183fcbbe496f96e2 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Tue, 8 Sep 2026 01:51:03 +0900 Subject: [PATCH 13/21] =?UTF-8?q?FEAT:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WhereAreYou/Core/SceneDelegate.swift | 20 ++++++++- .../MyPage/MyPageViewController.swift | 44 ++++++++++++++++--- .../Presentation/MyPage/MyPageViewModel.swift | 12 ++++- 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Core/SceneDelegate.swift b/WhereAreYou/WhereAreYou/Core/SceneDelegate.swift index 13d0c36..2e2e984 100644 --- a/WhereAreYou/WhereAreYou/Core/SceneDelegate.swift +++ b/WhereAreYou/WhereAreYou/Core/SceneDelegate.swift @@ -57,6 +57,23 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { return loginViewController } + // MARK: - 마이페이지 화면 생성 + + private func makeMyPageViewController() -> MyPageViewController { + let authRepository: AuthRepository = DIContainer.shared.resolve() + let viewModel = MyPageViewModel( + observeLocationPermissionUseCase: ObserveLocationPermissionUseCase( + repository: DIContainer.shared.resolve(LocationPermissionRepository.self) + ), + signOutUseCase: SignOutUseCase(authRepository: authRepository) + ) + let myPageViewController = MyPageViewController(viewModel: viewModel) + myPageViewController.onSignOut = { [weak self] in + self?.switchToLogin() + } + return myPageViewController + } + // MARK: - 화면 전환 private func switchToHome() { @@ -92,7 +109,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { selectedImage: UIImage(systemName: "tray.full.fill") ) - let myPageNav = UINavigationController(rootViewController: MyPageViewController()) + let myPageViewController = makeMyPageViewController() + let myPageNav = UINavigationController(rootViewController: myPageViewController) myPageNav.tabBarItem = UITabBarItem( title: "마이페이지", image: UIImage(systemName: "person"), diff --git a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift index 28891ba..7a7137b 100644 --- a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift @@ -11,11 +11,16 @@ final class MyPageViewController: UIViewController { private static let cardSpacing: CGFloat = 16 - private let viewModel = MyPageViewModel( - observeLocationPermissionUseCase: ObserveLocationPermissionUseCase( - repository: DIContainer.shared.resolve(LocationPermissionRepository.self) - ) - ) + private let viewModel: MyPageViewModel + + init(viewModel: MyPageViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } private let logoImageView: UIImageView = { let imageView = UIImageView(image: .logo) @@ -86,6 +91,9 @@ 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: "로그아웃") + + var onSignOut: (() -> Void)? override func viewDidLoad() { super.viewDidLoad() @@ -112,7 +120,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) @@ -176,6 +184,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 } @@ -197,6 +208,7 @@ final class MyPageViewController: UIViewController { appointmentNotificationRow.value = "\(enabledCount)개의 약속 알림 켜짐" appInfoRow.value = "버전 정보" + signOutRow.value = "현재 계정에서 나가기" } private func presentProfileEdit() { @@ -225,6 +237,26 @@ final class MyPageViewController: UIViewController { navigationController?.pushViewController(permissionVC, animated: true) } + 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?.performSignOut() + }) + present(alert, animated: true) + } + + private func performSignOut() { + do { + try viewModel.signOut() + onSignOut?() + } catch { + let alert = UIAlertController(title: "로그아웃 실패", message: error.localizedDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "확인", style: .default)) + present(alert, animated: true) + } + } + private func presentAppointmentNotificationList() { let listVC = AppointmentNotificationListViewController( 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..3a73e23 100644 --- a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewModel.swift +++ b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewModel.swift @@ -22,17 +22,27 @@ final class MyPageViewModel { @Published private(set) var locationPermissionState: LocationPermissionState 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() throws { + try signOutUseCase.execute() + } + } // MARK: - 프로필 From 260aae3119ab398101a3298ebff6b62b5122c130 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Thu, 10 Sep 2026 23:33:55 +0900 Subject: [PATCH 14/21] =?UTF-8?q?FIX:=20nonce=20=EB=9E=9C=EB=8D=A4=20?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=EC=97=B4=20=EC=83=9D=EC=84=B1=20=EC=8B=9C=20?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=20=EA=B7=A0=EB=93=B1=20=EB=B6=84=ED=8F=AC=20?= =?UTF-8?q?=EB=B3=B4=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/AppleSignInProvider.swift | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Data/AppleSignInProvider.swift b/WhereAreYou/WhereAreYou/Data/AppleSignInProvider.swift index 7885df8..8b3a579 100644 --- a/WhereAreYou/WhereAreYou/Data/AppleSignInProvider.swift +++ b/WhereAreYou/WhereAreYou/Data/AppleSignInProvider.swift @@ -115,12 +115,25 @@ extension AppleSignInProvider: ASAuthorizationControllerPresentationContextProvi private extension AppleSignInProvider { static func randomNonceString(length: Int = 32) -> String { - var randomBytes = [UInt8](repeating: 0, count: length) - let status = SecRandomCopyBytes(kSecRandomDefault, randomBytes.count, &randomBytes) - precondition(status == errSecSuccess) - let charset = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._") - return String(randomBytes.map { charset[Int($0) % charset.count] }) + 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 { From bf20f50037bb4c51fff35848184bc3c09bac9964 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Thu, 10 Sep 2026 23:35:40 +0900 Subject: [PATCH 15/21] =?UTF-8?q?FIX:=20Apple=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20continuation=20=EC=9D=B4=EC=A4=91=20resume=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/AppleSignInProvider.swift | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Data/AppleSignInProvider.swift b/WhereAreYou/WhereAreYou/Data/AppleSignInProvider.swift index 8b3a579..d3e129d 100644 --- a/WhereAreYou/WhereAreYou/Data/AppleSignInProvider.swift +++ b/WhereAreYou/WhereAreYou/Data/AppleSignInProvider.swift @@ -54,13 +54,11 @@ extension AppleSignInProvider: ASAuthorizationControllerDelegate { controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization ) { - defer { cleanUp() } - guard let appleCredential = authorization.credential as? ASAuthorizationAppleIDCredential, let idTokenData = appleCredential.identityToken, let idToken = String(data: idTokenData, encoding: .utf8), let nonce = currentNonce else { - continuation?.resume(throwing: AppError.notAuthenticated) + consumeContinuation { $0.resume(throwing: AppError.notAuthenticated) } return } @@ -73,26 +71,29 @@ extension AppleSignInProvider: ASAuthorizationControllerDelegate { nonce: nonce, nickname: nickname.isEmpty ? nil : nickname ) - continuation?.resume(returning: credential) + consumeContinuation { $0.resume(returning: credential) } } func authorizationController( controller: ASAuthorizationController, didCompleteWithError error: Error ) { - defer { cleanUp() } - let code = (error as NSError).code if code == ASAuthorizationError.canceled.rawValue { - continuation?.resume(throwing: SignInError.cancelled) + consumeContinuation { $0.resume(throwing: SignInError.cancelled) } } else { - continuation?.resume(throwing: AppError.unknown(error)) + consumeContinuation { $0.resume(throwing: AppError.unknown(error)) } } } - private func cleanUp() { + /// continuation을 nil로 교체한 뒤 resume — 비정상적 이중 콜백 시 중복 resume 방지 + private func consumeContinuation( + _ body: (CheckedContinuation) -> Void + ) { + guard let captured = continuation else { return } continuation = nil authController = nil + body(captured) } } From 961512b0b1c6e44107c586603347083b618e62b1 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Thu, 10 Sep 2026 23:35:47 +0900 Subject: [PATCH 16/21] =?UTF-8?q?CHORE:=20emulatorHost=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EB=B3=80=EC=88=98=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WhereAreYou/WhereAreYou/Core/AppDelegate.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Core/AppDelegate.swift b/WhereAreYou/WhereAreYou/Core/AppDelegate.swift index 7551977..96ee880 100644 --- a/WhereAreYou/WhereAreYou/Core/AppDelegate.swift +++ b/WhereAreYou/WhereAreYou/Core/AppDelegate.swift @@ -26,8 +26,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } - // 로컬 네트워크 IP — 기기마다 다르므로 본인의 IP로 변경하여 사용 (터미널: ipconfig getifaddr en0) - private static let emulatorHost = "192.168.45.234" + private static var emulatorHost: String { + ProcessInfo.processInfo.environment["FIREBASE_EMULATOR_HOST"] ?? "localhost" + } private func configureFirebaseEmulators() { #if DEBUG From 7bc0035ae8c78adb51f157fb96dd4f874557feba Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Thu, 10 Sep 2026 23:36:19 +0900 Subject: [PATCH 17/21] =?UTF-8?q?FIX:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=9D=B4=EC=A4=91=20=ED=83=AD=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Login/LoginViewController.swift | 6 ++++++ .../WhereAreYou/Presentation/Login/LoginViewModel.swift | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/WhereAreYou/WhereAreYou/Presentation/Login/LoginViewController.swift b/WhereAreYou/WhereAreYou/Presentation/Login/LoginViewController.swift index dd854c7..0369850 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Login/LoginViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Login/LoginViewController.swift @@ -136,6 +136,12 @@ final class LoginViewController: UIViewController { } } .store(in: &cancellables) + + viewModel.$isSigningIn + .sink { [weak self] isSigningIn in + self?.appleLoginButton.isUserInteractionEnabled = !isSigningIn + } + .store(in: &cancellables) } // MARK: - Error diff --git a/WhereAreYou/WhereAreYou/Presentation/Login/LoginViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/Login/LoginViewModel.swift index d5e2e2e..a528a4a 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Login/LoginViewModel.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Login/LoginViewModel.swift @@ -13,6 +13,7 @@ import Combine final class LoginViewModel { @Published private(set) var loginResult: Result? + @Published private(set) var isSigningIn = false private let signInUseCase: SignInUseCase @@ -23,7 +24,11 @@ final class LoginViewModel { // MARK: - 로그인 func signIn() { + guard !isSigningIn else { return } + isSigningIn = true + Task { + defer { isSigningIn = false } do { let user = try await signInUseCase.execute() loginResult = .success(user) From 1b71665383302701f3042d8ef452c99d4b267e20 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Thu, 10 Sep 2026 23:37:22 +0900 Subject: [PATCH 18/21] =?UTF-8?q?FIX:=20=EC=9E=90=EB=8F=99=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EA=B2=80=EC=A6=9D=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WhereAreYou/Core/SceneDelegate.swift | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Core/SceneDelegate.swift b/WhereAreYou/WhereAreYou/Core/SceneDelegate.swift index 2e2e984..809a8b8 100644 --- a/WhereAreYou/WhereAreYou/Core/SceneDelegate.swift +++ b/WhereAreYou/WhereAreYou/Core/SceneDelegate.swift @@ -31,13 +31,33 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { private func validateUser(userID: String) { let userRepository: UserRepository = DIContainer.shared.resolve() Task { @MainActor in - guard let _ = try? await userRepository.fetchUser(userID: userID) else { + do { + _ = try await userRepository.fetchUser(userID: userID) + } catch AppError.network { + showNetworkErrorAlert { [weak self] in + self?.validateUser(userID: userID) + } + } catch { switchToLogin() - return } } } + private func showNetworkErrorAlert(retryHandler: @escaping () -> Void) { + let alert = UIAlertController( + title: "연결 오류", + message: "네트워크 연결을 확인 후 다시 시도해 주세요.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "재시도", style: .default) { _ in + retryHandler() + }) + alert.addAction(UIAlertAction(title: "로그아웃", style: .destructive) { [weak self] _ in + self?.switchToLogin() + }) + window?.rootViewController?.present(alert, animated: true) + } + // MARK: - 로그인 화면 생성 private func makeRootLoginViewController() -> UIViewController { From e8df8a772cbd0232528e1b46fc6ed15371674d8e Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Thu, 10 Sep 2026 23:37:36 +0900 Subject: [PATCH 19/21] =?UTF-8?q?REFACTOR:=20AppError=20LocalizedError=20?= =?UTF-8?q?=EC=A0=81=ED=95=A9=EC=84=B1=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=97=90=EB=9F=AC=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WhereAreYou/Domain/Entity/AppError.swift | 19 ++++++++++++++++++- .../Login/LoginViewController.swift | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) 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/Presentation/Login/LoginViewController.swift b/WhereAreYou/WhereAreYou/Presentation/Login/LoginViewController.swift index 0369850..47c6ce0 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Login/LoginViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Login/LoginViewController.swift @@ -149,7 +149,7 @@ final class LoginViewController: UIViewController { private func showLoginError(_ error: AppError) { let alert = UIAlertController( title: "로그인 실패", - message: "다시 시도해 주세요.", + message: error.localizedDescription, preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "확인", style: .default)) From 4fed34f1e986135c157235834d9dc796bace040f Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Thu, 10 Sep 2026 23:37:48 +0900 Subject: [PATCH 20/21] =?UTF-8?q?REFACTOR:=20Firestore=20User=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=EC=9D=84=20UserDTO=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/FirestoreUserRepository.swift | 43 ++----------- WhereAreYou/WhereAreYou/Data/UserDTO.swift | 62 +++++++++++++++++++ 2 files changed, 66 insertions(+), 39 deletions(-) create mode 100644 WhereAreYou/WhereAreYou/Data/UserDTO.swift diff --git a/WhereAreYou/WhereAreYou/Data/FirestoreUserRepository.swift b/WhereAreYou/WhereAreYou/Data/FirestoreUserRepository.swift index 97f375e..f014b9a 100644 --- a/WhereAreYou/WhereAreYou/Data/FirestoreUserRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/FirestoreUserRepository.swift @@ -27,8 +27,8 @@ final class FirestoreUserRepository: UserRepository { } if let data = snapshot.data(), snapshot.exists, - let user = self.makeUser(id: userID, data: data) { - return user + let dto = UserDTO(data: data) { + return dto.toDomain(id: userID) } let userData: [String: Any] = [ @@ -72,10 +72,10 @@ final class FirestoreUserRepository: UserRepository { let snapshot = try await db.collection("users").document(userID).getDocument() guard let data = snapshot.data(), snapshot.exists, - let user = makeUser(id: userID, data: data) else { + let dto = UserDTO(data: data) else { throw AppError.notFound } - return user + return dto.toDomain(id: userID) } catch let error as AppError { throw error } catch { @@ -83,40 +83,5 @@ final class FirestoreUserRepository: UserRepository { } } - // MARK: - Firestore → User 변환 - - private func makeUser(id: String, data: [String: Any]) -> User? { - guard let nickname = data["nickname"] as? String, - let profileImageString = data["profileImage"] as? String else { - return nil - } - - let transportMode: TransportType = { - switch data["defaultTransportMode"] as? String { - case "WALK": return .walk - case "CAR": return .car - default: return .transit - } - }() - - let sharingScope: LocationSharingScope = { - switch data["locationSharingScope"] as? String { - case "ALWAYS": return .always - case "NEVER": return .never - default: return .onlyDuringAppointment - } - }() - - return User( - id: id, - nickname: nickname, - // TODO: Firebase Storage 전환 시 실제 URL로 교체 - profileImage: URL(string: profileImageString)!, - defaultTransportMode: transportMode, - locationSharingScope: sharingScope, - isNotificationEnabled: data["isNotificationEnabled"] as? Bool ?? true, - appointmentsNotification: data["appointmentsNotification"] as? [String: Bool] ?? [:] - ) - } } 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 + ) + } + +} From 2c863fe493cc523d5f430017f7767e751c569d1c Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Thu, 10 Sep 2026 23:37:54 +0900 Subject: [PATCH 21/21] =?UTF-8?q?REFACTOR:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20@Published=20?= =?UTF-8?q?=EB=B0=94=EC=9D=B8=EB=94=A9=EC=9C=BC=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyPage/MyPageViewController.swift | 37 ++++++++++++++----- .../Presentation/MyPage/MyPageViewModel.swift | 10 ++++- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift index 7a7137b..0ec6887 100644 --- a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift @@ -6,12 +6,14 @@ // import UIKit +import Combine final class MyPageViewController: UIViewController { private static let cardSpacing: CGFloat = 16 private let viewModel: MyPageViewModel + private var cancellables = Set() init(viewModel: MyPageViewModel) { self.viewModel = viewModel @@ -100,6 +102,7 @@ final class MyPageViewController: UIViewController { view.backgroundColor = .systemBackground setUpLayout() setUpActions() + bindViewModel() } override func viewWillAppear(_ animated: Bool) { @@ -237,24 +240,38 @@ final class MyPageViewController: UIViewController { navigationController?.pushViewController(permissionVC, animated: true) } + private func bindViewModel() { + viewModel.$signOutResult + .compactMap { $0 } + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + switch result { + case .success: + self?.onSignOut?() + 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?.performSignOut() + self?.viewModel.signOut() }) present(alert, animated: true) } - private func performSignOut() { - do { - try viewModel.signOut() - onSignOut?() - } catch { - let alert = UIAlertController(title: "로그아웃 실패", message: error.localizedDescription, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "확인", style: .default)) - 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() { diff --git a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewModel.swift index 3a73e23..98b9c19 100644 --- a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewModel.swift +++ b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewModel.swift @@ -20,6 +20,7 @@ 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 @@ -39,8 +40,13 @@ final class MyPageViewModel { bindLocationPermission() } - func signOut() throws { - try signOutUseCase.execute() + func signOut() { + do { + try signOutUseCase.execute() + signOutResult = .success(()) + } catch { + signOutResult = .failure(error) + } } }