diff --git a/GAMSS/Config/Debug.xcconfig b/GAMSS/Config/Debug.xcconfig
index f926c94..f1bf9f4 100644
--- a/GAMSS/Config/Debug.xcconfig
+++ b/GAMSS/Config/Debug.xcconfig
@@ -16,3 +16,7 @@ APP_NAME =
// Network Defines
BASE_URL =
+
+// Web
+TERMS_OF_SERVICE_URL =
+PRIVACY_POLICY_URL =
diff --git a/GAMSS/Config/Release.xcconfig b/GAMSS/Config/Release.xcconfig
index 7d52b9c..91b7bf3 100644
--- a/GAMSS/Config/Release.xcconfig
+++ b/GAMSS/Config/Release.xcconfig
@@ -16,3 +16,7 @@ APP_NAME =
// Network Defines
BASE_URL =
+
+// Web
+TERMS_OF_SERVICE_URL =
+PRIVACY_POLICY_URL =
diff --git a/GAMSS/Info.plist b/GAMSS/Info.plist
index c0fffca..382660a 100644
--- a/GAMSS/Info.plist
+++ b/GAMSS/Info.plist
@@ -6,6 +6,10 @@
$(APP_NAME)
BASE_URL
$(BASE_URL)
+ TERMS_OF_SERVICE_URL
+ $(TERMS_OF_SERVICE_URL)
+ PRIVACY_POLICY_URL
+ $(PRIVACY_POLICY_URL)
FIREBASE_CRASHLYTICS_COLLECTION_ENABLED
NO
UIDesignRequiresCompatibility
diff --git a/GAMSS/Resources/Assets.xcassets/emotions.imageset/Contents.json b/GAMSS/Resources/Assets.xcassets/emotions.imageset/Contents.json
new file mode 100644
index 0000000..c10899e
--- /dev/null
+++ b/GAMSS/Resources/Assets.xcassets/emotions.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "filename" : "emotions.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "emotions@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "emotions@3x.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/GAMSS/Resources/Assets.xcassets/emotions.imageset/emotions.png b/GAMSS/Resources/Assets.xcassets/emotions.imageset/emotions.png
new file mode 100644
index 0000000..6ba1018
Binary files /dev/null and b/GAMSS/Resources/Assets.xcassets/emotions.imageset/emotions.png differ
diff --git a/GAMSS/Resources/Assets.xcassets/emotions.imageset/emotions@2x.png b/GAMSS/Resources/Assets.xcassets/emotions.imageset/emotions@2x.png
new file mode 100644
index 0000000..44cc288
Binary files /dev/null and b/GAMSS/Resources/Assets.xcassets/emotions.imageset/emotions@2x.png differ
diff --git a/GAMSS/Resources/Assets.xcassets/emotions.imageset/emotions@3x.png b/GAMSS/Resources/Assets.xcassets/emotions.imageset/emotions@3x.png
new file mode 100644
index 0000000..6d1b4c6
Binary files /dev/null and b/GAMSS/Resources/Assets.xcassets/emotions.imageset/emotions@3x.png differ
diff --git a/GAMSS/Sources/App/GAMSSApp.swift b/GAMSS/Sources/App/GAMSSApp.swift
index b443764..8d9e395 100644
--- a/GAMSS/Sources/App/GAMSSApp.swift
+++ b/GAMSS/Sources/App/GAMSSApp.swift
@@ -23,8 +23,8 @@ struct GAMSSApp: App {
var body: some Scene {
WindowGroup {
- MainTabView()
- .preferredColorScheme(.light)
+ RootView()
+ .preferredColorScheme(.light)
}
// 디자인 토큰이 다크모드 값을 갖고 있지만 디자이너가 아직 다크모드 화면을 설계하지
// 않았다 — 시스템 다크모드를 그대로 따라가면 색상이 라이트/다크 목적과 반대로
diff --git a/GAMSS/Sources/Core/Components/List/MenuListItemView.swift b/GAMSS/Sources/Core/Components/List/MenuListItemView.swift
new file mode 100644
index 0000000..8cc6f4e
--- /dev/null
+++ b/GAMSS/Sources/Core/Components/List/MenuListItemView.swift
@@ -0,0 +1,60 @@
+//
+// MenuListItemView.swift
+// GAMSS
+//
+// Created by LEEGEONJUN on 8/13/26.
+//
+
+import SwiftUI
+
+struct MenuListItemView: View {
+ let title: String
+ var titleColor: Color = .colorGray950
+ var badgeText: String?
+ var trailingText: String?
+ var trailingColor: Color = .colorGray500
+
+ var body: some View {
+ HStack(spacing: Spacing.spacing100) {
+ Text(title)
+ .typography(.body3Medium)
+ .foregroundStyle(titleColor)
+
+ if let badgeText {
+ badge(badgeText)
+ }
+
+ Spacer(minLength: 0)
+
+ if let trailingText {
+ Text(trailingText)
+ .typography(.body3Medium)
+ .foregroundStyle(trailingColor)
+ }
+ }
+ .frame(height: 40)
+ .padding(.vertical, Spacing.spacing200)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ }
+
+ private func badge(_ text: String) -> some View {
+ Text(text)
+ .typography(.body6Medium)
+ .foregroundStyle(Color.colorGray900)
+ .padding(.horizontal, Spacing.spacing075)
+ .padding(.vertical, Spacing.spacing025)
+ .background(Color.colorGray200)
+ .clipShape(Capsule())
+ }
+}
+
+#Preview {
+ VStack(spacing: 0) {
+ MenuListItemView(title: "계정 정보")
+ MenuListItemView(title: "알림 설정", badgeText: "OFF")
+ MenuListItemView(title: "앱 버전", trailingText: "0.0.1")
+ MenuListItemView(title: "회원탈퇴", titleColor: .colorRed)
+ }
+ .padding(.horizontal, 18)
+}
diff --git a/GAMSS/Sources/Core/Components/Safari/SafariView.swift b/GAMSS/Sources/Core/Components/Safari/SafariView.swift
new file mode 100644
index 0000000..6e87bb6
--- /dev/null
+++ b/GAMSS/Sources/Core/Components/Safari/SafariView.swift
@@ -0,0 +1,22 @@
+//
+// SafariView.swift
+// GAMSS
+//
+// Created by 이건준 on 8/12/26.
+//
+
+import SafariServices
+import SwiftUI
+
+struct SafariView: UIViewControllerRepresentable {
+ let url: URL
+
+ func makeUIViewController(context: Context) -> SFSafariViewController {
+ SFSafariViewController(url: url)
+ }
+
+ func updateUIViewController(
+ _ uiViewController: SFSafariViewController,
+ context: Context
+ ) {}
+}
diff --git a/GAMSS/Sources/Core/Environment.swift b/GAMSS/Sources/Core/Environment.swift
index 5abc263..feecd67 100644
--- a/GAMSS/Sources/Core/Environment.swift
+++ b/GAMSS/Sources/Core/Environment.swift
@@ -25,10 +25,30 @@ public enum Environment {
return value(key: "APP_NAME")
}()
+ /// 앱 버전 (`MARKETING_VERSION` → `CFBundleShortVersionString`)
+ static let appVersion: String = {
+ guard let version = infoDictionary["CFBundleShortVersionString"] as? String,
+ !version.isEmpty
+ else {
+ return "-"
+ }
+ return version
+ }()
+
/// 서버 Base URL
static let baseURL: String = {
return value(key: "BASE_URL")
}()
+
+ /// 서비스 이용약관 URL
+ static let termsOfServiceURL: String = {
+ return value(key: "TERMS_OF_SERVICE_URL")
+ }()
+
+ /// 개인정보 처리방침 URL
+ static let privacyPolicyURL: String = {
+ return value(key: "PRIVACY_POLICY_URL")
+ }()
}
// MARK: I/O 관련 코드
diff --git a/GAMSS/Sources/Core/Network/NetworkError.swift b/GAMSS/Sources/Core/Network/NetworkError.swift
index 5af786f..14383cf 100644
--- a/GAMSS/Sources/Core/Network/NetworkError.swift
+++ b/GAMSS/Sources/Core/Network/NetworkError.swift
@@ -12,4 +12,5 @@ enum NetworkError: Error {
case invalidResponse
case httpError(statusCode: Int)
case decodingError
+ case expiredToken
}
diff --git a/GAMSS/Sources/Core/Network/NetworkManager.swift b/GAMSS/Sources/Core/Network/NetworkManager.swift
index 42d441f..fc51d7f 100644
--- a/GAMSS/Sources/Core/Network/NetworkManager.swift
+++ b/GAMSS/Sources/Core/Network/NetworkManager.swift
@@ -56,6 +56,16 @@ final class NetworkManager: NetworkRequesting {
from: data
).error
+ if apiError?.code == "EXPIRED_TOKEN" {
+ do {
+ try await TokenStorage.shared.reissueToken()
+ } catch {
+ Log.error("Token reissue failed: \(error)")
+ }
+
+ throw NetworkError.expiredToken
+ }
+
Log.error("""
❌ API Error
StatusCode: \(response.statusCode)
diff --git a/GAMSS/Sources/Core/UserManager.swift b/GAMSS/Sources/Core/UserManager.swift
new file mode 100644
index 0000000..ef1765d
--- /dev/null
+++ b/GAMSS/Sources/Core/UserManager.swift
@@ -0,0 +1,17 @@
+//
+// UserManager.swift
+// GAMSS
+//
+// Created by 이건준 on 8/13/26.
+//
+
+import Foundation
+
+@Observable
+final class UserManager {
+ static let shared = UserManager()
+
+ var user: User?
+
+ private init() {}
+}
diff --git a/GAMSS/Sources/Data/DTO/Auth/Response/ReissueTokenResponseDTO.swift b/GAMSS/Sources/Data/DTO/Auth/Response/ReissueTokenResponseDTO.swift
new file mode 100644
index 0000000..5885a13
--- /dev/null
+++ b/GAMSS/Sources/Data/DTO/Auth/Response/ReissueTokenResponseDTO.swift
@@ -0,0 +1,13 @@
+//
+// ReissueTokenResponseDTO.swift
+// GAMSS
+//
+// Created by 이건준 on 8/8/26.
+//
+
+import Foundation
+
+struct ReissueTokenResponseDTO: Decodable {
+ let accessToken: String
+ let refreshToken: String
+}
diff --git a/GAMSS/Sources/Data/Repository/DefaultAuthRepository.swift b/GAMSS/Sources/Data/Repository/DefaultAuthRepository.swift
index ff8c6fe..3468d8d 100644
--- a/GAMSS/Sources/Data/Repository/DefaultAuthRepository.swift
+++ b/GAMSS/Sources/Data/Repository/DefaultAuthRepository.swift
@@ -21,6 +21,11 @@ final class DefaultAuthRepository: AuthRepository {
self.tokenStorage = tokenStorage
}
+ func logout() async throws {
+ _ = try await networkManager.request(AuthEndpoint.logout, responseType: APIResponse.self)
+ try tokenStorage.deleteTokens()
+ }
+
func login(
with socialType: SocialType,
credential: ASAuthorizationAppleIDCredential,
diff --git a/GAMSS/Sources/Data/Storage/TokenStorage.swift b/GAMSS/Sources/Data/Storage/TokenStorage.swift
index 47d0586..bbdea8a 100644
--- a/GAMSS/Sources/Data/Storage/TokenStorage.swift
+++ b/GAMSS/Sources/Data/Storage/TokenStorage.swift
@@ -12,13 +12,14 @@ final class TokenStorage {
private init() {}
- func reissueToken() throws {
- guard LoginState.current != .notLoggedIn else {
+ func reissueToken() async throws {
+ guard let refreshToken = readToken(.refreshToken) else {
try TokenStorage.shared.deleteTokens()
return
}
- /// TODO: - accessToken, refreshToken 재발행 로직 필요
+ let response = try await NetworkManager.shared.request(AuthEndpoint.reissueToken(.init(refreshToken: refreshToken)), responseType: APIResponse.self).data
+ try createTokens(accessToken: response.accessToken, refreshToken: response.refreshToken)
}
func createTokens(accessToken: String, refreshToken: String) throws {
diff --git a/GAMSS/Sources/Domain/Entity/User.swift b/GAMSS/Sources/Domain/Entity/User.swift
new file mode 100644
index 0000000..01f22e9
--- /dev/null
+++ b/GAMSS/Sources/Domain/Entity/User.swift
@@ -0,0 +1,15 @@
+//
+// User.swift
+// GAMSS
+//
+// Created by 이건준 on 8/13/26.
+//
+
+import Foundation
+
+struct User {
+ let id: Int
+ let email: String
+ let name: String
+ let nickname: String
+}
diff --git a/GAMSS/Sources/Domain/Repository/AuthRepository.swift b/GAMSS/Sources/Domain/Repository/AuthRepository.swift
index 3b949a2..af10c86 100644
--- a/GAMSS/Sources/Domain/Repository/AuthRepository.swift
+++ b/GAMSS/Sources/Domain/Repository/AuthRepository.swift
@@ -7,11 +7,12 @@
import AuthenticationServices
-// FIXME: - 로그인 시 올바른 응답값으로 수정 필요
protocol AuthRepository {
func login(
with socialType: SocialType,
credential: ASAuthorizationAppleIDCredential,
nonce: String
- ) async throws
+ ) async throws
+
+ func logout() async throws
}
diff --git a/GAMSS/Sources/Domain/UseCase/Auth/DefaultLogoutUseCase.swift b/GAMSS/Sources/Domain/UseCase/Auth/DefaultLogoutUseCase.swift
new file mode 100644
index 0000000..c303213
--- /dev/null
+++ b/GAMSS/Sources/Domain/UseCase/Auth/DefaultLogoutUseCase.swift
@@ -0,0 +1,20 @@
+//
+// DefaultLogoutUseCase.swift
+// GAMSS
+//
+// Created by 이건준 on 8/13/26.
+//
+
+import Foundation
+
+final class DefaultLogoutUseCase: LogoutUseCase {
+ private let authRepository: AuthRepository
+
+ init(authRepository: AuthRepository) {
+ self.authRepository = authRepository
+ }
+
+ func logout() async throws {
+ try await authRepository.logout()
+ }
+}
diff --git a/GAMSS/Sources/Domain/UseCase/Auth/LogoutUseCase.swift b/GAMSS/Sources/Domain/UseCase/Auth/LogoutUseCase.swift
new file mode 100644
index 0000000..4eba096
--- /dev/null
+++ b/GAMSS/Sources/Domain/UseCase/Auth/LogoutUseCase.swift
@@ -0,0 +1,12 @@
+//
+// LogoutUseCase.swift
+// GAMSS
+//
+// Created by 이건준 on 8/13/26.
+//
+
+import Foundation
+
+protocol LogoutUseCase {
+ func logout() async throws
+}
diff --git a/GAMSS/Sources/Presentation/Home/HomeView.swift b/GAMSS/Sources/Presentation/Home/HomeView.swift
index 1c47ccc..1711140 100644
--- a/GAMSS/Sources/Presentation/Home/HomeView.swift
+++ b/GAMSS/Sources/Presentation/Home/HomeView.swift
@@ -10,6 +10,7 @@ import SwiftUI
struct HomeView: View {
@StateObject private var viewModel: HomeViewModel
@FocusState private var isInputFocused: Bool
+ @State private var isSettingPresented = false
init(viewModel: HomeViewModel) {
_viewModel = StateObject(wrappedValue: viewModel)
@@ -57,6 +58,9 @@ struct HomeView: View {
)
.toolbar(.hidden, for: .tabBar)
}
+ .navigationDestination(isPresented: $isSettingPresented) {
+ SettingView()
+ }
}
.alert(viewModel.alertMessage ?? "", isPresented: Binding(
get: { viewModel.alertMessage != nil },
@@ -75,8 +79,12 @@ struct HomeView: View {
Spacer()
- Image(systemName: "gearshape")
- .foregroundStyle(Color.colorGray500)
+ Button {
+ isSettingPresented = true
+ } label: {
+ Image(systemName: "gearshape")
+ .foregroundStyle(Color.colorGray500)
+ }
}
}
diff --git a/GAMSS/Sources/Presentation/Login/LoginView.swift b/GAMSS/Sources/Presentation/Login/LoginView.swift
index b167c49..2861f7c 100644
--- a/GAMSS/Sources/Presentation/Login/LoginView.swift
+++ b/GAMSS/Sources/Presentation/Login/LoginView.swift
@@ -9,6 +9,7 @@ import AuthenticationServices
import SwiftUI
struct LoginView: View {
+ @SwiftUI.Environment(LoginSession.self) private var loginState
@StateObject private var viewModel: LoginViewModel
@State private var currentNonce: String?
@@ -19,9 +20,21 @@ struct LoginView: View {
}
var body: some View {
-
- // FIXME: 소셜로그인관련 Mock 버튼 추후 수정
VStack(alignment: .center) {
+ Spacer()
+
+ Image(.logoGamss)
+ .padding(.bottom, 12)
+ Text("오늘의 감정을 비워보세요")
+ .foregroundStyle(Color.colorGray950)
+ .typography(.subtitle2)
+ .padding(.bottom, 32)
+ Image(.emotions)
+ .resizable()
+ .scaledToFit()
+ .padding(.bottom, 54)
+ .padding(.horizontal, 31)
+
SignInWithAppleButton(.signIn) { request in
let nonce = NonceGenerator.generate()
currentNonce = nonce
@@ -48,11 +61,18 @@ struct LoginView: View {
credential: credential,
nonce: nonce
)
+ loginState.value = LoginState.current
}
case .failure(let error):
- print(error.localizedDescription)
+ Log.debug(error.localizedDescription)
}
}
+ .frame(height: 54)
+ .padding(.horizontal, 18)
+
+ Spacer()
}
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.colorWhite)
}
}
diff --git a/GAMSS/Sources/Presentation/Login/LoginViewModel.swift b/GAMSS/Sources/Presentation/Login/LoginViewModel.swift
index 583d1ff..10ce284 100644
--- a/GAMSS/Sources/Presentation/Login/LoginViewModel.swift
+++ b/GAMSS/Sources/Presentation/Login/LoginViewModel.swift
@@ -24,22 +24,20 @@ final class LoginViewModel: ObservableObject {
credential: ASAuthorizationAppleIDCredential,
nonce: String
) async {
- Task {
- isLoading = true
-
- defer {
- isLoading = false
- }
-
- do {
- try await loginUseCase.login(
- with: socialType,
- credential: credential,
- nonce: nonce
- )
- } catch {
- errorMessage = error.localizedDescription
- }
+ isLoading = true
+
+ defer {
+ isLoading = false
+ }
+
+ do {
+ try await loginUseCase.login(
+ with: socialType,
+ credential: credential,
+ nonce: nonce
+ )
+ } catch {
+ errorMessage = error.localizedDescription
}
}
}
diff --git a/GAMSS/Sources/Presentation/Root/RootView.swift b/GAMSS/Sources/Presentation/Root/RootView.swift
new file mode 100644
index 0000000..0187785
--- /dev/null
+++ b/GAMSS/Sources/Presentation/Root/RootView.swift
@@ -0,0 +1,43 @@
+//
+// RootView.swift
+// GAMSS
+//
+// Created by 이건준 on 8/12/26.
+//
+
+import SwiftUI
+
+@Observable
+final class LoginSession {
+ var value: LoginState = .current
+}
+
+struct RootView: View {
+ @State private var loginSession = LoginSession()
+
+ var body: some View {
+ Group {
+ switch loginSession.value {
+ case .notLoggedIn:
+ LoginView(
+ viewModel: LoginViewModel(
+ loginUseCase: DefaultLoginUseCase(
+ authRepository: DefaultAuthRepository(
+ networkManager: NetworkManager.shared,
+ tokenStorage: TokenStorage.shared
+ )
+ )
+ )
+ )
+ case .autoLoginPending, .loggedIn:
+ MainTabView()
+ }
+ }
+ .environment(loginSession)
+ .environment(UserManager.shared)
+ }
+}
+
+#Preview {
+ RootView()
+}
diff --git a/GAMSS/Sources/Presentation/Setting/Account/AccountItem.swift b/GAMSS/Sources/Presentation/Setting/Account/AccountItem.swift
new file mode 100644
index 0000000..843f944
--- /dev/null
+++ b/GAMSS/Sources/Presentation/Setting/Account/AccountItem.swift
@@ -0,0 +1,58 @@
+//
+// AccountItem.swift
+// GAMSS
+//
+// Created by LEEGEONJUN on 8/13/26.
+//
+
+import SwiftUI
+
+enum AccountItem: CaseIterable, Identifiable {
+ case changeNickname
+ case email
+ case logout
+ case withdraw
+
+ var id: Self { self }
+
+ var title: String {
+ switch self {
+ case .changeNickname:
+ "닉네임 변경"
+ case .email:
+ "이메일"
+ case .logout:
+ "로그아웃"
+ case .withdraw:
+ "회원탈퇴"
+ }
+ }
+
+ var titleColor: Color {
+ switch self {
+ case .withdraw:
+ .colorRed
+ case .changeNickname, .email, .logout:
+ .colorGray950
+ }
+ }
+
+ enum Action {
+ case navigate
+ case logout
+ case none
+ }
+
+ var action: Action {
+ switch self {
+ case .changeNickname:
+ .navigate
+ case .email:
+ .none
+ case .logout:
+ .logout
+ case .withdraw:
+ .navigate
+ }
+ }
+}
diff --git a/GAMSS/Sources/Presentation/Setting/Account/AccountView.swift b/GAMSS/Sources/Presentation/Setting/Account/AccountView.swift
new file mode 100644
index 0000000..da912be
--- /dev/null
+++ b/GAMSS/Sources/Presentation/Setting/Account/AccountView.swift
@@ -0,0 +1,126 @@
+//
+// AccountView.swift
+// GAMSS
+//
+// Created by LEEGEONJUN on 8/13/26.
+//
+
+import SwiftUI
+
+struct AccountView: View {
+ let title: String
+
+ @StateObject private var viewModel: AccountViewModel
+ @SwiftUI.Environment(LoginSession.self) private var loginSession
+ @SwiftUI.Environment(UserManager.self) private var userManager
+ @SwiftUI.Environment(\.dismiss) private var dismiss
+
+ init(title: String, viewModel: AccountViewModel) {
+ self.title = title
+ _viewModel = StateObject(wrappedValue: viewModel)
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ header
+ .padding(.horizontal, 18)
+ .padding(.vertical, Spacing.spacing400)
+
+ ForEach(AccountItem.allCases) { item in
+ accountItem(item)
+ .padding(.horizontal, 18)
+ }
+
+ Spacer()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ .background(Color.colorWhite)
+ .toolbar(.hidden, for: .navigationBar)
+ .alert(
+ viewModel.errorMessage ?? "",
+ isPresented: Binding(
+ get: { viewModel.errorMessage != nil },
+ set: { if !$0 { viewModel.errorMessage = nil } }
+ )
+ ) {
+ Button("확인", role: .cancel) {}
+ }
+ }
+
+ private var header: some View {
+ HStack(spacing: 12) {
+ Button {
+ dismiss()
+ } label: {
+ Image(systemName: "chevron.left")
+ .foregroundStyle(Color.colorGray900)
+ }
+ Text(title)
+ .typography(.subtitle2)
+ .foregroundStyle(Color.colorGray900)
+ Spacer()
+ }
+ }
+
+ @ViewBuilder
+ private func accountItem(_ item: AccountItem) -> some View {
+ let row = MenuListItemView(
+ title: item.title,
+ titleColor: item.titleColor,
+ trailingText: trailingText(for: item)
+ )
+
+ switch item.action {
+ case .navigate:
+ NavigationLink {
+ Text("\(item.title) 화면 이동")
+ } label: {
+ row
+ }
+ .buttonStyle(.plain)
+
+ case .logout:
+ Button {
+ Task {
+ await viewModel.logout()
+ loginSession.value = .current
+ }
+ } label: {
+ row
+ }
+ .buttonStyle(.plain)
+
+ case .none:
+ row
+ }
+ }
+
+ private func trailingText(for item: AccountItem) -> String? {
+ switch item {
+ case .changeNickname:
+ userManager.user?.nickname ?? "-"
+ case .email:
+ userManager.user?.email ?? "-"
+ case .logout, .withdraw:
+ nil
+ }
+ }
+}
+
+#Preview {
+ NavigationStack {
+ AccountView(
+ title: SettingItem.accountInfo.title,
+ viewModel: AccountViewModel(
+ logoutUseCase: DefaultLogoutUseCase(
+ authRepository: DefaultAuthRepository(
+ networkManager: NetworkManager.shared,
+ tokenStorage: TokenStorage.shared
+ )
+ )
+ )
+ )
+ .environment(LoginSession())
+ .environment(UserManager.shared)
+ }
+}
diff --git a/GAMSS/Sources/Presentation/Setting/Account/AccountViewModel.swift b/GAMSS/Sources/Presentation/Setting/Account/AccountViewModel.swift
new file mode 100644
index 0000000..d67b38e
--- /dev/null
+++ b/GAMSS/Sources/Presentation/Setting/Account/AccountViewModel.swift
@@ -0,0 +1,34 @@
+//
+// AccountViewModel.swift
+// GAMSS
+//
+// Created by LEEGEONJUN on 8/13/26.
+//
+
+import Combine
+import Foundation
+
+@MainActor
+final class AccountViewModel: ObservableObject {
+ private let logoutUseCase: LogoutUseCase
+ private let userManager: UserManager
+
+ @Published var errorMessage: String?
+
+ init(
+ logoutUseCase: LogoutUseCase,
+ userManager: UserManager = .shared
+ ) {
+ self.logoutUseCase = logoutUseCase
+ self.userManager = userManager
+ }
+
+ func logout() async {
+ do {
+ try await logoutUseCase.logout()
+ userManager.user = nil
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+}
diff --git a/GAMSS/Sources/Presentation/Setting/SettingItem.swift b/GAMSS/Sources/Presentation/Setting/SettingItem.swift
new file mode 100644
index 0000000..19e59b4
--- /dev/null
+++ b/GAMSS/Sources/Presentation/Setting/SettingItem.swift
@@ -0,0 +1,77 @@
+//
+// SettingItem.swift
+// GAMSS
+//
+// Created by LEEGEONJUN on 8/12/26.
+//
+
+import Foundation
+
+enum SettingSection: CaseIterable, Identifiable {
+ case account
+ case service
+
+ var id: Self { self }
+
+ var items: [SettingItem] {
+ switch self {
+ case .account:
+ [.accountInfo, .notification]
+ case .service:
+ [.termsOfService, .privacyPolicy, .appVersion]
+ }
+ }
+}
+
+enum SettingItem: Identifiable {
+ case accountInfo
+ case notification
+ case termsOfService
+ case privacyPolicy
+ case appVersion
+
+ var id: Self { self }
+
+ var title: String {
+ switch self {
+ case .accountInfo:
+ "계정 정보"
+ case .notification:
+ "알림 설정"
+ case .termsOfService:
+ "서비스 이용약관"
+ case .privacyPolicy:
+ "개인정보 처리방침"
+ case .appVersion:
+ "앱 버전"
+ }
+ }
+
+ enum Action {
+ case navigate
+ case web(String)
+ case none
+ }
+
+ var action: Action {
+ switch self {
+ case .accountInfo, .notification:
+ .navigate
+ case .termsOfService:
+ .web(Environment.termsOfServiceURL)
+ case .privacyPolicy:
+ .web(Environment.privacyPolicyURL)
+ case .appVersion:
+ .none
+ }
+ }
+
+ var showsDividerBelow: Bool {
+ switch self {
+ case .accountInfo, .notification:
+ true
+ case .termsOfService, .privacyPolicy, .appVersion:
+ false
+ }
+ }
+}
diff --git a/GAMSS/Sources/Presentation/Setting/SettingView.swift b/GAMSS/Sources/Presentation/Setting/SettingView.swift
new file mode 100644
index 0000000..06506b7
--- /dev/null
+++ b/GAMSS/Sources/Presentation/Setting/SettingView.swift
@@ -0,0 +1,130 @@
+//
+// SettingView.swift
+// GAMSS
+//
+// Created by LEEGEONJUN on 8/12/26.
+//
+
+import SwiftUI
+
+struct SettingView: View {
+ @SwiftUI.Environment(\.dismiss) private var dismiss
+ @State private var presentedWebPage: WebPage?
+
+ var body: some View {
+ VStack {
+ header
+ .padding(.horizontal, 18)
+ .padding(.vertical, Spacing.spacing400)
+
+ ForEach(SettingSection.allCases) { section in
+ ForEach(section.items) { item in
+ settingItem(item)
+ .padding(.horizontal, 18)
+
+ if item.showsDividerBelow {
+ fullWidthDivider
+ }
+ }
+ }
+
+ Spacer()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.colorWhite)
+ .toolbar(.hidden, for: .navigationBar)
+ .sheet(item: $presentedWebPage) { page in
+ SafariView(url: page.url)
+ .ignoresSafeArea()
+ }
+ }
+
+ private var header: some View {
+ HStack(spacing: 12) {
+ Button {
+ dismiss()
+ } label: {
+ Image(systemName: "chevron.left")
+ .foregroundStyle(Color.colorGray900)
+ }
+ Text("설정")
+ .typography(.subtitle2)
+ .foregroundStyle(Color.colorGray900)
+ Spacer()
+ }
+ }
+
+ private var fullWidthDivider: some View {
+ Color.colorGray075
+ .frame(height: 2)
+ .frame(maxWidth: .infinity)
+ }
+
+ @ViewBuilder
+ private func settingItem(_ item: SettingItem) -> some View {
+ let row = MenuListItemView(
+ title: item.title,
+ badgeText: item == .notification ? "OFF" : nil,
+ trailingText: item == .appVersion ? Environment.appVersion : nil
+ )
+
+ switch item.action {
+ case .navigate:
+ NavigationLink {
+ destination(for: item)
+ } label: {
+ row
+ }
+ .buttonStyle(.plain)
+
+ case let .web(urlString):
+ Button {
+ presentWeb(urlString)
+ } label: {
+ row
+ }
+ .buttonStyle(.plain)
+
+ case .none:
+ row
+ }
+ }
+
+ @ViewBuilder
+ private func destination(for item: SettingItem) -> some View {
+ switch item {
+ case .accountInfo:
+ AccountView(
+ title: item.title,
+ viewModel: AccountViewModel(
+ logoutUseCase: DefaultLogoutUseCase(
+ authRepository: DefaultAuthRepository(
+ networkManager: NetworkManager.shared,
+ tokenStorage: TokenStorage.shared
+ )
+ )
+ )
+ )
+ case .notification:
+ Text("알림 설정 화면 이동")
+ case .termsOfService, .privacyPolicy, .appVersion:
+ EmptyView()
+ }
+ }
+
+ private func presentWeb(_ urlString: String) {
+ guard let url = URL(string: urlString) else { return }
+ presentedWebPage = WebPage(url: url)
+ }
+}
+
+private struct WebPage: Identifiable {
+ let id = UUID()
+ let url: URL
+}
+
+#Preview {
+ NavigationStack {
+ SettingView()
+ }
+}