From 5c9cecbcd57d9b167e35ed7018b8097e19116c0c Mon Sep 17 00:00:00 2001 From: h9kwon Date: Sun, 5 Jul 2026 02:55:23 +0900 Subject: [PATCH 1/4] refactor: introduce auth/user/device-token domain models and protocols --- Siksha/Data/Repository/Repository.swift | 4 +- .../Data/Repository/RepositoryProtocol.swift | 6 +-- Siksha/Domain/Models/AppVersion.swift | 10 +++++ Siksha/Domain/Models/AuthSession.swift | 43 +++++++++++++++++++ Siksha/Domain/Models/AuthState.swift | 11 +++++ Siksha/Domain/Models/LoginCredential.swift | 22 ++++++++++ Siksha/Domain/Models/LoginProvider.swift | 12 ++++++ Siksha/Domain/Models/User.swift | 18 ++++++++ .../AppVersionRepositoryProtocol.swift | 10 +++++ .../Repository/AuthRepositoryProtocol.swift | 15 +++++++ .../DeviceTokenRepositoryProtocol.swift | 16 +++++++ .../Repository/UserRepositoryProtocol.swift | 19 ++++++++ .../Settings/RenewalSettingsViewModel.swift | 4 +- Siksha/Presentation/UserManager.swift | 4 +- Siksha/System/DI/RepositoryProvider.swift | 4 +- .../System/MenuAlarmNotificationManager.swift | 4 +- 16 files changed, 189 insertions(+), 13 deletions(-) create mode 100644 Siksha/Domain/Models/AppVersion.swift create mode 100644 Siksha/Domain/Models/AuthSession.swift create mode 100644 Siksha/Domain/Models/AuthState.swift create mode 100644 Siksha/Domain/Models/LoginCredential.swift create mode 100644 Siksha/Domain/Models/LoginProvider.swift create mode 100644 Siksha/Domain/Repository/AppVersionRepositoryProtocol.swift create mode 100644 Siksha/Domain/Repository/AuthRepositoryProtocol.swift create mode 100644 Siksha/Domain/Repository/DeviceTokenRepositoryProtocol.swift create mode 100644 Siksha/Domain/Repository/UserRepositoryProtocol.swift diff --git a/Siksha/Data/Repository/Repository.swift b/Siksha/Data/Repository/Repository.swift index a0f8d10f..f96d2722 100644 --- a/Siksha/Data/Repository/Repository.swift +++ b/Siksha/Data/Repository/Repository.swift @@ -114,7 +114,7 @@ extension Repository: CommunityRepositoryProtocol { } -extension Repository: UserRepositoryProtocol { +extension Repository: LegacyUserRepositoryProtocol { func loadUserInfo() -> AnyPublisher { let endpoint = SikshaAPI.getUserInfo return self.networkModule.request(endpoint: endpoint) @@ -134,7 +134,7 @@ extension Repository: UserRepositoryProtocol { } } -extension Repository: AuthRepositoryProtocol{ +extension Repository: LegacyAuthRepositoryProtocol { func postUserDevice(fcmToken: String)-> AnyPublisher{ let endpoint = SikshaAPI.postUserDevice(fcmToken: fcmToken) return self.networkModule.requestWithNoContent(endpoint: endpoint) diff --git a/Siksha/Data/Repository/RepositoryProtocol.swift b/Siksha/Data/Repository/RepositoryProtocol.swift index c9b5c3f9..9047bd25 100644 --- a/Siksha/Data/Repository/RepositoryProtocol.swift +++ b/Siksha/Data/Repository/RepositoryProtocol.swift @@ -8,7 +8,7 @@ import Foundation import Combine -protocol RepositoryProtocol: CommunityRepositoryProtocol, UserRepositoryProtocol, AuthRepositoryProtocol { +protocol RepositoryProtocol: CommunityRepositoryProtocol, LegacyUserRepositoryProtocol, LegacyAuthRepositoryProtocol { } @@ -34,14 +34,14 @@ protocol CommunityRepositoryProtocol { } -protocol UserRepositoryProtocol { +protocol LegacyUserRepositoryProtocol { func loadUserInfo() -> AnyPublisher func updateUserProfile(nickname: String?, image: Data?, changeToDefaultImage: Bool) -> AnyPublisher func submitVOC(comment: String, platform: String) -> AnyPublisher func deleteUser() -> AnyPublisher } -protocol AuthRepositoryProtocol{ +protocol LegacyAuthRepositoryProtocol { func postUserDevice(fcmToken: String)-> AnyPublisher func deleteUserDevice(fcmToken: String)-> AnyPublisher } diff --git a/Siksha/Domain/Models/AppVersion.swift b/Siksha/Domain/Models/AppVersion.swift new file mode 100644 index 00000000..273000f1 --- /dev/null +++ b/Siksha/Domain/Models/AppVersion.swift @@ -0,0 +1,10 @@ +// +// AppVersion.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +struct AppVersion: Equatable { + let rawValue: String +} diff --git a/Siksha/Domain/Models/AuthSession.swift b/Siksha/Domain/Models/AuthSession.swift new file mode 100644 index 00000000..0ffeea25 --- /dev/null +++ b/Siksha/Domain/Models/AuthSession.swift @@ -0,0 +1,43 @@ +// +// AuthSession.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Foundation + +struct AuthSession { + let accessToken: String + let expiresAt: Date? + let appleUserIdentifier: String? + + init( + accessToken: String, + expiresAt: Date?, + appleUserIdentifier: String? = nil + ) { + self.accessToken = accessToken + self.expiresAt = expiresAt + self.appleUserIdentifier = appleUserIdentifier + } + + func isExpired(asOf date: Date = Date()) -> Bool { + guard let expiresAt else { + return false + } + + return expiresAt <= date + } + + func needsRefresh( + asOf date: Date = Date(), + refreshWindow: TimeInterval = 15_552_000 + ) -> Bool { + guard let expiresAt, !isExpired(asOf: date) else { + return false + } + + return DateInterval(start: date, end: expiresAt).duration < refreshWindow + } +} diff --git a/Siksha/Domain/Models/AuthState.swift b/Siksha/Domain/Models/AuthState.swift new file mode 100644 index 00000000..176a2555 --- /dev/null +++ b/Siksha/Domain/Models/AuthState.swift @@ -0,0 +1,11 @@ +// +// AuthState.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +enum AuthState { + case authenticated(AuthSession) + case requiresLogin +} diff --git a/Siksha/Domain/Models/LoginCredential.swift b/Siksha/Domain/Models/LoginCredential.swift new file mode 100644 index 00000000..92d1f63d --- /dev/null +++ b/Siksha/Domain/Models/LoginCredential.swift @@ -0,0 +1,22 @@ +// +// LoginCredential.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +struct LoginCredential { + let provider: LoginProvider + let token: String + let appleUserIdentifier: String? + + init( + provider: LoginProvider, + token: String, + appleUserIdentifier: String? = nil + ) { + self.provider = provider + self.token = token + self.appleUserIdentifier = appleUserIdentifier + } +} diff --git a/Siksha/Domain/Models/LoginProvider.swift b/Siksha/Domain/Models/LoginProvider.swift new file mode 100644 index 00000000..4e1c0540 --- /dev/null +++ b/Siksha/Domain/Models/LoginProvider.swift @@ -0,0 +1,12 @@ +// +// LoginProvider.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +enum LoginProvider: String { + case kakao + case google + case apple +} diff --git a/Siksha/Domain/Models/User.swift b/Siksha/Domain/Models/User.swift index 6680b873..154b2990 100644 --- a/Siksha/Domain/Models/User.swift +++ b/Siksha/Domain/Models/User.swift @@ -25,6 +25,24 @@ struct User: Decodable { let profileUrl: String? let createdAt: Date let updatedAt: Date + + init( + id: Int, + type: String, + identity: String, + nickname: String?, + profileUrl: String?, + createdAt: Date, + updatedAt: Date + ) { + self.id = id + self.type = type + self.identity = identity + self.nickname = nickname + self.profileUrl = profileUrl + self.createdAt = createdAt + self.updatedAt = updatedAt + } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) diff --git a/Siksha/Domain/Repository/AppVersionRepositoryProtocol.swift b/Siksha/Domain/Repository/AppVersionRepositoryProtocol.swift new file mode 100644 index 00000000..2d75eb5b --- /dev/null +++ b/Siksha/Domain/Repository/AppVersionRepositoryProtocol.swift @@ -0,0 +1,10 @@ +// +// AppVersionRepositoryProtocol.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +protocol AppVersionRepositoryProtocol { + func fetchLatestAppStoreVersion() async throws -> AppVersion +} diff --git a/Siksha/Domain/Repository/AuthRepositoryProtocol.swift b/Siksha/Domain/Repository/AuthRepositoryProtocol.swift new file mode 100644 index 00000000..ccc93898 --- /dev/null +++ b/Siksha/Domain/Repository/AuthRepositoryProtocol.swift @@ -0,0 +1,15 @@ +// +// AuthRepositoryProtocol.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +protocol AuthRepositoryProtocol { + func login(with credential: LoginCredential) async throws -> AuthSession + func loginForTest() async throws -> AuthSession + func refreshAccessToken(_ accessToken: String) async throws -> AuthSession + func loadSession() -> AuthSession? + func saveSession(_ session: AuthSession) + func clearSession() +} diff --git a/Siksha/Domain/Repository/DeviceTokenRepositoryProtocol.swift b/Siksha/Domain/Repository/DeviceTokenRepositoryProtocol.swift new file mode 100644 index 00000000..4854cd13 --- /dev/null +++ b/Siksha/Domain/Repository/DeviceTokenRepositoryProtocol.swift @@ -0,0 +1,16 @@ +// +// DeviceTokenRepositoryProtocol.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +protocol DeviceTokenRepositoryProtocol { + func registerDevice(fcmToken: String) async throws + func unregisterDevice(fcmToken: String) async throws + func loadDeviceToken() -> String? + func saveDeviceToken(_ token: String) + func isDeviceTokenRegistered() -> Bool + func setDeviceTokenRegistered(_ isRegistered: Bool) + func clearDeviceToken() +} diff --git a/Siksha/Domain/Repository/UserRepositoryProtocol.swift b/Siksha/Domain/Repository/UserRepositoryProtocol.swift new file mode 100644 index 00000000..3085615a --- /dev/null +++ b/Siksha/Domain/Repository/UserRepositoryProtocol.swift @@ -0,0 +1,19 @@ +// +// UserRepositoryProtocol.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Foundation + +protocol UserRepositoryProtocol { + func fetchCurrentUser() async throws -> User + func updateProfile( + nickname: String?, + image: Data?, + changeToDefaultImage: Bool + ) async throws -> User + func submitVOC(comment: String, platform: String) async throws + func deleteAccount() async throws +} diff --git a/Siksha/Presentation/Settings/RenewalSettingsViewModel.swift b/Siksha/Presentation/Settings/RenewalSettingsViewModel.swift index 390f135a..c840ce60 100644 --- a/Siksha/Presentation/Settings/RenewalSettingsViewModel.swift +++ b/Siksha/Presentation/Settings/RenewalSettingsViewModel.swift @@ -13,8 +13,8 @@ import FirebaseMessaging class RenewalSettingsViewModel: ObservableObject { private var cancellables = Set() - private let repository: UserRepositoryProtocol = AppContainer.shared.domain.userRepository - private let authRepository:AuthRepositoryProtocol = AppContainer.shared.domain.authRepository + private let repository: LegacyUserRepositoryProtocol = AppContainer.shared.domain.userRepository + private let authRepository: LegacyAuthRepositoryProtocol = AppContainer.shared.domain.authRepository private let manageRestaurantsWithoutMenuVisibilityUseCase: ManageRestaurantsWithoutMenuVisibilityUseCase @Published var error: AppError? diff --git a/Siksha/Presentation/UserManager.swift b/Siksha/Presentation/UserManager.swift index 4bca6c14..c9bcda77 100644 --- a/Siksha/Presentation/UserManager.swift +++ b/Siksha/Presentation/UserManager.swift @@ -29,9 +29,9 @@ class UserManager: ObservableObject { @Published var imageData: Data? private var cancellables = Set() - private let userRepository: UserRepositoryProtocol + private let userRepository: LegacyUserRepositoryProtocol - private init(userRepository: UserRepositoryProtocol = AppContainer.shared.domain.userRepository) { + private init(userRepository: LegacyUserRepositoryProtocol = AppContainer.shared.domain.userRepository) { self.userRepository = userRepository } diff --git a/Siksha/System/DI/RepositoryProvider.swift b/Siksha/System/DI/RepositoryProvider.swift index 19604834..105d7046 100644 --- a/Siksha/System/DI/RepositoryProvider.swift +++ b/Siksha/System/DI/RepositoryProvider.swift @@ -18,11 +18,11 @@ final class RepositoryProvider { return self.repository } - var userRepository: UserRepositoryProtocol { + var userRepository: LegacyUserRepositoryProtocol { return self.repository } - var authRepository: AuthRepositoryProtocol{ + var authRepository: LegacyAuthRepositoryProtocol { return self.repository } } diff --git a/Siksha/System/MenuAlarmNotificationManager.swift b/Siksha/System/MenuAlarmNotificationManager.swift index db856cdc..7a614881 100644 --- a/Siksha/System/MenuAlarmNotificationManager.swift +++ b/Siksha/System/MenuAlarmNotificationManager.swift @@ -11,11 +11,11 @@ import FirebaseMessaging import Combine final class DefaultMenuAlarmNotificationManager: MenuAlarmNotificationManaging { - private let authRepository: AuthRepositoryProtocol + private let authRepository: LegacyAuthRepositoryProtocol private var cancellables = Set() private var isSendingFCMToken = false - init(authRepository: AuthRepositoryProtocol) { + init(authRepository: LegacyAuthRepositoryProtocol) { self.authRepository = authRepository } From d6feef1ac117e10258bb2f79aa07e298c99cbbd1 Mon Sep 17 00:00:00 2001 From: h9kwon Date: Sun, 5 Jul 2026 04:34:55 +0900 Subject: [PATCH 2/4] refactor: add auth/user data sources and repository implementations --- Siksha/Data/DTO/AppVersionDTO.swift | 16 ++++ Siksha/Data/DTO/AuthDTO.swift | 16 ++++ Siksha/Data/DTO/UserDTO.swift | 18 +++++ .../Local/AuthSessionLocalDataSource.swift | 79 +++++++++++++++++++ .../Network/AppVersionRemoteDataSource.swift | 42 ++++++++++ .../Data/Network/AuthRemoteDataSource.swift | 44 +++++++++++ Siksha/Data/Network/JWTTokenDecoder.swift | 46 +++++++++++ .../Data/Network/UserRemoteDataSource.swift | 63 +++++++++++++++ .../Repository/AppVersionRepositoryImpl.swift | 25 ++++++ .../Data/Repository/AuthRepositoryImpl.swift | 72 +++++++++++++++++ .../Data/Repository/UserRepositoryImpl.swift | 56 +++++++++++++ 11 files changed, 477 insertions(+) create mode 100644 Siksha/Data/DTO/AppVersionDTO.swift create mode 100644 Siksha/Data/DTO/AuthDTO.swift create mode 100644 Siksha/Data/DTO/UserDTO.swift create mode 100644 Siksha/Data/Local/AuthSessionLocalDataSource.swift create mode 100644 Siksha/Data/Network/AppVersionRemoteDataSource.swift create mode 100644 Siksha/Data/Network/AuthRemoteDataSource.swift create mode 100644 Siksha/Data/Network/JWTTokenDecoder.swift create mode 100644 Siksha/Data/Network/UserRemoteDataSource.swift create mode 100644 Siksha/Data/Repository/AppVersionRepositoryImpl.swift create mode 100644 Siksha/Data/Repository/AuthRepositoryImpl.swift create mode 100644 Siksha/Data/Repository/UserRepositoryImpl.swift diff --git a/Siksha/Data/DTO/AppVersionDTO.swift b/Siksha/Data/DTO/AppVersionDTO.swift new file mode 100644 index 00000000..0de98551 --- /dev/null +++ b/Siksha/Data/DTO/AppVersionDTO.swift @@ -0,0 +1,16 @@ +// +// AppVersionDTO.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Foundation + +struct AppVersionLookupResponseDTO: Decodable { + let results: [AppVersionResultDTO] +} + +struct AppVersionResultDTO: Decodable { + let version: String +} diff --git a/Siksha/Data/DTO/AuthDTO.swift b/Siksha/Data/DTO/AuthDTO.swift new file mode 100644 index 00000000..0c19b0f6 --- /dev/null +++ b/Siksha/Data/DTO/AuthDTO.swift @@ -0,0 +1,16 @@ +// +// AuthDTO.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Foundation + +struct AuthTokenResponseDTO: Decodable { + let accessToken: String + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + } +} diff --git a/Siksha/Data/DTO/UserDTO.swift b/Siksha/Data/DTO/UserDTO.swift new file mode 100644 index 00000000..7c5dfb6b --- /dev/null +++ b/Siksha/Data/DTO/UserDTO.swift @@ -0,0 +1,18 @@ +// +// UserDTO.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Foundation + +struct UserDTO: Decodable { + let id: Int + let type: String + let identity: String + let nickname: String? + let profileUrl: String? + let createdAt: Date + let updatedAt: Date +} diff --git a/Siksha/Data/Local/AuthSessionLocalDataSource.swift b/Siksha/Data/Local/AuthSessionLocalDataSource.swift new file mode 100644 index 00000000..11382016 --- /dev/null +++ b/Siksha/Data/Local/AuthSessionLocalDataSource.swift @@ -0,0 +1,79 @@ +// +// AuthSessionLocalDataSource.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Foundation + +protocol AuthSessionLocalDataSource { + func loadSession() -> AuthSession? + func saveSession(_ session: AuthSession) + func clearSession() +} + +final class AuthSessionLocalDataSourceImpl: AuthSessionLocalDataSource { + private enum Key { + static let accessToken = "accessToken" + static let tokenExpDate = "tokenExpDate" + static let appleUserIdentifier = "appleUserIdentifier" + static let signedInWithApple = "signedInWithApple" + } + + private let userDefaults: UserDefaults + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + func loadSession() -> AuthSession? { + guard let accessToken = userDefaults.string(forKey: Key.accessToken) else { + return nil + } + + let expiresAt = expirationDate() + let appleUserIdentifier = userDefaults.bool(forKey: Key.signedInWithApple) + ? userDefaults.string(forKey: Key.appleUserIdentifier) + : nil + + return AuthSession( + accessToken: accessToken, + expiresAt: expiresAt, + appleUserIdentifier: appleUserIdentifier + ) + } + + func saveSession(_ session: AuthSession) { + userDefaults.set(session.accessToken, forKey: Key.accessToken) + + if let expiresAt = session.expiresAt { + userDefaults.set(expiresAt.timeIntervalSince1970, forKey: Key.tokenExpDate) + } else { + userDefaults.removeObject(forKey: Key.tokenExpDate) + } + + if let appleUserIdentifier = session.appleUserIdentifier { + userDefaults.set(appleUserIdentifier, forKey: Key.appleUserIdentifier) + userDefaults.set(true, forKey: Key.signedInWithApple) + } else { + userDefaults.removeObject(forKey: Key.appleUserIdentifier) + userDefaults.set(false, forKey: Key.signedInWithApple) + } + } + + func clearSession() { + userDefaults.removeObject(forKey: Key.accessToken) + userDefaults.removeObject(forKey: Key.tokenExpDate) + userDefaults.removeObject(forKey: Key.appleUserIdentifier) + userDefaults.removeObject(forKey: Key.signedInWithApple) + } + + private func expirationDate() -> Date? { + guard let timestamp = userDefaults.object(forKey: Key.tokenExpDate) as? Double else { + return nil + } + + return Date(timeIntervalSince1970: timestamp) + } +} diff --git a/Siksha/Data/Network/AppVersionRemoteDataSource.swift b/Siksha/Data/Network/AppVersionRemoteDataSource.swift new file mode 100644 index 00000000..04454a08 --- /dev/null +++ b/Siksha/Data/Network/AppVersionRemoteDataSource.swift @@ -0,0 +1,42 @@ +// +// AppVersionRemoteDataSource.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Foundation + +protocol AppVersionRemoteDataSource { + func fetchLatestAppStoreVersion() async throws -> AppVersionLookupResponseDTO +} + +final class AppVersionRemoteDataSourceImpl: AppVersionRemoteDataSource { + private let lookupURL: URL + private let session: URLSession + + init( + lookupURL: URL = URL(string: "https://itunes.apple.com/lookup?id=1032700617")!, + session: URLSession = .shared + ) { + self.lookupURL = lookupURL + self.session = session + } + + func fetchLatestAppStoreVersion() async throws -> AppVersionLookupResponseDTO { + let (data, response) = try await session.data(from: lookupURL) + + guard let httpResponse = response as? HTTPURLResponse else { + throw NetworkError.decodingError + } + + guard 200..<300 ~= httpResponse.statusCode else { + throw NetworkError.apiError( + message: "HTTP \(httpResponse.statusCode)", + code: "\(httpResponse.statusCode)" + ) + } + + return try JSONDecoder().decode(AppVersionLookupResponseDTO.self, from: data) + } +} diff --git a/Siksha/Data/Network/AuthRemoteDataSource.swift b/Siksha/Data/Network/AuthRemoteDataSource.swift new file mode 100644 index 00000000..734d50ed --- /dev/null +++ b/Siksha/Data/Network/AuthRemoteDataSource.swift @@ -0,0 +1,44 @@ +// +// AuthRemoteDataSource.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Alamofire +import Foundation + +protocol AuthRemoteDataSource { + func login(provider: LoginProvider, token: String) async throws -> AuthTokenResponseDTO + func loginForTest() async throws -> AuthTokenResponseDTO + func refreshAccessToken(_ accessToken: String) async throws -> AuthTokenResponseDTO +} + +final class AuthRemoteDataSourceImpl: AuthRemoteDataSource { + func login(provider: LoginProvider, token: String) async throws -> AuthTokenResponseDTO { + try await AF + .request(SikshaAPI.getAccessToken(token: token, endPoint: provider.rawValue)) + .validate() + .serializingDecodable(AuthTokenResponseDTO.self) + .value + } + + func loginForTest() async throws -> AuthTokenResponseDTO { + try await AF + .request(SikshaAPI.testLogin) + .validate() + .serializingDecodable(AuthTokenResponseDTO.self) + .value + } + + func refreshAccessToken(_ accessToken: String) async throws -> AuthTokenResponseDTO { + var request = try SikshaAPI.refreshAccessToken(token: accessToken).asURLRequest() + request.setToken(token: accessToken) + + return try await AF + .request(request) + .validate() + .serializingDecodable(AuthTokenResponseDTO.self) + .value + } +} diff --git a/Siksha/Data/Network/JWTTokenDecoder.swift b/Siksha/Data/Network/JWTTokenDecoder.swift new file mode 100644 index 00000000..dee0f4b5 --- /dev/null +++ b/Siksha/Data/Network/JWTTokenDecoder.swift @@ -0,0 +1,46 @@ +// +// JWTTokenDecoder.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Foundation + +struct JWTTokenDecoder { + func expirationDate(from token: String) -> Date? { + guard let exp = decodePayload(token)["exp"] as? Double else { + return nil + } + + return Date(timeIntervalSince1970: exp) + } + + private func decodePayload(_ token: String) -> [String: Any] { + let segments = token.components(separatedBy: ".") + guard segments.count == 3 else { + return [:] + } + + guard let data = base64URLDecode(segments[1]), + let json = try? JSONSerialization.jsonObject(with: data), + let payload = json as? [String: Any] else { + return [:] + } + + return payload + } + + private func base64URLDecode(_ value: String) -> Data? { + var base64 = value + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + let remainder = base64.count % 4 + if remainder > 0 { + base64.append(String(repeating: "=", count: 4 - remainder)) + } + + return Data(base64Encoded: base64, options: .ignoreUnknownCharacters) + } +} diff --git a/Siksha/Data/Network/UserRemoteDataSource.swift b/Siksha/Data/Network/UserRemoteDataSource.swift new file mode 100644 index 00000000..c235f3f9 --- /dev/null +++ b/Siksha/Data/Network/UserRemoteDataSource.swift @@ -0,0 +1,63 @@ +// +// UserRemoteDataSource.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Alamofire +import Foundation + +protocol UserRemoteDataSource { + func fetchCurrentUser() async throws -> UserDTO + func updateProfile( + nickname: String?, + image: Data?, + changeToDefaultImage: Bool + ) async throws -> UserDTO + func submitVOC(comment: String, platform: String) async throws + func deleteAccount() async throws +} + +final class UserRemoteDataSourceImpl: UserRemoteDataSource { + func fetchCurrentUser() async throws -> UserDTO { + try await AF + .request(SikshaAPI.getUserInfo) + .validate() + .serializingDecodable(UserDTO.self, decoder: NetworkDecoder.make()) + .value + } + + func updateProfile( + nickname: String?, + image: Data?, + changeToDefaultImage: Bool + ) async throws -> UserDTO { + let endpoint = SikshaAPI.updateUserProfile( + nickname: nickname, + image: image, + changeToDefaultImage: changeToDefaultImage + ) + + return try await AF + .upload(multipartFormData: endpoint.multipartFormData!, with: endpoint) + .validate() + .serializingDecodable(UserDTO.self, decoder: NetworkDecoder.make()) + .value + } + + func submitVOC(comment: String, platform: String) async throws { + try await validateNoContentRequest(AF.request(SikshaAPI.submitVOC(comment: comment, platform: platform))) + } + + func deleteAccount() async throws { + try await validateNoContentRequest(AF.request(SikshaAPI.deleteUser)) + } + + private func validateNoContentRequest(_ request: DataRequest) async throws { + _ = try await request + .validate() + .serializingData(emptyResponseCodes: [200, 201, 204]) + .value + } +} diff --git a/Siksha/Data/Repository/AppVersionRepositoryImpl.swift b/Siksha/Data/Repository/AppVersionRepositoryImpl.swift new file mode 100644 index 00000000..c77a8ea5 --- /dev/null +++ b/Siksha/Data/Repository/AppVersionRepositoryImpl.swift @@ -0,0 +1,25 @@ +// +// AppVersionRepositoryImpl.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Foundation + +final class AppVersionRepositoryImpl: AppVersionRepositoryProtocol { + private let remote: AppVersionRemoteDataSource + + init(remote: AppVersionRemoteDataSource) { + self.remote = remote + } + + func fetchLatestAppStoreVersion() async throws -> AppVersion { + guard let version = try await remote.fetchLatestAppStoreVersion().results.first?.version, + !version.isEmpty else { + throw NetworkError.decodingError + } + + return AppVersion(rawValue: version) + } +} diff --git a/Siksha/Data/Repository/AuthRepositoryImpl.swift b/Siksha/Data/Repository/AuthRepositoryImpl.swift new file mode 100644 index 00000000..8449800f --- /dev/null +++ b/Siksha/Data/Repository/AuthRepositoryImpl.swift @@ -0,0 +1,72 @@ +// +// AuthRepositoryImpl.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Foundation + +final class AuthRepositoryImpl: AuthRepositoryProtocol { + private let remote: AuthRemoteDataSource + private let local: AuthSessionLocalDataSource + private let tokenDecoder: JWTTokenDecoder + + init( + remote: AuthRemoteDataSource, + local: AuthSessionLocalDataSource, + tokenDecoder: JWTTokenDecoder = JWTTokenDecoder() + ) { + self.remote = remote + self.local = local + self.tokenDecoder = tokenDecoder + } + + func login(with credential: LoginCredential) async throws -> AuthSession { + let response = try await remote.login( + provider: credential.provider, + token: credential.token + ) + + return makeSession( + accessToken: response.accessToken, + appleUserIdentifier: credential.appleUserIdentifier + ) + } + + func loginForTest() async throws -> AuthSession { + let response = try await remote.loginForTest() + return makeSession(accessToken: response.accessToken) + } + + func refreshAccessToken(_ accessToken: String) async throws -> AuthSession { + let response = try await remote.refreshAccessToken(accessToken) + return makeSession( + accessToken: response.accessToken, + appleUserIdentifier: local.loadSession()?.appleUserIdentifier + ) + } + + func loadSession() -> AuthSession? { + local.loadSession() + } + + func saveSession(_ session: AuthSession) { + local.saveSession(session) + } + + func clearSession() { + local.clearSession() + } + + private func makeSession( + accessToken: String, + appleUserIdentifier: String? = nil + ) -> AuthSession { + AuthSession( + accessToken: accessToken, + expiresAt: tokenDecoder.expirationDate(from: accessToken), + appleUserIdentifier: appleUserIdentifier + ) + } +} diff --git a/Siksha/Data/Repository/UserRepositoryImpl.swift b/Siksha/Data/Repository/UserRepositoryImpl.swift new file mode 100644 index 00000000..59790ab4 --- /dev/null +++ b/Siksha/Data/Repository/UserRepositoryImpl.swift @@ -0,0 +1,56 @@ +// +// UserRepositoryImpl.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Foundation + +final class UserRepositoryImpl: UserRepositoryProtocol { + private let remote: UserRemoteDataSource + + init(remote: UserRemoteDataSource) { + self.remote = remote + } + + func fetchCurrentUser() async throws -> User { + try await remote.fetchCurrentUser().toDomain() + } + + func updateProfile( + nickname: String?, + image: Data?, + changeToDefaultImage: Bool + ) async throws -> User { + try await remote + .updateProfile( + nickname: nickname, + image: image, + changeToDefaultImage: changeToDefaultImage + ) + .toDomain() + } + + func submitVOC(comment: String, platform: String) async throws { + try await remote.submitVOC(comment: comment, platform: platform) + } + + func deleteAccount() async throws { + try await remote.deleteAccount() + } +} + +private extension UserDTO { + func toDomain() -> User { + User( + id: id, + type: type, + identity: identity, + nickname: nickname, + profileUrl: profileUrl, + createdAt: createdAt, + updatedAt: updatedAt + ) + } +} From fc78d44591432de2bdd57538cc5880b3ed4c3c5a Mon Sep 17 00:00:00 2001 From: h9kwon Date: Sun, 5 Jul 2026 05:29:36 +0900 Subject: [PATCH 3/4] refactor: add login/user/app-version use cases --- .../FetchAppStoreVersionUseCase.swift | 22 ++++++++++ Siksha/Domain/UseCase/Auth/LoginUseCase.swift | 31 ++++++++++++++ .../Auth/RefreshAccessTokenUseCase.swift | 28 +++++++++++++ .../User/FetchCurrentUserUseCase.swift | 22 ++++++++++ .../UseCase/User/SubmitVOCUseCase.swift | 22 ++++++++++ .../User/UpdateUserProfileUseCase.swift | 36 ++++++++++++++++ Siksha/System/DI/UseCaseProvider.swift | 41 +++++++++++++++++++ 7 files changed, 202 insertions(+) create mode 100644 Siksha/Domain/UseCase/AppVersion/FetchAppStoreVersionUseCase.swift create mode 100644 Siksha/Domain/UseCase/Auth/LoginUseCase.swift create mode 100644 Siksha/Domain/UseCase/Auth/RefreshAccessTokenUseCase.swift create mode 100644 Siksha/Domain/UseCase/User/FetchCurrentUserUseCase.swift create mode 100644 Siksha/Domain/UseCase/User/SubmitVOCUseCase.swift create mode 100644 Siksha/Domain/UseCase/User/UpdateUserProfileUseCase.swift diff --git a/Siksha/Domain/UseCase/AppVersion/FetchAppStoreVersionUseCase.swift b/Siksha/Domain/UseCase/AppVersion/FetchAppStoreVersionUseCase.swift new file mode 100644 index 00000000..9a3d6e3c --- /dev/null +++ b/Siksha/Domain/UseCase/AppVersion/FetchAppStoreVersionUseCase.swift @@ -0,0 +1,22 @@ +// +// FetchAppStoreVersionUseCase.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +protocol FetchAppStoreVersionUseCase { + func execute() async throws -> AppVersion +} + +final class DefaultFetchAppStoreVersionUseCase: FetchAppStoreVersionUseCase { + private let repository: AppVersionRepositoryProtocol + + init(repository: AppVersionRepositoryProtocol) { + self.repository = repository + } + + func execute() async throws -> AppVersion { + try await repository.fetchLatestAppStoreVersion() + } +} diff --git a/Siksha/Domain/UseCase/Auth/LoginUseCase.swift b/Siksha/Domain/UseCase/Auth/LoginUseCase.swift new file mode 100644 index 00000000..6c7c2a97 --- /dev/null +++ b/Siksha/Domain/UseCase/Auth/LoginUseCase.swift @@ -0,0 +1,31 @@ +// +// LoginUseCase.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +protocol LoginUseCase { + func login(with credential: LoginCredential) async throws -> AuthSession + func loginForTest() async throws -> AuthSession +} + +final class DefaultLoginUseCase: LoginUseCase { + private let repository: AuthRepositoryProtocol + + init(repository: AuthRepositoryProtocol) { + self.repository = repository + } + + func login(with credential: LoginCredential) async throws -> AuthSession { + let session = try await repository.login(with: credential) + repository.saveSession(session) + return session + } + + func loginForTest() async throws -> AuthSession { + let session = try await repository.loginForTest() + repository.saveSession(session) + return session + } +} diff --git a/Siksha/Domain/UseCase/Auth/RefreshAccessTokenUseCase.swift b/Siksha/Domain/UseCase/Auth/RefreshAccessTokenUseCase.swift new file mode 100644 index 00000000..efa0a76c --- /dev/null +++ b/Siksha/Domain/UseCase/Auth/RefreshAccessTokenUseCase.swift @@ -0,0 +1,28 @@ +// +// RefreshAccessTokenUseCase.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +protocol RefreshAccessTokenUseCase { + func execute() async throws -> AuthSession? +} + +final class DefaultRefreshAccessTokenUseCase: RefreshAccessTokenUseCase { + private let repository: AuthRepositoryProtocol + + init(repository: AuthRepositoryProtocol) { + self.repository = repository + } + + func execute() async throws -> AuthSession? { + guard let currentSession = repository.loadSession() else { + return nil + } + + let refreshedSession = try await repository.refreshAccessToken(currentSession.accessToken) + repository.saveSession(refreshedSession) + return refreshedSession + } +} diff --git a/Siksha/Domain/UseCase/User/FetchCurrentUserUseCase.swift b/Siksha/Domain/UseCase/User/FetchCurrentUserUseCase.swift new file mode 100644 index 00000000..065a2dd5 --- /dev/null +++ b/Siksha/Domain/UseCase/User/FetchCurrentUserUseCase.swift @@ -0,0 +1,22 @@ +// +// FetchCurrentUserUseCase.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +protocol FetchCurrentUserUseCase { + func execute() async throws -> User +} + +final class DefaultFetchCurrentUserUseCase: FetchCurrentUserUseCase { + private let repository: UserRepositoryProtocol + + init(repository: UserRepositoryProtocol) { + self.repository = repository + } + + func execute() async throws -> User { + try await repository.fetchCurrentUser() + } +} diff --git a/Siksha/Domain/UseCase/User/SubmitVOCUseCase.swift b/Siksha/Domain/UseCase/User/SubmitVOCUseCase.swift new file mode 100644 index 00000000..0e5d823c --- /dev/null +++ b/Siksha/Domain/UseCase/User/SubmitVOCUseCase.swift @@ -0,0 +1,22 @@ +// +// SubmitVOCUseCase.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +protocol SubmitVOCUseCase { + func execute(comment: String, platform: String) async throws +} + +final class DefaultSubmitVOCUseCase: SubmitVOCUseCase { + private let repository: UserRepositoryProtocol + + init(repository: UserRepositoryProtocol) { + self.repository = repository + } + + func execute(comment: String, platform: String) async throws { + try await repository.submitVOC(comment: comment, platform: platform) + } +} diff --git a/Siksha/Domain/UseCase/User/UpdateUserProfileUseCase.swift b/Siksha/Domain/UseCase/User/UpdateUserProfileUseCase.swift new file mode 100644 index 00000000..f53536de --- /dev/null +++ b/Siksha/Domain/UseCase/User/UpdateUserProfileUseCase.swift @@ -0,0 +1,36 @@ +// +// UpdateUserProfileUseCase.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Foundation + +protocol UpdateUserProfileUseCase { + func execute( + nickname: String?, + image: Data?, + changeToDefaultImage: Bool + ) async throws -> User +} + +final class DefaultUpdateUserProfileUseCase: UpdateUserProfileUseCase { + private let repository: UserRepositoryProtocol + + init(repository: UserRepositoryProtocol) { + self.repository = repository + } + + func execute( + nickname: String?, + image: Data?, + changeToDefaultImage: Bool + ) async throws -> User { + try await repository.updateProfile( + nickname: nickname, + image: image, + changeToDefaultImage: changeToDefaultImage + ) + } +} diff --git a/Siksha/System/DI/UseCaseProvider.swift b/Siksha/System/DI/UseCaseProvider.swift index c5dac739..764b30cb 100644 --- a/Siksha/System/DI/UseCaseProvider.swift +++ b/Siksha/System/DI/UseCaseProvider.swift @@ -6,6 +6,12 @@ // final class UseCaseProvider { + let loginUseCase: LoginUseCase + let refreshAccessTokenUseCase: RefreshAccessTokenUseCase + let fetchCurrentUserUseCase: FetchCurrentUserUseCase + let updateUserProfileUseCase: UpdateUserProfileUseCase + let submitVOCUseCase: SubmitVOCUseCase + let fetchAppStoreVersionUseCase: FetchAppStoreVersionUseCase let manageMenuFiltersUseCase: ManageMenuFiltersUseCase let manageRestaurantsWithoutMenuVisibilityUseCase: ManageRestaurantsWithoutMenuVisibilityUseCase let manageFestivalPreferencesUseCase: ManageFestivalPreferencesUseCase @@ -38,6 +44,12 @@ final class UseCaseProvider { let updateMenuAlarmTimeUseCase: UpdateMenuAlarmTimeUseCase init( + loginUseCase: LoginUseCase? = nil, + refreshAccessTokenUseCase: RefreshAccessTokenUseCase? = nil, + fetchCurrentUserUseCase: FetchCurrentUserUseCase? = nil, + updateUserProfileUseCase: UpdateUserProfileUseCase? = nil, + submitVOCUseCase: SubmitVOCUseCase? = nil, + fetchAppStoreVersionUseCase: FetchAppStoreVersionUseCase? = nil, manageMenuFiltersUseCase: ManageMenuFiltersUseCase? = nil, manageRestaurantsWithoutMenuVisibilityUseCase: ManageRestaurantsWithoutMenuVisibilityUseCase? = nil, manageFestivalPreferencesUseCase: ManageFestivalPreferencesUseCase? = nil, @@ -99,7 +111,36 @@ final class UseCaseProvider { remote: MyLikedMenuRemoteDataSourceImpl(), local: UserDefaultsMenuAlarmLocalDataSource() ) + let authSessionLocalDataSource = AuthSessionLocalDataSourceImpl() + let authRepository = AuthRepositoryImpl( + remote: AuthRemoteDataSourceImpl(), + local: authSessionLocalDataSource + ) + let userRepository = UserRepositoryImpl( + remote: UserRemoteDataSourceImpl() + ) + let appVersionRepository = AppVersionRepositoryImpl( + remote: AppVersionRemoteDataSourceImpl() + ) + self.loginUseCase = loginUseCase ?? DefaultLoginUseCase( + repository: authRepository + ) + self.refreshAccessTokenUseCase = refreshAccessTokenUseCase ?? DefaultRefreshAccessTokenUseCase( + repository: authRepository + ) + self.fetchCurrentUserUseCase = fetchCurrentUserUseCase ?? DefaultFetchCurrentUserUseCase( + repository: userRepository + ) + self.updateUserProfileUseCase = updateUserProfileUseCase ?? DefaultUpdateUserProfileUseCase( + repository: userRepository + ) + self.submitVOCUseCase = submitVOCUseCase ?? DefaultSubmitVOCUseCase( + repository: userRepository + ) + self.fetchAppStoreVersionUseCase = fetchAppStoreVersionUseCase ?? DefaultFetchAppStoreVersionUseCase( + repository: appVersionRepository + ) self.manageMenuFiltersUseCase = manageMenuFiltersUseCase ?? DefaultManageMenuFiltersUseCase( repository: userPreferenceRepository ) From b342181cae461234e6bc4293e5ed3f054923ffd2 Mon Sep 17 00:00:00 2001 From: h9kwon Date: Sun, 5 Jul 2026 21:50:04 +0900 Subject: [PATCH 4/4] refactor(auth): extract social login handling into SocialLoginService --- Siksha/Data/External/SocialLoginService.swift | 213 ++++++++++++++++++ Siksha/Presentation/LoginView.swift | 108 ++++----- Siksha/Presentation/LoginViewModel.swift | 162 +++++-------- Siksha/System/DI/AppContainer.swift | 2 + Siksha/System/SceneDelegate.swift | 9 +- 5 files changed, 316 insertions(+), 178 deletions(-) create mode 100644 Siksha/Data/External/SocialLoginService.swift diff --git a/Siksha/Data/External/SocialLoginService.swift b/Siksha/Data/External/SocialLoginService.swift new file mode 100644 index 00000000..c6e78bd2 --- /dev/null +++ b/Siksha/Data/External/SocialLoginService.swift @@ -0,0 +1,213 @@ +// +// SocialLoginService.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import AuthenticationServices +import Foundation +import GoogleSignIn +import KakaoSDKAuth +import KakaoSDKUser +import UIKit + +protocol SocialLoginService: AnyObject { + func login( + provider: LoginProvider, + presentingViewController: UIViewController? + ) async throws -> LoginCredential + @MainActor + func handleOpenURL(_ url: URL) -> Bool +} + +enum SocialLoginServiceError: Error { + case missingPresentingViewController + case missingCredential +} + +final class SocialLoginServiceImpl: SocialLoginService { + private var appleAuthorizationCoordinator: AppleAuthorizationCoordinator? + + func login( + provider: LoginProvider, + presentingViewController: UIViewController? + ) async throws -> LoginCredential { + switch provider { + case .kakao: + return try await loginWithKakao() + case .google: + return try await loginWithGoogle(presentingViewController: presentingViewController) + case .apple: + return try await loginWithApple(presentingViewController: presentingViewController) + } + } + + @MainActor + func handleOpenURL(_ url: URL) -> Bool { + if AuthApi.isKakaoTalkLoginUrl(url) { + return AuthController.handleOpenUrl(url: url) + } + + return GIDSignIn.sharedInstance.handle(url) + } + + private func loginWithKakao() async throws -> LoginCredential { + let oauthToken = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let completion: (OAuthToken?, Error?) -> Void = { oauthToken, error in + if let error { + continuation.resume(throwing: error) + return + } + + guard let oauthToken else { + continuation.resume(throwing: SocialLoginServiceError.missingCredential) + return + } + + continuation.resume(returning: oauthToken) + } + + if UserApi.isKakaoTalkLoginAvailable() { + UserApi.shared.loginWithKakaoTalk(completion: completion) + } else { + UserApi.shared.loginWithKakaoAccount(completion: completion) + } + } + + return LoginCredential(provider: .kakao, token: oauthToken.accessToken) + } + + private func loginWithGoogle( + presentingViewController: UIViewController? + ) async throws -> LoginCredential { + guard let presentingViewController else { + throw SocialLoginServiceError.missingPresentingViewController + } + + let signInResult = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + GIDSignIn.sharedInstance.signIn(withPresenting: presentingViewController) { signInResult, error in + if let error { + continuation.resume(throwing: error) + return + } + + guard let signInResult else { + continuation.resume(throwing: SocialLoginServiceError.missingCredential) + return + } + + continuation.resume(returning: signInResult) + } + } + + guard let token = signInResult.user.idToken?.tokenString else { + throw SocialLoginServiceError.missingCredential + } + + return LoginCredential(provider: .google, token: token) + } + + private func loginWithApple( + presentingViewController: UIViewController? + ) async throws -> LoginCredential { + let coordinator = AppleAuthorizationCoordinator( + presentingViewController: presentingViewController + ) + appleAuthorizationCoordinator = coordinator + defer { + appleAuthorizationCoordinator = nil + } + + return try await coordinator.perform() + } +} + +private final class AppleAuthorizationCoordinator: NSObject { + private weak var presentingViewController: UIViewController? + private var continuation: CheckedContinuation? + + init(presentingViewController: UIViewController?) { + self.presentingViewController = presentingViewController + } + + func perform() async throws -> LoginCredential { + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + + let provider = ASAuthorizationAppleIDProvider() + let request = provider.createRequest() + request.requestedScopes = [.fullName, .email] + + let authorizationController = ASAuthorizationController( + authorizationRequests: [request] + ) + authorizationController.delegate = self + authorizationController.presentationContextProvider = self + authorizationController.performRequests() + } + } + + private func complete(with result: Result) { + guard let continuation else { + return + } + + self.continuation = nil + + switch result { + case .success(let credential): + continuation.resume(returning: credential) + case .failure(let error): + continuation.resume(throwing: error) + } + } +} + +extension AppleAuthorizationCoordinator: ASAuthorizationControllerDelegate { + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization + ) { + guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential, + let tokenData = appleIDCredential.identityToken, + let token = String(data: tokenData, encoding: .utf8) else { + complete(with: .failure(SocialLoginServiceError.missingCredential)) + return + } + + complete( + with: .success( + LoginCredential( + provider: .apple, + token: token, + appleUserIdentifier: appleIDCredential.user + ) + ) + ) + } + + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithError error: Error + ) { + complete(with: .failure(error)) + } +} + +extension AppleAuthorizationCoordinator: ASAuthorizationControllerPresentationContextProviding { + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + if let window = presentingViewController?.view.window { + return window + } + + if let keyWindow = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .flatMap(\.windows) + .first(where: \.isKeyWindow) { + return keyWindow + } + + return ASPresentationAnchor() + } +} diff --git a/Siksha/Presentation/LoginView.swift b/Siksha/Presentation/LoginView.swift index f15fce54..d322ab30 100644 --- a/Siksha/Presentation/LoginView.swift +++ b/Siksha/Presentation/LoginView.swift @@ -6,67 +6,74 @@ // import SwiftUI -import AuthenticationServices -import KakaoSDKAuth -import KakaoSDKUser -import GoogleSignIn +import UIKit struct LoginView: View { @Environment(\.viewController) private var viewControllerHolder: UIViewController? - - @ObservedObject var viewModel = LoginViewModel() - + + @StateObject private var viewModel: LoginViewModel + + init( + viewModel: LoginViewModel = LoginViewModel( + loginUseCase: AppContainer.shared.useCases.loginUseCase, + socialLoginService: AppContainer.shared.socialLoginService + ) + ) { + _viewModel = StateObject(wrappedValue: viewModel) + } + var body: some View { GeometryReader { geometry in ZStack { VStack { Spacer() - + Image(.Logos.sikshaSplash) .resizable() .frame(width: 85.5, height: 49.5) - + Spacer() - + VStack(spacing: 10) { - Button(action : { - handleKakaoLogin() - }){ + Button(action: { + login(provider: .kakao) + }) { Image(.Images.Login.kakaoButton) .frame(width: 300, height: 45) .foregroundColor(.black) .cornerRadius(5.5) } - - Button(action : { - handleGoogleLogin() - }){ + + Button(action: { + login(provider: .google) + }) { Image(.Images.Login.googleButton) .frame(width: 300, height: 45) .foregroundColor(.black) .cornerRadius(5.5) } - + Button(action: { - handleAppleLogin() + login(provider: .apple) }, label: { Image(.Images.Login.appleButton) .frame(width: 300, height: 45) .foregroundColor(.black) .cornerRadius(5.5) }) - + #if DEBUG - + Button(action: { - viewModel.requestTestLogin() + loginForTest() }, label: { Text("테스트 로그인") }) - + #endif } - + .disabled(viewModel.isLoggingIn) + Spacer() } } @@ -82,50 +89,23 @@ struct LoginView: View { } .edgesIgnoringSafeArea(.all) } - - func handleAppleLogin() { - let appleIDProvider = ASAuthorizationAppleIDProvider() - let request = appleIDProvider.createRequest() - request.requestedScopes = [.fullName, .email] - - let authorizationController = ASAuthorizationController(authorizationRequests: [request]) - - authorizationController.delegate = viewModel - authorizationController.performRequests() - } - - func handleKakaoLogin() { - // checks whether KakaoTalk is installed - if (UserApi.isKakaoTalkLoginAvailable()) { - UserApi.shared.loginWithKakaoTalk(completion: self.handleKakaoLoginResponse) - } else { - // Login through Safari - UserApi.shared.loginWithKakaoAccount(completion: self.handleKakaoLoginResponse) - } - } - - func handleKakaoLoginResponse(oauthToken: OAuthToken?, error: Error?) { - if let oauthToken = oauthToken { - viewModel.kakaoIdToken = oauthToken.accessToken - } else { - viewModel.signInFailed = true + + private func login(provider: LoginProvider) { + Task { + await viewModel.login( + provider: provider, + presentingViewController: viewControllerHolder + ) } } - - func handleGoogleLogin() { - if let viewController = viewControllerHolder { - GIDSignIn.sharedInstance.signIn(withPresenting: viewController) { signInResult, error in - guard let result = signInResult, - let token = result.user.idToken?.tokenString else { - viewModel.signInFailed = true - return - } - viewModel.googleIdToken = token - } + + private func loginForTest() { + Task { + await viewModel.loginForTest() } } - - func presentMenu() { + + private func presentMenu() { let appState = AppState() viewControllerHolder?.present(style: .fullScreen) { ContentView().environmentObject(appState) diff --git a/Siksha/Presentation/LoginViewModel.swift b/Siksha/Presentation/LoginViewModel.swift index 187f25b0..7c94b670 100644 --- a/Siksha/Presentation/LoginViewModel.swift +++ b/Siksha/Presentation/LoginViewModel.swift @@ -6,117 +6,67 @@ // import Foundation -import SwiftyJSON -import GoogleSignIn -import AuthenticationServices -import Combine +import UIKit + +final class LoginViewModel: ObservableObject { + private let loginUseCase: LoginUseCase + private let socialLoginService: SocialLoginService -@MainActor -class LoginViewModel: NSObject, ObservableObject, ASAuthorizationControllerDelegate { - private var cancellables = Set() - - @Published var kakaoIdToken: String? = nil - @Published var googleIdToken: String? = nil @Published var signInFailed: Bool = false - + @Published private(set) var isLoggingIn: Bool = false + var onSignedIn: () -> Void = {} - - override init() { - super.init() - - $kakaoIdToken - .sink { [weak self] token in - guard let self = self, let token = token else { - return - } - - self.authenticateWithKakao(token: token) - } - .store(in: &cancellables) - - $googleIdToken - .sink { [weak self] token in - guard let self = self, let token = token else { - return - } - - self.authenticateWithGoogle(idToken: token) - } - .store(in: &cancellables) - } - - func requestTokenToSikshaAPI(token: String, endPoint: String) { - #if DEBUG - print(token) - #endif - - Networking.shared.getAccessToken(token: token, endPoint: endPoint) - .sink { result in - guard let data = result.value, - let accessToken = try? JSON(data: data)["access_token"].stringValue, - let expDate = Utils.shared.decode(jwtToken: accessToken)["exp"] as? Double else { - self.signInFailed = true - return - } - - #if DEBUG - print(expDate) - print(accessToken) - #endif - - UserDefaults.standard.set(expDate, forKey: "tokenExpDate") - UserDefaults.standard.set(accessToken, forKey: "accessToken") - - self.onSignedIn() - } - .store(in: &cancellables) - } - - func requestTestLogin() { - Networking.shared.testLogin() - .sink { result in - guard let data = result.value, - let accessToken = try? JSON(data: data)["access_token"].stringValue else { - self.signInFailed = true - return - } - - UserDefaults.standard.set(accessToken, forKey: "accessToken") - - self.onSignedIn() - } - .store(in: &cancellables) - } - - // MARK: - Google Sign in - - func authenticateWithGoogle(idToken: String) { - requestTokenToSikshaAPI(token: idToken, endPoint: "google") + + init( + loginUseCase: LoginUseCase, + socialLoginService: SocialLoginService + ) { + self.loginUseCase = loginUseCase + self.socialLoginService = socialLoginService } - - // MARK: - Kakao Sign in - - func authenticateWithKakao(token: String) { - requestTokenToSikshaAPI(token: token, endPoint: "kakao") + + @MainActor + func login( + provider: LoginProvider, + presentingViewController: UIViewController? + ) async { + guard !isLoggingIn else { + return + } + + isLoggingIn = true + defer { + isLoggingIn = false + } + + do { + let credential = try await socialLoginService.login( + provider: provider, + presentingViewController: presentingViewController + ) + _ = try await loginUseCase.login(with: credential) + onSignedIn() + } catch { + signInFailed = true + } } - - // MARK: - Apple Sign in - - func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { - switch authorization.credential { - case let appleIDCredential as ASAuthorizationAppleIDCredential: - // Create an account in your system. - let userIdentifier = appleIDCredential.user - let token = appleIDCredential.identityToken - - UserDefaults.standard.set(userIdentifier, forKey: "appleUserIdentifier") - UserDefaults.standard.set(true, forKey: "signedInWithApple") - - print(String(decoding: token!, as: UTF8.self)) - - requestTokenToSikshaAPI(token: String(decoding: token!, as: UTF8.self), endPoint: "apple") - default: - break + + @MainActor + func loginForTest() async { + guard !isLoggingIn else { + return + } + + isLoggingIn = true + defer { + isLoggingIn = false + } + + do { + _ = try await loginUseCase.loginForTest() + onSignedIn() + } catch { + signInFailed = true } } } diff --git a/Siksha/System/DI/AppContainer.swift b/Siksha/System/DI/AppContainer.swift index c39f57d8..a8c1d540 100644 --- a/Siksha/System/DI/AppContainer.swift +++ b/Siksha/System/DI/AppContainer.swift @@ -10,6 +10,7 @@ final class AppContainer { let domain: RepositoryProvider let useCases: UseCaseProvider + let socialLoginService: SocialLoginService let menuAlarmNotificationManager: DefaultMenuAlarmNotificationManager init() { @@ -19,6 +20,7 @@ final class AppContainer { self.domain = domain self.useCases = UseCaseProvider() + self.socialLoginService = SocialLoginServiceImpl() self.menuAlarmNotificationManager = DefaultMenuAlarmNotificationManager( authRepository: domain.authRepository ) diff --git a/Siksha/System/SceneDelegate.swift b/Siksha/System/SceneDelegate.swift index 8dccba77..50c103bc 100644 --- a/Siksha/System/SceneDelegate.swift +++ b/Siksha/System/SceneDelegate.swift @@ -8,8 +8,6 @@ import UIKit import SwiftUI import AuthenticationServices -import KakaoSDKAuth -import GoogleSignIn class SceneDelegate: UIResponder, UIWindowSceneDelegate { @@ -17,11 +15,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { if let url = URLContexts.first?.url { - if (AuthApi.isKakaoTalkLoginUrl(url)) { - _ = AuthController.handleOpenUrl(url: url) - } else { - _ = GIDSignIn.sharedInstance.handle(url) - } + _ = AppContainer.shared.socialLoginService.handleOpenURL(url) } } @@ -105,4 +99,3 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } -