Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions jengyoon/Starbuck/Starbuck.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -85,9 +86,11 @@
F8D54BB8F383F5345CEFC4FB /* Pretendard-Bold.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Pretendard-Bold.otf"; sourceTree = "<group>"; };
F971E5B6815CB1BE429AAFDA /* CustomColor.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = CustomColor.xcassets; sourceTree = "<group>"; };
FB5860E6E14B3D180E9A7ABF /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
FF5FBBBE2DCCA35000646675 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFileSystemSynchronizedRootGroup section */
FF5FBBBB2DCC697500646675 /* Utillity */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Utillity; sourceTree = "<group>"; };
FF6F74DB2D9DAC70003079EE /* Home */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Home; sourceTree = "<group>"; };
FF6F75002D9E551F003079EE /* Home */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Home; sourceTree = "<group>"; };
FF6F75012D9E5529003079EE /* Login */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Login; sourceTree = "<group>"; };
Expand Down Expand Up @@ -159,6 +162,7 @@
3A2543C67DA0A3DAC99340AA /* Sources */ = {
isa = PBXGroup;
children = (
FF5FBBBB2DCC697500646675 /* Utillity */,
FF89C1842DA78559002BEBA9 /* Navigation */,
FF96846D2D932655009914B3 /* Components */,
BB0F89AD98F7C1411A6B3723 /* Extentions */,
Expand All @@ -167,6 +171,7 @@
D63F2DD2A4425E7FF0092A12 /* ViewModels */,
3CFD97B39B9B1F9402805C71 /* Views */,
CF9B6D9BD6286FD79B02F2D7 /* StarbuckApp.swift */,
FF5FBBBE2DCCA35000646675 /* AppDelegate.swift */,
);
path = Sources;
sourceTree = "<group>";
Expand Down Expand Up @@ -328,6 +333,7 @@
dependencies = (
);
fileSystemSynchronizedGroups = (
FF5FBBBB2DCC697500646675 /* Utillity */,
FF6F74DB2D9DAC70003079EE /* Home */,
FF6F75002D9E551F003079EE /* Home */,
FF6F75012D9E5529003079EE /* Login */,
Expand Down Expand Up @@ -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;
};
Expand Down
32 changes: 32 additions & 0 deletions jengyoon/Starbuck/Starbuck/Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -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")
}
24 changes: 24 additions & 0 deletions jengyoon/Starbuck/Starbuck/Sources/Model/Login/KakaoToken.swift
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions jengyoon/Starbuck/Starbuck/Sources/StarbuckApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import SwiftUI

@main
struct StarbuckApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

var body: some Scene {
WindowGroup {
AppRootView()
Expand Down
66 changes: 66 additions & 0 deletions jengyoon/Starbuck/Starbuck/Sources/Utillity/KeychainWrapper.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/// ์ถ”์ฒœ ๋ฉ”๋‰ด ๋”๋ฏธ ๋ฐ์ดํ„ฐ
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AnyCancellable>()

// ์ดˆ๊ธฐํ™”: ์นด์นด์˜ค ๋กœ๊ทธ์ธ ๊ณผ์ •์—์„œ ๋ฐ›์€ 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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import SwiftUI

struct HomeView: View {
/// ๊ด€์ฐฐ ๊ฐ€๋Šฅํ•œ ๊ฐ์ฒด๋ฅผ HomeView์—์„œ ์ง์ ‘ ์ƒ์„ฑํ›„ ์†Œ์œ ํ•œ๋‹ค.
/// AppStorage์— ์ €์žฅ๋œ ๋‹‰๋„ค์ž„๊ณผ ๋”๋ฏธ๋ฐ์ดํ„ฐ ๋žœ๋”๋ง
@StateObject private var viewModel = HomeViewModel()

@State private var showAdvertisement = false
Expand Down
Loading