diff --git a/jengyoon/Starbuck/Starbuck.xcodeproj/project.pbxproj b/jengyoon/Starbuck/Starbuck.xcodeproj/project.pbxproj index 59b8dfd..ac57550 100644 --- a/jengyoon/Starbuck/Starbuck.xcodeproj/project.pbxproj +++ b/jengyoon/Starbuck/Starbuck.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ C87EE176AD207D08A51D8E1C /* Pretendard-Thin.otf in Resources */ = {isa = PBXBuildFile; fileRef = 6F9415C82BD901C6AA812805 /* Pretendard-Thin.otf */; }; D137A0CE8238989E873328EF /* Pretendard-ExtraBold.otf in Resources */ = {isa = PBXBuildFile; fileRef = D2E6CFDB2FE55DB301BB7E93 /* Pretendard-ExtraBold.otf */; }; F9B59708E65FFDC479FA292D /* Pretendard-SemiBold.otf in Resources */ = {isa = PBXBuildFile; fileRef = 1C05EE3A01621AA71D5A3474 /* Pretendard-SemiBold.otf */; }; + FF5FBBBF2DCCA35000646675 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5FBBBE2DCCA35000646675 /* AppDelegate.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -85,9 +86,11 @@ F8D54BB8F383F5345CEFC4FB /* Pretendard-Bold.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Pretendard-Bold.otf"; sourceTree = ""; }; F971E5B6815CB1BE429AAFDA /* CustomColor.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = CustomColor.xcassets; sourceTree = ""; }; FB5860E6E14B3D180E9A7ABF /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + FF5FBBBE2DCCA35000646675 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + FF5FBBBB2DCC697500646675 /* Utillity */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Utillity; sourceTree = ""; }; FF6F74DB2D9DAC70003079EE /* Home */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Home; sourceTree = ""; }; FF6F75002D9E551F003079EE /* Home */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Home; sourceTree = ""; }; FF6F75012D9E5529003079EE /* Login */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Login; sourceTree = ""; }; @@ -159,6 +162,7 @@ 3A2543C67DA0A3DAC99340AA /* Sources */ = { isa = PBXGroup; children = ( + FF5FBBBB2DCC697500646675 /* Utillity */, FF89C1842DA78559002BEBA9 /* Navigation */, FF96846D2D932655009914B3 /* Components */, BB0F89AD98F7C1411A6B3723 /* Extentions */, @@ -167,6 +171,7 @@ D63F2DD2A4425E7FF0092A12 /* ViewModels */, 3CFD97B39B9B1F9402805C71 /* Views */, CF9B6D9BD6286FD79B02F2D7 /* StarbuckApp.swift */, + FF5FBBBE2DCCA35000646675 /* AppDelegate.swift */, ); path = Sources; sourceTree = ""; @@ -328,6 +333,7 @@ dependencies = ( ); fileSystemSynchronizedGroups = ( + FF5FBBBB2DCC697500646675 /* Utillity */, FF6F74DB2D9DAC70003079EE /* Home */, FF6F75002D9E551F003079EE /* Home */, FF6F75012D9E5529003079EE /* Login */, @@ -448,6 +454,7 @@ 9F4761109412ED75AEFC6F93 /* ColorExtension.swift in Sources */, 7840DC737DE4A9321BFA6B04 /* FontManager.swift in Sources */, B2EECE92459A9FE846F262F6 /* StarbuckApp.swift in Sources */, + FF5FBBBF2DCCA35000646675 /* AppDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/jengyoon/Starbuck/Starbuck/Sources/AppDelegate.swift b/jengyoon/Starbuck/Starbuck/Sources/AppDelegate.swift new file mode 100644 index 0000000..5544437 --- /dev/null +++ b/jengyoon/Starbuck/Starbuck/Sources/AppDelegate.swift @@ -0,0 +1,32 @@ +// +// AppDelegate.swift +// Starbuck +// +// Created by 송승윤 on 5/8/25. +// + +import UIKit + +final class AppDelegate: NSObject, UIApplicationDelegate { + func application(_ app: UIApplication, open url: URL, + options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { + if url.scheme == "myapp", url.host == "oauth" { + if let code = URLComponents(string: url.absoluteString)? + .queryItems?.first(where: { $0.name == "code" })?.value { + + // 인가 코드 Notification으로 전달 + NotificationCenter.default.post( + name: .didReceiveKakaoCode, + object: nil, + userInfo: ["code": code] + ) + } + return true + } + return false + } +} + +extension Notification.Name { + static let didReceiveKakaoCode = Notification.Name("didReceieveKakaoCode") +} diff --git a/jengyoon/Starbuck/Starbuck/Sources/Model/Login/KakaoToken.swift b/jengyoon/Starbuck/Starbuck/Sources/Model/Login/KakaoToken.swift new file mode 100644 index 0000000..1ac82fd --- /dev/null +++ b/jengyoon/Starbuck/Starbuck/Sources/Model/Login/KakaoToken.swift @@ -0,0 +1,24 @@ +// +// KakaoToken.swift +// Starbuck +// +// Created by 송승윤 on 5/8/25. +// + +import Foundation + +struct KakaoToken: Decodable { + let access_token: String +} + +struct KakaoUser: Decodable { + let kakao_account: KakaoAccount +} + +struct KakaoAccount: Decodable { + let profile: KakaoProfile +} + +struct KakaoProfile: Decodable { + let nickname: String +} diff --git a/jengyoon/Starbuck/Starbuck/Sources/StarbuckApp.swift b/jengyoon/Starbuck/Starbuck/Sources/StarbuckApp.swift index ba3993c..5cc808b 100644 --- a/jengyoon/Starbuck/Starbuck/Sources/StarbuckApp.swift +++ b/jengyoon/Starbuck/Starbuck/Sources/StarbuckApp.swift @@ -2,6 +2,8 @@ import SwiftUI @main struct StarbuckApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + var body: some Scene { WindowGroup { AppRootView() diff --git a/jengyoon/Starbuck/Starbuck/Sources/Utillity/KeychainWrapper.swift b/jengyoon/Starbuck/Starbuck/Sources/Utillity/KeychainWrapper.swift new file mode 100644 index 0000000..90659c0 --- /dev/null +++ b/jengyoon/Starbuck/Starbuck/Sources/Utillity/KeychainWrapper.swift @@ -0,0 +1,66 @@ +// +// KeychainWrapper.swift +// Starbuck +// +// Created by 송승윤 on 5/8/25. +// + +import Foundation +import Security + +/// Keychain에 문자열 값을 저장, 불러오기, 삭제하는 유틸리티 +enum KeychainKey: String { + case email + case password + case nickname +} + +class KeychainWrapper { + + /// Keychain에 문자열 저장하기 + @discardableResult + static func save(_ value: String, for key: KeychainKey) -> Bool { + guard let data = value.data(using: .utf8) else { return false } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key.rawValue, + kSecValueData as String: data + ] + + // 기존 항목 삭제 후 새로 저장 (중복 방지) + SecItemDelete(query as CFDictionary) + return SecItemAdd(query as CFDictionary, nil) == errSecSuccess + } + + /// Keychain에서 문자열 불러오기 + static func load(for key: KeychainKey) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key.rawValue, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var dataTypeRef: AnyObject? + let status = SecItemCopyMatching(query as NSDictionary, &dataTypeRef) + + // 값이 존재하면 문자열로 변환하여 반환 + if status == errSecSuccess, + let data = dataTypeRef as? Data, + let result = String(data: data, encoding: .utf8) { + return result + } + + return nil + } + + /// Keychain에서 항목 삭제 + @discardableResult + static func delete(for key: KeychainKey) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key.rawValue + ] + return SecItemDelete(query as CFDictionary) == errSecSuccess + } +} diff --git a/jengyoon/Starbuck/Starbuck/Sources/ViewModels/Home/HomeViewModel.swift b/jengyoon/Starbuck/Starbuck/Sources/ViewModels/Home/HomeViewModel.swift index db59d69..002730d 100644 --- a/jengyoon/Starbuck/Starbuck/Sources/ViewModels/Home/HomeViewModel.swift +++ b/jengyoon/Starbuck/Starbuck/Sources/ViewModels/Home/HomeViewModel.swift @@ -9,12 +9,11 @@ import Foundation import SwiftUI class HomeViewModel: ObservableObject { - /// 회원가입시 저장된 닉네임 불러오기 - @AppStorage("userNickname") private var nickname: String = "" - + /// 뷰에서 접근하는 닉네임 var displayName: String { - nickname.isEmpty ? "(설정 닉네임)" : nickname + let nickname = KeychainWrapper.load(for: .nickname) ?? "" + return nickname.isEmpty ? "(설정 닉네임)" : nickname } /// 추천 메뉴 더미 데이터 diff --git a/jengyoon/Starbuck/Starbuck/Sources/ViewModels/Login/KakaoLoginViewModel.swift b/jengyoon/Starbuck/Starbuck/Sources/ViewModels/Login/KakaoLoginViewModel.swift new file mode 100644 index 0000000..b5d7ad7 --- /dev/null +++ b/jengyoon/Starbuck/Starbuck/Sources/ViewModels/Login/KakaoLoginViewModel.swift @@ -0,0 +1,82 @@ +// +// KakaoLoginViewModel.swift +// Starbuck +// +// Created by 송승윤 on 5/8/25. +// + +import Foundation +import UIKit +import Combine + +class KakaoLoginViewModel: ObservableObject { + var loginViewModel: LoginViewModel? // Login 상태를 갱신하기 위한 참조 + private var cancellables = Set() + + // 초기화: 카카오 로그인 과정에서 받은 authorization code를 처리하기 위한 NotificationCenter 구독 설정 + init() { + NotificationCenter.default.publisher(for: .didReceiveKakaoCode) + .compactMap { $0.userInfo?["code"] as? String } + .sink { [weak self] code in + self?.requestToken(with: code) // 받은 코드로 토큰 요청 시작 + } + .store(in: &cancellables) + } + + // 카카오 로그인 페이지로 이동하는 메서드 + func loginWithKakao() { + let clientID = "" + let redirectURI = "https://songtarbuck.com/oauth" + let urlStr = "https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=\(clientID)&redirect_uri=\(redirectURI)" + + // URL이 유효하면 카카오 로그인 페이지를 열어 인증 진행 + if let url = URL(string: urlStr) { + UIApplication.shared.open(url) + } + } + + // authorization code를 이용해 asccess token을 요청 + private func requestToken(with code: String) { + let url = URL(string: "")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + // 요청 파라미터 설정 + let params = [ + "grant_type": "authorization_code", + "client_id": "", + "redirect_uri": "https://songtarbuck.com/oauth", + "code": code + ] + request.httpBody = params.map { "\($0.key)=\($0.value)" } + .joined(separator: "&") + .data(using: .utf8) + + // 토큰 요청을 비동기로 수행 + URLSession.shared.dataTask(with: request) { data, _, _ in + guard let data = data, + let token = try? JSONDecoder().decode(KakaoToken.self, from: data) else { return } + // 토큰을 성공적으로 받으면 사용자 정보 요청 시작 + self.requestUserInfo(with: token.access_token) + }.resume() + } + + private func requestUserInfo(with accessToken: String) { + var request = URLRequest(url: URL(string: "https://kapi.kakao.com/v2/user/me")!) + request.httpMethod = "GET" + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + + // 사용자 정보 요청을 비동기로 수행 + URLSession.shared.dataTask(with: request) { data, _, _ in + guard let data = data, + let user = try? JSONDecoder().decode(KakaoUser.self, from: data) else { return } + + // 메인 스레드에서 로그인 상태를 갱신 + DispatchQueue.main.async { + let nickname = user.kakao_account.profile.nickname + self.loginViewModel?.loginWithKakao(nickname: nickname) + } + }.resume() + } +} diff --git a/jengyoon/Starbuck/Starbuck/Sources/ViewModels/Login/LoginViewModel.swift b/jengyoon/Starbuck/Starbuck/Sources/ViewModels/Login/LoginViewModel.swift index 38190a9..2b76e4c 100644 --- a/jengyoon/Starbuck/Starbuck/Sources/ViewModels/Login/LoginViewModel.swift +++ b/jengyoon/Starbuck/Starbuck/Sources/ViewModels/Login/LoginViewModel.swift @@ -16,16 +16,19 @@ class LoginViewModel: ObservableObject { @Published var inputEmail: String = "" @Published var inputPassword: String = "" - // MARK: - AppStorage에 저장된 회원 정보 불러오기 - @AppStorage("userEmail") private var savedEmail: String = "" - @AppStorage("userPassword") private var savedPassword: String = "" - // MARK: - 로그인 상태 @Published var isLogin: Bool = false @Published var loginError: String? = nil // MARK: - 로그인 로직 func login() { + guard let savedEmail = KeychainWrapper.load(for: .email), + let savedPassword = KeychainWrapper.load(for: .password) else { + loginError = "저장된 사용자 정보가 없습니다." + isLogin = false + return + } + if (inputEmail == savedEmail && inputPassword == savedPassword) { isLogin = true loginError = nil @@ -38,4 +41,12 @@ class LoginViewModel: ObservableObject { var buttonValid: Bool { !inputEmail.isEmpty && !inputPassword.isEmpty } + + /// 카카오 로그인 성공 시 호출되는 메서드 + /// 토큰 요청 및.사용자 정보 요청은 kakaoLovinViewModel에서 처리 + func loginWithKakao(nickname: String) { + isLogin = true + loginError = nil + KeychainWrapper.save(nickname, for: .nickname) + } } diff --git a/jengyoon/Starbuck/Starbuck/Sources/ViewModels/Login/SignupViewModel.swift b/jengyoon/Starbuck/Starbuck/Sources/ViewModels/Login/SignupViewModel.swift index f10c6f6..0a8826d 100644 --- a/jengyoon/Starbuck/Starbuck/Sources/ViewModels/Login/SignupViewModel.swift +++ b/jengyoon/Starbuck/Starbuck/Sources/ViewModels/Login/SignupViewModel.swift @@ -5,31 +5,47 @@ // Created by 송승윤 on 3/27/25. /// 회원가입 화면에서 사용하는 ViewModel -/// 사용자 입력값을 AppStorage(UserDefaults)와 연동하여 저장 +/// 사용자 입력값을 Keychain에 저장 import Foundation import SwiftUI class SignupViewModel: ObservableObject { - @AppStorage("userEmail") private var userEmail: String = "" - @AppStorage("userPassword") private var userPassword: String = "" - @AppStorage("userNickname") private var userNickname: String = "" + // 사용자 입력값 @Published var email = "" @Published var password = "" @Published var nickname = "" + + // 회원가입 완료 여부 @Published var isSignupComplete = false + /// 입력 필드 검증 로직 var isFormValid: Bool { !email.isEmpty && !password.isEmpty && !nickname.isEmpty } + /// 회원가입 처리 함수 + /// 입력값을 Keychain에 저장한다. func signup() { - // AppStorage에 사용자 정보 저장 - userEmail = email - userPassword = password - userNickname = nickname + KeychainWrapper.save(email, for: .email) + KeychainWrapper.save(password, for: .password) + KeychainWrapper.save(nickname, for: .nickname) // 회원가입 완료 처리 isSignupComplete = true } -} + + /// Keycahin에서 기존 사용자 데이터를 불러와 필드에 반영하기 + func loadSavedUserData() { + email = KeychainWrapper.load(for: .email) ?? "" + password = KeychainWrapper.load(for: .password) ?? "" + nickname = KeychainWrapper.load(for: .nickname) ?? "" + } + + /// Keychain에 저장된 사용자 정보 삭제 + func clearUserData() { + KeychainWrapper.delete(for: .email) + KeychainWrapper.delete(for: .password) + KeychainWrapper.delete(for: .nickname) + } +} diff --git a/jengyoon/Starbuck/Starbuck/Sources/Views/Home/HomeView.swift b/jengyoon/Starbuck/Starbuck/Sources/Views/Home/HomeView.swift index bd78ed3..c6eeb78 100644 --- a/jengyoon/Starbuck/Starbuck/Sources/Views/Home/HomeView.swift +++ b/jengyoon/Starbuck/Starbuck/Sources/Views/Home/HomeView.swift @@ -9,7 +9,6 @@ import SwiftUI struct HomeView: View { /// 관찰 가능한 객체를 HomeView에서 직접 생성후 소유한다. - /// AppStorage에 저장된 닉네임과 더미데이터 랜더링 @StateObject private var viewModel = HomeViewModel() @State private var showAdvertisement = false diff --git a/jengyoon/Starbuck/Starbuck/Sources/Views/Login/LoginView.swift b/jengyoon/Starbuck/Starbuck/Sources/Views/Login/LoginView.swift index a0e4d3c..a60f9da 100644 --- a/jengyoon/Starbuck/Starbuck/Sources/Views/Login/LoginView.swift +++ b/jengyoon/Starbuck/Starbuck/Sources/Views/Login/LoginView.swift @@ -19,6 +19,7 @@ struct LoginView: View { @EnvironmentObject private var router: NavigationRouter @FocusState private var focusField: Field? @StateObject private var viewModel = LoginViewModel() + @StateObject private var kakaoVM = KakaoLoginViewModel() var body: some View { ZStack { @@ -120,7 +121,11 @@ struct LoginView: View { router.navigate(to: .signup) } - SocialLoginButton(buttonColor: Color.yellow, textColor: Color.black, text: "카카오 로그인", font: .PretendardMedium16, icon: "kakao", action: {}) + SocialLoginButton(buttonColor: Color.yellow, textColor: Color.black, text: "카카오 로그인", font: .PretendardMedium16, icon: "kakao", action: { + // 카카오 버튼을 누르면 LoginViewModel과 연결하여 로그인 절차 진행 + kakaoVM.loginViewModel = viewModel + kakaoVM.loginWithKakao() + }) SocialLoginButton(buttonColor: Color.black, textColor: Color.white, text: "Apple로 로그인", font: .PretendardMedium16, icon: "apple", action: {}) diff --git a/jengyoon/Starbuck/Starbuck/Sources/Views/Other/OtherView.swift b/jengyoon/Starbuck/Starbuck/Sources/Views/Other/OtherView.swift index 662b2f6..f6ead01 100644 --- a/jengyoon/Starbuck/Starbuck/Sources/Views/Other/OtherView.swift +++ b/jengyoon/Starbuck/Starbuck/Sources/Views/Other/OtherView.swift @@ -9,8 +9,9 @@ import SwiftUI struct OtherView: View { /// 회원가입시 저장한 닉네임을 표시 - /// UserDefaults의 "nickname" 키에 저장된 값을 불러온다. - @AppStorage("nickname") private var nickname : String? + /// 키체인에서 닉네임 불러온 닉네임 상태 변수 + @State private var nickname: String = "" + @Environment(\.dismiss) private var dismiss @EnvironmentObject private var router: NavigationRouter @@ -31,8 +32,10 @@ struct OtherView: View { otherBottomView }//: VStack } //: ZStack - - + .onAppear { + // 뷰 등장 시 닉네임 로드 + nickname = KeychainWrapper.load(for: .nickname) ?? "" + } } // MARK: - Properties @@ -49,8 +52,8 @@ struct OtherView: View { dismiss() }) { Image("logout") - .resizable() - .frame(width: 35, height: 35) + .resizable() + .frame(width: 35, height: 35) } } .padding(.horizontal, 20) @@ -61,15 +64,14 @@ struct OtherView: View { private var otherTitleView: some View { VStack (spacing: 24) { Group { - if let nickname { - Text("\(nickname)") - .foregroundStyle(Color(.green01)) - + Text("님") - } - else { + if nickname.isEmpty { Text("작성한 닉네임") - .foregroundStyle(Color(.green01)) - + Text("님") + .foregroundStyle(Color(.green01)) + + Text("님") + } else { + Text(nickname) + .foregroundStyle(Color(.green01)) + + Text("님") } Text("환영합니다! 🙌") } @@ -86,42 +88,42 @@ struct OtherView: View { /// 결제 관련 버튼 뷰 /// - 버튼을 컴포넌트화 하여 재사용성 높임 - private var otherPayView: some View { - VStack() { - HStack { - Text("Pay") - .font(.PretendardSemiBold18) - .frame(height: 28) - - Spacer() - } - - Spacer().frame(height: 8) - - HStack { - OtherViewButton(text: "스타벅스 카드 등록", textColor: .black, font: .PretendardSemiBold16, icon: "other2.1", action: {print("스타벅스 카드 등록 클릭")}) - - Spacer() - - OtherViewButton(text: "카드 교환권 등록", textColor: .black, font: .PretendardSemiBold16, icon: "other2.2", action: {print("카드 교환권 클릭")}) - - Spacer().frame(width: 10) - } - .padding(.vertical, 16) - - HStack { - OtherViewButton(text: "쿠폰 등록", textColor: .black, font: .PretendardSemiBold16, icon: "other2.3", action: {print("쿠폰 등록 클릭")}) - - Spacer() - - OtherViewButton(text: "쿠폰 히스토리", textColor: .black ,font: .PretendardSemiBold16, icon: "other2.4", action: {print("쿠폰 히스토리 클릭")}) - - Spacer().frame(width: 30) - } - .padding(.vertical, 16) - } //: VStack - .padding(.horizontal, 10) - } + private var otherPayView: some View { + VStack() { + HStack { + Text("Pay") + .font(.PretendardSemiBold18) + .frame(height: 28) + + Spacer() + } + + Spacer().frame(height: 8) + + HStack { + OtherViewButton(text: "스타벅스 카드 등록", textColor: .black, font: .PretendardSemiBold16, icon: "other2.1", action: {print("스타벅스 카드 등록 클릭")}) + + Spacer() + + OtherViewButton(text: "카드 교환권 등록", textColor: .black, font: .PretendardSemiBold16, icon: "other2.2", action: {print("카드 교환권 클릭")}) + + Spacer().frame(width: 10) + } + .padding(.vertical, 16) + + HStack { + OtherViewButton(text: "쿠폰 등록", textColor: .black, font: .PretendardSemiBold16, icon: "other2.3", action: {print("쿠폰 등록 클릭")}) + + Spacer() + + OtherViewButton(text: "쿠폰 히스토리", textColor: .black ,font: .PretendardSemiBold16, icon: "other2.4", action: {print("쿠폰 히스토리 클릭")}) + + Spacer().frame(width: 30) + } + .padding(.vertical, 16) + } //: VStack + .padding(.horizontal, 10) + } /// 고객지원 뷰 /// - 버튼을 컴포넌트화 하여 재사용성 높임