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() + } +}