diff --git a/SampoomManagement.xcodeproj/project.pbxproj b/SampoomManagement.xcodeproj/project.pbxproj index ea94f0a..1adaef9 100644 --- a/SampoomManagement.xcodeproj/project.pbxproj +++ b/SampoomManagement.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 533528342E8BD99400F38FD1 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 533528332E8BD99400F38FD1 /* Alamofire */; }; 5387CA3A2E8F676E005A3936 /* Swinject in Frameworks */ = {isa = PBXBuildFile; productRef = 5387CA392E8F676E005A3936 /* Swinject */; }; + 53F27C452E9F9C8800D223ED /* Toast in Frameworks */ = {isa = PBXBuildFile; productRef = 53F27C442E9F9C8800D223ED /* Toast */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -19,7 +20,7 @@ 533528372E8BDAB300F38FD1 /* Exceptions for "SampoomManagement" folder in "SampoomManagement" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - Resources/Info.plist, + Info.plist, ); target = 53A7B4BE2E8A43AF00BC946E /* SampoomManagement */; }; @@ -41,6 +42,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 53F27C452E9F9C8800D223ED /* Toast in Frameworks */, 533528342E8BD99400F38FD1 /* Alamofire in Frameworks */, 5387CA3A2E8F676E005A3936 /* Swinject in Frameworks */, ); @@ -87,6 +89,7 @@ packageProductDependencies = ( 533528332E8BD99400F38FD1 /* Alamofire */, 5387CA392E8F676E005A3936 /* Swinject */, + 53F27C442E9F9C8800D223ED /* Toast */, ); productName = SampoomManagement; productReference = 53A7B4BF2E8A43AF00BC946E /* SampoomManagement.app */; @@ -119,6 +122,7 @@ packageReferences = ( 533528322E8BD99400F38FD1 /* XCRemoteSwiftPackageReference "Alamofire" */, 5387CA382E8F676E005A3936 /* XCRemoteSwiftPackageReference "Swinject" */, + 53F27C432E9F9C8800D223ED /* XCRemoteSwiftPackageReference "toast-swift" */, ); preferredProjectObjectVersion = 77; productRefGroup = 53A7B4C02E8A43AF00BC946E /* Products */; @@ -282,6 +286,7 @@ DEVELOPMENT_TEAM = B9PUAVBBKX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SampoomManagement/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "삼품관리"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -316,6 +321,7 @@ DEVELOPMENT_TEAM = B9PUAVBBKX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SampoomManagement/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "삼품관리"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -380,6 +386,14 @@ minimumVersion = 2.10.0; }; }; + 53F27C432E9F9C8800D223ED /* XCRemoteSwiftPackageReference "toast-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/BastiaanJansen/toast-swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.1.3; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -393,6 +407,11 @@ package = 5387CA382E8F676E005A3936 /* XCRemoteSwiftPackageReference "Swinject" */; productName = Swinject; }; + 53F27C442E9F9C8800D223ED /* Toast */ = { + isa = XCSwiftPackageProductDependency; + package = 53F27C432E9F9C8800D223ED /* XCRemoteSwiftPackageReference "toast-swift" */; + productName = Toast; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 53A7B4B72E8A43AF00BC946E /* Project object */; diff --git a/SampoomManagement/App/ContentView.swift b/SampoomManagement/App/ContentView.swift index f22696f..dd6fb25 100644 --- a/SampoomManagement/App/ContentView.swift +++ b/SampoomManagement/App/ContentView.swift @@ -12,15 +12,13 @@ enum Tabs { } struct ContentView: View { + let dependencies: AppDependencies @StateObject private var partViewModel: PartViewModel @State private var selectedTab: Tabs = .dashboard - init() { - // DI Container에서 ViewModel 주입 - guard let viewModel = DIContainer.shared.resolve(PartViewModel.self) else { - fatalError("PartViewModel을 DIContainer에서 찾을 수 없습니다.") - } - _partViewModel = StateObject(wrappedValue: viewModel) + init(dependencies: AppDependencies) { + self.dependencies = dependencies + _partViewModel = StateObject(wrappedValue: dependencies.makePartViewModel()) } var body: some View { diff --git a/SampoomManagement/App/RootView.swift b/SampoomManagement/App/RootView.swift new file mode 100644 index 0000000..ca8f90b --- /dev/null +++ b/SampoomManagement/App/RootView.swift @@ -0,0 +1,76 @@ +// +// RootView.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import SwiftUI + +struct RootView: View { + let dependencies: AppDependencies + + @StateObject private var loginViewModel: LoginViewModel + @StateObject private var signUpViewModel: SignUpViewModel + @State private var isAuthenticated: Bool = false + @State private var showSignUp: Bool = false + + init(dependencies: AppDependencies) { + self.dependencies = dependencies + _loginViewModel = StateObject(wrappedValue: dependencies.makeLoginViewModel()) + _signUpViewModel = StateObject(wrappedValue: dependencies.makeSignUpViewModel()) + } + + var body: some View { + Group { + if isAuthenticated { + // 로그인 되어있으면 메인 화면 + ContentView(dependencies: dependencies) + } else { + // 로그인 안되어있으면 로그인/회원가입 화면 + if showSignUp { + NavigationStack { + SignUpView( + viewModel: signUpViewModel, + onSuccess: { + // 회원가입 성공 시 자동 로그인 완료 → 메인 화면으로 + isAuthenticated = true + } + ) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { + showSignUp = false + }) { + Image(systemName: "chevron.left") + .foregroundColor(Color(red: 0.5, green: 0.2, blue: 0.8)) + } + } + } + } + } else { + LoginView( + viewModel: loginViewModel, + onSuccess: { + // 로그인 성공 시 메인 화면으로 + isAuthenticated = true + }, + onNavigateSignUp: { + // 회원가입 화면으로 + showSignUp = true + } + ) + } + } + } + .onAppear { + // 앱 시작 시 로그인 상태 확인 + checkAuthenticationStatus() + } + } + + private func checkAuthenticationStatus() { + isAuthenticated = dependencies.authPreferences.hasToken() + } +} diff --git a/SampoomManagement/App/SampoomManagementApp.swift b/SampoomManagement/App/SampoomManagementApp.swift index c04c678..7a38eb4 100644 --- a/SampoomManagement/App/SampoomManagementApp.swift +++ b/SampoomManagement/App/SampoomManagementApp.swift @@ -9,15 +9,28 @@ import SwiftUI @main struct SampoomManagementApp: App { + // SwiftUI Environment 기반 DI + private let dependencies = AppDependencies() init() { - // DI Container 초기화 - _ = DIContainer.shared + // 앱 전체 폰트 설정 + setupGlobalFont() } var body: some Scene { WindowGroup { - ContentView() + RootView(dependencies: dependencies) + } + } + + // MARK: - Setup + + private func setupGlobalFont() { + // UIKit 컴포넌트에 대한 기본 폰트 설정 + if let font = UIFont.gmarketSans(size: 16, weight: .medium) { + UILabel.appearance().font = font + UITextField.appearance().font = font + UITextView.appearance().font = font } } } diff --git a/SampoomManagement/Core/DI/AppDependencies.swift b/SampoomManagement/Core/DI/AppDependencies.swift new file mode 100644 index 0000000..baa04af --- /dev/null +++ b/SampoomManagement/Core/DI/AppDependencies.swift @@ -0,0 +1,62 @@ +// +// AppDependencies.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import Foundation +import SwiftUI + +/// SwiftUI Environment 기반 의존성 관리 +class AppDependencies { + // MARK: - Core + let networkManager: NetworkManager + + // MARK: - Auth + let authPreferences: AuthPreferences + let authAPI: AuthAPI + let authRepository: AuthRepository + let loginUseCase: LoginUseCase + let signUpUseCase: SignUpUseCase + + // MARK: - Part + let partAPI: PartAPI + let partRepository: PartRepository + let getPartUseCase: GetPartUseCase + + init() { + // Core + networkManager = NetworkManager() + + // Auth + authPreferences = AuthPreferences() + authAPI = AuthAPI(networkManager: networkManager) + authRepository = AuthRepositoryImpl( + api: authAPI, + preferences: authPreferences + ) + loginUseCase = LoginUseCase(repository: authRepository) + signUpUseCase = SignUpUseCase(repository: authRepository) + + // Part + partAPI = PartAPI(networkManager: networkManager) + partRepository = PartRepositoryImpl(api: partAPI) + getPartUseCase = GetPartUseCase(repository: partRepository) + } + + // MARK: - ViewModel Factories + + func makeLoginViewModel() -> LoginViewModel { + return LoginViewModel(loginUseCase: loginUseCase) + } + + func makeSignUpViewModel() -> SignUpViewModel { + return SignUpViewModel(signUpUseCase: signUpUseCase) + } + + func makePartViewModel() -> PartViewModel { + return PartViewModel(getPartUseCase: getPartUseCase) + } +} + diff --git a/SampoomManagement/Core/DI/CoreDIModule.swift b/SampoomManagement/Core/DI/CoreDIModule.swift deleted file mode 100644 index 898877c..0000000 --- a/SampoomManagement/Core/DI/CoreDIModule.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// CoreDIModule.swift -// SampoomManagement -// -// Created by 채상윤 on 9/29/25. -// - -import Foundation -import Swinject - -final class CoreDIModule: Assembly { - func assemble(container: Container) { - // MARK: - Core Layer Dependencies - - // NetworkManager 등록 - container.register(NetworkManager.self) { _ in - NetworkManager() - }.inObjectScope(.container) - } -} diff --git a/SampoomManagement/Core/DI/DIContainer.swift b/SampoomManagement/Core/DI/DIContainer.swift deleted file mode 100644 index 8813450..0000000 --- a/SampoomManagement/Core/DI/DIContainer.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// DIContainer.swift -// SampoomManagement -// -// Created by 채상윤 on 9/29/25. -// - -import Foundation -import Swinject - -final class DIContainer { - static let shared = DIContainer() - - private let container: Container - private let assembler: Assembler - - private init() { - container = Container() - assembler = Assembler([ - CoreDIModule(), // Core 레벨 의존성 - PartDIModule() // Part Feature 의존성 - ], container: container) - } - - func resolve(_ type: T.Type) -> T? { - return container.resolve(type) - } - - func resolve(_ type: T.Type, name: String) -> T? { - return container.resolve(type, name: name) - } -} diff --git a/SampoomManagement/Core/Network/NetworkError.swift b/SampoomManagement/Core/Network/NetworkError.swift index 4333563..29a74b2 100644 --- a/SampoomManagement/Core/Network/NetworkError.swift +++ b/SampoomManagement/Core/Network/NetworkError.swift @@ -29,3 +29,20 @@ enum NetworkError: Error, LocalizedError { } } } + +enum AuthError: Error, LocalizedError { + case tokenSaveFailed(Error) + case invalidCredentials + case networkError(Error) + + var errorDescription: String? { + switch self { + case .tokenSaveFailed(let error): + return "토큰 저장 실패: \(error.localizedDescription)" + case .invalidCredentials: + return "잘못된 인증 정보입니다" + case .networkError(let error): + return "네트워크 오류: \(error.localizedDescription)" + } + } +} diff --git a/SampoomManagement/Core/Network/NetworkManager.swift b/SampoomManagement/Core/Network/NetworkManager.swift index ee13375..917b8ed 100644 --- a/SampoomManagement/Core/Network/NetworkManager.swift +++ b/SampoomManagement/Core/Network/NetworkManager.swift @@ -11,7 +11,7 @@ import Alamofire class NetworkManager { static let shared = NetworkManager() - private let baseURL = "http://localhost:8080/api/" + private let baseURL = "https://sampoom.store/api/" init() {} diff --git a/SampoomManagement/Core/Resources/StringResources.swift b/SampoomManagement/Core/Resources/StringResources.swift index 874dd17..783d612 100644 --- a/SampoomManagement/Core/Resources/StringResources.swift +++ b/SampoomManagement/Core/Resources/StringResources.swift @@ -70,4 +70,43 @@ struct StringResources { static let edit = "편집" static let done = "완료" } + + // MARK: - Auth + struct Auth { + // Login + static let emailLabel = "이메일" + static let passwordLabel = "비밀번호" + static let emailPlaceholder = "이메일 입력" + static let passwordPlaceholder = "비밀번호 입력" + static let loginButton = "로그인" + static let loginButtonLoading = "로그인 중..." + static let needAccount = "계정이 없으신가요?" + static let signUpLink = "회원가입" + static let signUpDo = "하기" + + // SignUp + static let nameLabel = "이름" + static let branchLabel = "지점" + static let positionLabel = "직급" + static let passwordCheckLabel = "비밀번호 확인" + static let namePlaceholder = "이름 입력" + static let branchPlaceholder = "지점 입력" + static let positionPlaceholder = "직급 입력" + static let passwordCheckPlaceholder = "비밀번호 확인 입력" + static let signUpButton = "회원가입" + static let signUpButtonLoading = "회원가입 중..." + static let back = "뒤로" + + // Validation Messages + static func fieldRequired(_ field: String) -> String { + return "\(field)을(를) 입력해주세요." + } + static let emailRequired = "이메일을 입력해주세요." + static let emailInvalid = "올바른 이메일 형식이 아닙니다." + static let passwordRequired = "비밀번호를 입력해주세요." + static let passwordTooShort = "비밀번호는 8자 이상이어야 합니다." + static let passwordInvalid = "비밀번호는 영문과 숫자를 포함해야 합니다." + static let passwordCheckRequired = "비밀번호 확인을 입력해주세요." + static let passwordCheckMismatch = "비밀번호가 일치하지 않습니다." + } } diff --git a/SampoomManagement/Core/UI/Components/CommonButton.swift b/SampoomManagement/Core/UI/Components/CommonButton.swift index cb1f91a..a86c0b0 100644 --- a/SampoomManagement/Core/UI/Components/CommonButton.swift +++ b/SampoomManagement/Core/UI/Components/CommonButton.swift @@ -82,7 +82,7 @@ struct CommonButton: View { } Text(title) - .font(size.font) + .font(.gmarketBody) if let icon = icon, iconPosition == .trailing { Image(systemName: icon) @@ -91,23 +91,23 @@ struct CommonButton: View { } .frame(height: size.height) .frame(maxWidth: .infinity) + .padding(4) .foregroundColor(buttonTextColor) .background(buttonBackgroundColor) .overlay( - RoundedRectangle(cornerRadius: 8) + RoundedRectangle(cornerRadius: 16) .stroke(buttonBorderColor, lineWidth: borderWidth) ) - .cornerRadius(8) + .cornerRadius(16) } .disabled(!isEnabled) - .opacity(isEnabled ? 1.0 : 0.6) .animation(.easeInOut(duration: 0.2), value: isEnabled) } // MARK: - Button Styling private var buttonBackgroundColor: Color { if !isEnabled { - return .gray + return .disable } if let customColor = backgroundColor { @@ -116,7 +116,7 @@ struct CommonButton: View { switch type { case .filled: - return Color(red: 0.5, green: 0.2, blue: 0.8) // 기본 보라색 + return Color(.accent) // 기본 보라색 case .outlined: return .clear } @@ -124,7 +124,7 @@ struct CommonButton: View { private var buttonTextColor: Color { if !isEnabled { - return .white + return .textSecondary } if let customColor = textColor { diff --git a/SampoomManagement/Core/UI/Components/CommonTextField.swift b/SampoomManagement/Core/UI/Components/CommonTextField.swift index 1225005..e54500c 100644 --- a/SampoomManagement/Core/UI/Components/CommonTextField.swift +++ b/SampoomManagement/Core/UI/Components/CommonTextField.swift @@ -50,97 +50,101 @@ enum TextFieldSize { struct CommonTextField: View { @Environment(\.colorScheme) private var colorScheme @State private var isPasswordVisible = false - @State private var text = "" + @FocusState private var isFocused: Bool + @Binding var value: String let placeholder: String let type: TextFieldType let size: TextFieldSize - let textColor: Color? - let backgroundColor: Color? - let borderColor: Color? + let isError: Bool + let errorMessage: String? let onTextChange: (String) -> Void init( + value: Binding, placeholder: String, type: TextFieldType = .text, size: TextFieldSize = .medium, - textColor: Color? = nil, - backgroundColor: Color? = nil, - borderColor: Color? = nil, + isError: Bool = false, + errorMessage: String? = nil, onTextChange: @escaping (String) -> Void = { _ in } ) { + self._value = value self.placeholder = placeholder self.type = type self.size = size - self.textColor = textColor - self.backgroundColor = backgroundColor - self.borderColor = borderColor + self.isError = isError + self.errorMessage = errorMessage self.onTextChange = onTextChange } var body: some View { - HStack { - // Text Field - Group { - if type == .password && !isPasswordVisible { - SecureField(placeholder, text: $text) - .textFieldStyle(PlainTextFieldStyle()) - } else { - TextField(placeholder, text: $text) - .keyboardType(keyboardType) - .textInputAutocapitalization(autocapitalization) - .disableAutocorrection(disableAutocorrection) - .textFieldStyle(PlainTextFieldStyle()) + VStack(alignment: .leading, spacing: 4) { + HStack { + // Text Field + Group { + if type == .password && !isPasswordVisible { + SecureField(placeholder, text: $value) + .textFieldStyle(PlainTextFieldStyle()) + .focused($isFocused) + } else { + TextField(placeholder, text: $value) + .keyboardType(keyboardType) + .textInputAutocapitalization(autocapitalization) + .disableAutocorrection(disableAutocorrection) + .textFieldStyle(PlainTextFieldStyle()) + .focused($isFocused) + } + } + .font(.gmarketBody) + .foregroundColor(buttonTextColor) + + // Password Toggle Button (inside TextField) + if type == .password { + Button(action: { + isPasswordVisible.toggle() + }) { + Image(systemName: isPasswordVisible ? "eye.slash" : "eye") + .foregroundColor(iconColor) + .font(.system(size: 16, weight: .medium)) + } + .padding(.trailing, 8) } } - .font(size.font) - .foregroundColor(buttonTextColor) + .padding(size.padding) + .padding(4) + .background( + RoundedRectangle(cornerRadius: 16) + .stroke(effectiveBorderColor, lineWidth: isFocused ? 1.5 : 1) + ) - // Password Toggle Button (inside TextField) - if type == .password { - Button(action: { - isPasswordVisible.toggle() - }) { - Image(systemName: isPasswordVisible ? "eye.slash" : "eye") - .foregroundColor(iconColor) - .font(.system(size: 16, weight: .medium)) - } - .padding(.trailing, 8) + // Error Message + if isError, let errorMessage = errorMessage { + Text(errorMessage) + .font(.gmarketBody) + .foregroundColor(.red) + .padding(.leading, 4) } } - .padding(size.padding) - .background(buttonBackgroundColor) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(buttonBorderColor, lineWidth: 1) - ) - .onChange(of: text) { oldValue, newValue in + .onChange(of: value) { _, newValue in onTextChange(newValue) } } // MARK: - Computed Properties private var buttonTextColor: Color { - if let textColor = textColor { - return textColor - } - // 다크모드 고려한 기본 색상 switch colorScheme { case .dark: - return text.isEmpty ? .gray : .white + return value.isEmpty ? .gray : .white case .light: - return text.isEmpty ? .gray : .black + return value.isEmpty ? .gray : .black @unknown default: - return text.isEmpty ? .gray : .primary + return value.isEmpty ? .gray : .primary } } private var buttonBackgroundColor: Color { - if let backgroundColor = backgroundColor { - return backgroundColor - } - // 다크모드 고려한 기본 배경색 switch colorScheme { case .dark: @@ -153,19 +157,25 @@ struct CommonTextField: View { } private var buttonBorderColor: Color { - if let borderColor = borderColor { - return borderColor - } - // 다크모드 고려한 기본 테두리색 switch colorScheme { case .dark: - return .gray.opacity(0.3) + return .gray.opacity(0.4) case .light: - return .gray.opacity(0.3) + return .gray.opacity(0.4) @unknown default: - return .gray.opacity(0.3) + return .gray.opacity(0.4) + } + } + + private var effectiveBorderColor: Color { + if isError { + return .red + } + if isFocused { + return .accentColor } + return buttonBorderColor } private var iconColor: Color { @@ -215,17 +225,14 @@ struct CommonTextField: View { // MARK: - Preview #Preview { + @Previewable @State var email = "" + @Previewable @State var password = "" + @Previewable @State var errorText = "Error" + VStack(spacing: 16) { // Email Input (Placeholder) CommonTextField( - placeholder: "이메일 입력", - type: .email - ) { text in - print("Email: \(text)") - } - - // Email Input (Filled) - CommonTextField( + value: $email, placeholder: "이메일 입력", type: .email ) { text in @@ -234,27 +241,29 @@ struct CommonTextField: View { // Password Input (Placeholder) CommonTextField( + value: $password, placeholder: "비밀번호 입력", type: .password ) { text in print("Password: \(text)") } - // Password Input (Filled) + // Error State CommonTextField( - placeholder: "비밀번호 입력", - type: .password + value: $errorText, + placeholder: "에러 상태", + type: .text, + isError: true, + errorMessage: "이메일 형식이 올바르지 않습니다." ) { text in - print("Password: \(text)") + print("Error: \(text)") } // Custom Colors CommonTextField( + value: $email, placeholder: "커스텀 색상", type: .text, - textColor: .blue, - backgroundColor: .yellow.opacity(0.1), - borderColor: .blue ) { text in print("Custom: \(text)") } diff --git a/SampoomManagement/Core/UI/Extensions/Font+Extension.swift b/SampoomManagement/Core/UI/Extensions/Font+Extension.swift new file mode 100644 index 0000000..0562e2b --- /dev/null +++ b/SampoomManagement/Core/UI/Extensions/Font+Extension.swift @@ -0,0 +1,98 @@ +// +// Font+Extension.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import SwiftUI + +extension Font { + // MARK: - GmarketSans Font + + enum GmarketSansWeight { + case light + case medium + case bold + + var name: String { + switch self { + case .light: return "GmarketSansLight" + case .medium: return "GmarketSansMedium" + case .bold: return "GmarketSansBold" + } + } + } + + /// GmarketSans 커스텀 폰트 + static func gmarketSans(size: CGFloat, weight: GmarketSansWeight = .medium) -> Font { + return .custom(weight.name, size: size) + } + + // MARK: - GmarketSans Semantic Fonts + + /// 큰 타이틀 (40pt, Medium) - 매우 큰 제목 + static var gmarketLargeTitle: Font { + return .gmarketSans(size: 40, weight: .medium) + } + + /// 타이틀 (32pt, Medium) - 화면 제목 + static var gmarketTitle: Font { + return .gmarketSans(size: 32, weight: .medium) + } + + /// 타이틀 2 (24pt, Medium) - 섹션 제목 + static var gmarketTitle2: Font { + return .gmarketSans(size: 24, weight: .medium) + } + + /// 타이틀 3 (20pt, Medium) - 작은 제목 + static var gmarketTitle3: Font { + return .gmarketSans(size: 20, weight: .medium) + } + + /// 헤드라인 (18pt, Medium) - 강조 텍스트 + static var gmarketHeadline: Font { + return .gmarketSans(size: 18, weight: .medium) + } + + /// 본문 (16pt, Medium) - 기본 본문 + static var gmarketBody: Font { + return .gmarketSans(size: 16, weight: .medium) + } + + /// 서브헤드 (14pt, Medium) - 작은 본문 + static var gmarketSubheadline: Font { + return .gmarketSans(size: 14, weight: .medium) + } + + /// 캡션 (13pt, Medium) - 설명 텍스트 + static var gmarketCaption: Font { + return .gmarketSans(size: 13, weight: .medium) + } + + /// 작은 캡션 (12pt, Medium) - 매우 작은 텍스트 + static var gmarketCaption2: Font { + return .gmarketSans(size: 12, weight: .medium) + } + + // MARK: - Bold Variants (필요시 사용) + + /// 타이틀 Bold (32pt, Bold) + static var gmarketTitleBold: Font { + return .gmarketSans(size: 32, weight: .bold) + } + + /// 헤드라인 Bold (18pt, Bold) + static var gmarketHeadlineBold: Font { + return .gmarketSans(size: 18, weight: .bold) + } +} + +// MARK: - UIFont Extension (UIKit 사용 시) +extension UIFont { + static func gmarketSans(size: CGFloat, weight: Font.GmarketSansWeight) -> UIFont? { + return UIFont(name: weight.name, size: size) + } +} + diff --git a/SampoomManagement/Core/Utilities/KeyboardObserver.swift b/SampoomManagement/Core/Utilities/KeyboardObserver.swift new file mode 100644 index 0000000..48f95d2 --- /dev/null +++ b/SampoomManagement/Core/Utilities/KeyboardObserver.swift @@ -0,0 +1,38 @@ +// +// KeyboardObserver.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import SwiftUI +import Combine + +class KeyboardObserver: ObservableObject { + @Published var keyboardHeight: CGFloat = 0 + + private var cancellables = Set() + + init() { + // 키보드가 올라올 때 + NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification) + .compactMap { $0.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect } + .map { $0.height } + .sink { [weak self] height in + withAnimation(.easeOut(duration: 0.25)) { + self?.keyboardHeight = height + } + } + .store(in: &cancellables) + + // 키보드가 내려갈 때 + NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification) + .sink { [weak self] _ in + withAnimation(.easeOut(duration: 0.25)) { + self?.keyboardHeight = 0 + } + } + .store(in: &cancellables) + } +} + diff --git a/SampoomManagement/Features/Auth/Data/Local/Preferences/AuthPreferences.swift b/SampoomManagement/Features/Auth/Data/Local/Preferences/AuthPreferences.swift new file mode 100644 index 0000000..56be25c --- /dev/null +++ b/SampoomManagement/Features/Auth/Data/Local/Preferences/AuthPreferences.swift @@ -0,0 +1,68 @@ +// +// AuthPreferences.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +class AuthPreferences { + private let keychain = KeychainManager() + + private enum Keys { + static let accessToken = "auth.accessToken" + static let refreshToken = "auth.refreshToken" + } + + func saveToken(accessToken: String, refreshToken: String) throws { + do { + try keychain.save(accessToken, for: Keys.accessToken) + try keychain.save(refreshToken, for: Keys.refreshToken) + } catch { + // 부분 저장 실패 시 롤백 + try? keychain.delete(Keys.accessToken) + try? keychain.delete(Keys.refreshToken) + throw error + } + } + + func getAccessToken() throws -> String? { + return try keychain.get(Keys.accessToken) + } + + func getRefreshToken() throws -> String? { + return try keychain.get(Keys.refreshToken) + } + + func hasToken() -> Bool { + do { + let accessToken = try getAccessToken() + let refreshToken = try getRefreshToken() + return accessToken != nil && refreshToken != nil + } catch { + // 키체인 접근 오류 발생 시 로깅하고 false 반환 + print("AuthPreferences - 키체인 접근 오류: \(error)") + return false + } + } + + // 에러를 전파하는 버전 (필요한 경우 사용) + func hasTokenSafely() throws -> Bool { + let accessToken = try getAccessToken() + let refreshToken = try getRefreshToken() + return accessToken != nil && refreshToken != nil + } + + func clear() { + do { + try keychain.delete(Keys.accessToken) + try keychain.delete(Keys.refreshToken) + } catch { + // 로그아웃 시에는 실패해도 에러를 던지지 않음 (이미 로그아웃 상태로 간주) + print("AuthPreferences - 키체인 삭제 실패: \(error)") + } + } +} + + diff --git a/SampoomManagement/Features/Auth/Data/Local/Preferences/KeychainManager.swift b/SampoomManagement/Features/Auth/Data/Local/Preferences/KeychainManager.swift new file mode 100644 index 0000000..2354d3f --- /dev/null +++ b/SampoomManagement/Features/Auth/Data/Local/Preferences/KeychainManager.swift @@ -0,0 +1,100 @@ +// +// KeychainManager.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation +import Security + +class KeychainManager { + enum KeychainError: Error { + case duplicateItem + case unknown(OSStatus) + case itemNotFound + } + + private let service: String + + init(service: String = Bundle.main.bundleIdentifier ?? "com.sampoom.ios") { + self.service = service + } + + func save(_ value: String, for key: String) throws { + let data = value.data(using: .utf8)! + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecValueData as String: data + ] + + // 기존 항목 삭제 + SecItemDelete(query as CFDictionary) + + // 새 항목 추가 + let status = SecItemAdd(query as CFDictionary, nil) + + guard status == errSecSuccess else { + throw KeychainError.unknown(status) + } + } + + func get(_ key: String) throws -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status != errSecItemNotFound else { + return nil + } + + guard status == errSecSuccess else { + throw KeychainError.unknown(status) + } + + guard let data = result as? Data, + let value = String(data: data, encoding: .utf8) else { + return nil + } + + return value + } + + func delete(_ key: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + + let status = SecItemDelete(query as CFDictionary) + + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.unknown(status) + } + } + + func deleteAll() throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service + ] + + let status = SecItemDelete(query as CFDictionary) + + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.unknown(status) + } + } +} + diff --git a/SampoomManagement/Features/Auth/Data/Mappers/AuthMappers.swift b/SampoomManagement/Features/Auth/Data/Mappers/AuthMappers.swift new file mode 100644 index 0000000..b7887ed --- /dev/null +++ b/SampoomManagement/Features/Auth/Data/Mappers/AuthMappers.swift @@ -0,0 +1,21 @@ +// +// AuthMappers.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import Foundation + +extension LoginResponseDTO { + func toModel() -> User { + return User( + id: self.userId, + name: self.userName, + role: self.role, + accessToken: self.accessToken, + refreshToken: self.refreshToken, + expiresIn: self.expiresIn + ) + } +} diff --git a/SampoomManagement/Features/Auth/Data/Remote/API/AuthAPI.swift b/SampoomManagement/Features/Auth/Data/Remote/API/AuthAPI.swift new file mode 100644 index 0000000..59d35d5 --- /dev/null +++ b/SampoomManagement/Features/Auth/Data/Remote/API/AuthAPI.swift @@ -0,0 +1,88 @@ +// +// AuthAPI.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation +import Alamofire + +class AuthAPI { + private let networkManager: NetworkManager + + init(networkManager: NetworkManager) { + self.networkManager = networkManager + } + + // 로그인 + func login(email: String, password: String) async throws -> APIResponse { + return try await withCheckedThrowingContinuation { continuation in + let requestDTO = LoginRequestDTO(email: email, password: password) + + let parameters: [String: Any] = [ + "email": requestDTO.email, + "password": requestDTO.password + ] + + networkManager.request( + endpoint: "auth/login", + method: .post, + parameters: parameters, + responseType: LoginResponseDTO.self + ) { result in + switch result { + case .success(let response): + continuation.resume(returning: response) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + // 회원가입 + func signup( + email: String, + password: String, + workspace: String, + branch: String, + userName: String, + position: String + ) async throws -> APIResponse { + return try await withCheckedThrowingContinuation { continuation in + let requestDTO = SignupRequestDTO( + userName: userName, + workspace: workspace, + branch: branch, + position: position, + email: email, + password: password + ) + + let parameters: [String: Any] = [ + "email": requestDTO.email, + "password": requestDTO.password, + "workspace": requestDTO.workspace, + "branch": requestDTO.branch, + "userName": requestDTO.userName, + "position": requestDTO.position + ] + + networkManager.request( + endpoint: "auth/signup", + method: .post, + parameters: parameters, + responseType: SignupResponseDTO.self + ) { result in + switch result { + case .success(let response): + continuation.resume(returning: response) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } +} + diff --git a/SampoomManagement/Features/Auth/Data/Remote/DTO/LoginRequestDTO.swift b/SampoomManagement/Features/Auth/Data/Remote/DTO/LoginRequestDTO.swift new file mode 100644 index 0000000..9b219f9 --- /dev/null +++ b/SampoomManagement/Features/Auth/Data/Remote/DTO/LoginRequestDTO.swift @@ -0,0 +1,15 @@ +// +// LoginRequestDTO.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +struct LoginRequestDTO: Codable { + let email: String + let password: String +} + + diff --git a/SampoomManagement/Features/Auth/Data/Remote/DTO/LoginResponseDTO.swift b/SampoomManagement/Features/Auth/Data/Remote/DTO/LoginResponseDTO.swift new file mode 100644 index 0000000..dc04079 --- /dev/null +++ b/SampoomManagement/Features/Auth/Data/Remote/DTO/LoginResponseDTO.swift @@ -0,0 +1,19 @@ +// +// LoginResponseDTO.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +struct LoginResponseDTO: Codable { + let userId: Int + let userName: String + let role: String + let accessToken: String + let refreshToken: String + let expiresIn: Int +} + + diff --git a/SampoomManagement/Features/Auth/Data/Remote/DTO/SignupRequestDTO.swift b/SampoomManagement/Features/Auth/Data/Remote/DTO/SignupRequestDTO.swift new file mode 100644 index 0000000..e1d586c --- /dev/null +++ b/SampoomManagement/Features/Auth/Data/Remote/DTO/SignupRequestDTO.swift @@ -0,0 +1,19 @@ +// +// SignupRequestDTO.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +struct SignupRequestDTO: Codable { + let userName: String + let workspace: String + let branch: String + let position: String + let email: String + let password: String +} + + diff --git a/SampoomManagement/Features/Auth/Data/Remote/DTO/SignupResponseDTO.swift b/SampoomManagement/Features/Auth/Data/Remote/DTO/SignupResponseDTO.swift new file mode 100644 index 0000000..eea88cf --- /dev/null +++ b/SampoomManagement/Features/Auth/Data/Remote/DTO/SignupResponseDTO.swift @@ -0,0 +1,16 @@ +// +// SignupResponseDTO.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +struct SignupResponseDTO: Codable { + let userId: Int + let userName: String + let email: String +} + + diff --git a/SampoomManagement/Features/Auth/Data/Repository/AuthRepositoryImpl.swift b/SampoomManagement/Features/Auth/Data/Repository/AuthRepositoryImpl.swift new file mode 100644 index 0000000..69f534b --- /dev/null +++ b/SampoomManagement/Features/Auth/Data/Repository/AuthRepositoryImpl.swift @@ -0,0 +1,75 @@ +// +// AuthRepositoryImpl.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +class AuthRepositoryImpl: AuthRepository { + private let api: AuthAPI + private let preferences: AuthPreferences + + init(api: AuthAPI, preferences: AuthPreferences) { + self.api = api + self.preferences = preferences + } + + // 회원가입 + func signUp( + userName: String, + workspace: String, + branch: String, + position: String, + email: String, + password: String + ) async throws -> User { + _ = try await api.signup( + email: email, + password: password, + workspace: workspace, + branch: branch, + userName: userName, + position: position + ) + + // 회원가입 후 자동 로그인 + return try await signIn(email: email, password: password) + } + + func signIn(email: String, password: String) async throws -> User { + let response = try await api.login(email: email, password: password) + let dto = response.data + + do { + try preferences.saveToken( + accessToken: dto.accessToken, + refreshToken: dto.refreshToken + ) + } catch { + // 키체인 저장 실패 시 로깅 및 에러 전파 + print("AuthRepositoryImpl - 키체인 저장 실패: \(error)") + throw AuthError.tokenSaveFailed(error) + } + + return dto.toModel() + } + + func signOut() async throws { + preferences.clear() + } + + func isSignedIn() -> Bool { + return preferences.hasToken() + } + + // 토큰 조회 (API 요청 시 사용) + func getAccessToken() throws -> String? { + return try preferences.getAccessToken() + } + + func getRefreshToken() throws -> String? { + return try preferences.getRefreshToken() + } +} diff --git a/SampoomManagement/Features/Auth/Domain/AuthValidator.swift b/SampoomManagement/Features/Auth/Domain/AuthValidator.swift new file mode 100644 index 0000000..3b651f5 --- /dev/null +++ b/SampoomManagement/Features/Auth/Domain/AuthValidator.swift @@ -0,0 +1,72 @@ +// +// AuthValidator.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +class AuthValidator { + // 빈 값 검증 + static func validateNotEmpty(_ value: String, _ label: String) -> ValidationResult { + if value.trimmingCharacters(in: .whitespaces).isEmpty { + return .error(StringResources.Auth.fieldRequired(label)) + } + return .success + } + + // 이메일 형식 검증 + static func validateEmail(_ email: String) -> ValidationResult { + let trimmedEmail = email.trimmingCharacters(in: .whitespaces) + + if trimmedEmail.isEmpty { + return .error(StringResources.Auth.emailRequired) + } + + let emailRegex = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$" + let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailRegex) + + if !emailPredicate.evaluate(with: trimmedEmail) { + return .error(StringResources.Auth.emailInvalid) + } + + return .success + } + + // 비밀번호 검증 + static func validatePassword(_ password: String) -> ValidationResult { + if password.isEmpty { + return .error(StringResources.Auth.passwordRequired) + } + + if password.count < 8 { + return .error(StringResources.Auth.passwordTooShort) + } + + // 영문, 숫자 포함 여부 확인 + let hasLetter = password.rangeOfCharacter(from: .letters) != nil + let hasNumber = password.rangeOfCharacter(from: .decimalDigits) != nil + + if !hasLetter || !hasNumber { + return .error(StringResources.Auth.passwordInvalid) + } + + return .success + } + + // 비밀번호 확인 검증 + static func validatePasswordCheck(_ password: String, _ passwordCheck: String) -> ValidationResult { + if passwordCheck.isEmpty { + return .error(StringResources.Auth.passwordCheckRequired) + } + + if password != passwordCheck { + return .error(StringResources.Auth.passwordCheckMismatch) + } + + return .success + } +} + + diff --git a/SampoomManagement/Features/Auth/Domain/Models/User.swift b/SampoomManagement/Features/Auth/Domain/Models/User.swift new file mode 100644 index 0000000..c73011d --- /dev/null +++ b/SampoomManagement/Features/Auth/Domain/Models/User.swift @@ -0,0 +1,17 @@ +// +// User.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import Foundation + +struct User: Identifiable, Codable, Equatable { + let id: Int + let name: String + let role: String + let accessToken: String + let refreshToken: String + let expiresIn: Int +} diff --git a/SampoomManagement/Features/Auth/Domain/Repository/AuthRepository.swift b/SampoomManagement/Features/Auth/Domain/Repository/AuthRepository.swift new file mode 100644 index 0000000..8a44ef0 --- /dev/null +++ b/SampoomManagement/Features/Auth/Domain/Repository/AuthRepository.swift @@ -0,0 +1,23 @@ +// +// AuthRepository.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +protocol AuthRepository { + func signUp( + userName: String, + workspace: String, + branch: String, + position: String, + email: String, + password: String + ) async throws -> User + + func signIn(email: String, password: String) async throws -> User + func signOut() async throws + func isSignedIn() -> Bool +} diff --git a/SampoomManagement/Features/Auth/Domain/UseCase/LoginUseCase.swift b/SampoomManagement/Features/Auth/Domain/UseCase/LoginUseCase.swift new file mode 100644 index 0000000..9f3b9b5 --- /dev/null +++ b/SampoomManagement/Features/Auth/Domain/UseCase/LoginUseCase.swift @@ -0,0 +1,21 @@ +// +// LoginUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +class LoginUseCase { + private let repository: AuthRepository + + init(repository: AuthRepository) { + self.repository = repository + } + + func execute(email: String, password: String) async throws -> User { + return try await repository.signIn(email: email, password: password) + } +} + diff --git a/SampoomManagement/Features/Auth/Domain/UseCase/SignUpUseCase.swift b/SampoomManagement/Features/Auth/Domain/UseCase/SignUpUseCase.swift new file mode 100644 index 0000000..4ccf761 --- /dev/null +++ b/SampoomManagement/Features/Auth/Domain/UseCase/SignUpUseCase.swift @@ -0,0 +1,35 @@ +// +// SignUpUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +class SignUpUseCase { + private let repository: AuthRepository + + init(repository: AuthRepository) { + self.repository = repository + } + + func execute( + userName: String, + workspace: String, + branch: String, + position: String, + email: String, + password: String + ) async throws -> User { + return try await repository.signUp( + userName: userName, + workspace: workspace, + branch: branch, + position: position, + email: email, + password: password + ) + } +} + diff --git a/SampoomManagement/Features/Auth/Domain/ValidationResult.swift b/SampoomManagement/Features/Auth/Domain/ValidationResult.swift new file mode 100644 index 0000000..5e10ad1 --- /dev/null +++ b/SampoomManagement/Features/Auth/Domain/ValidationResult.swift @@ -0,0 +1,29 @@ +// +// ValidationResult.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +enum ValidationResult { + case success + case error(String) + + var isSuccess: Bool { + if case .success = self { + return true + } + return false + } + + var errorMessage: String? { + if case .error(let message) = self { + return message + } + return nil + } +} + + diff --git a/SampoomManagement/Features/Auth/UI/LoginUiEvent.swift b/SampoomManagement/Features/Auth/UI/LoginUiEvent.swift new file mode 100644 index 0000000..8a6d00e --- /dev/null +++ b/SampoomManagement/Features/Auth/UI/LoginUiEvent.swift @@ -0,0 +1,16 @@ +// +// LoginUiEvent.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +enum LoginUiEvent { + case emailChanged(String) + case passwordChanged(String) + case submit +} + + diff --git a/SampoomManagement/Features/Auth/UI/LoginUiState.swift b/SampoomManagement/Features/Auth/UI/LoginUiState.swift new file mode 100644 index 0000000..e9e468c --- /dev/null +++ b/SampoomManagement/Features/Auth/UI/LoginUiState.swift @@ -0,0 +1,68 @@ +// +// LoginUiState.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +struct LoginUiState: UIState { + let email: String + let password: String + + // Error message + let emailError: String? + let passwordError: String? + + let loading: Bool + let error: String? + let success: Bool + + init( + email: String = "", + password: String = "", + emailError: String? = nil, + passwordError: String? = nil, + loading: Bool = false, + error: String? = nil, + success: Bool = false + ) { + self.email = email + self.password = password + self.emailError = emailError + self.passwordError = passwordError + self.loading = loading + self.error = error + self.success = success + } + + var isValid: Bool { + return !email.isEmpty && + !password.isEmpty && + emailError == nil && + passwordError == nil + } + + func copy( + email: String? = nil, + password: String? = nil, + emailError: String?? = nil, + passwordError: String?? = nil, + loading: Bool? = nil, + error: String?? = nil, + success: Bool? = nil + ) -> LoginUiState { + return LoginUiState( + email: email ?? self.email, + password: password ?? self.password, + emailError: emailError ?? self.emailError, + passwordError: passwordError ?? self.passwordError, + loading: loading ?? self.loading, + error: error ?? self.error, + success: success ?? self.success + ) + } +} + + diff --git a/SampoomManagement/Features/Auth/UI/LoginView.swift b/SampoomManagement/Features/Auth/UI/LoginView.swift new file mode 100644 index 0000000..32a0d96 --- /dev/null +++ b/SampoomManagement/Features/Auth/UI/LoginView.swift @@ -0,0 +1,118 @@ +// +// LoginView.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import SwiftUI +import Toast + +struct LoginView: View { + @ObservedObject var viewModel: LoginViewModel + @StateObject private var keyboardObserver = KeyboardObserver() + @State private var email = "" + @State private var password = "" + + let onSuccess: () -> Void + let onNavigateSignUp: () -> Void + + var body: some View { + VStack { + Spacer() + + // 로고 + Image("square_logo") + .resizable() + .scaledToFit() + .frame(width: 120, height: 120) + + Spacer() + .frame(height: 48) + + // 이메일 입력 + CommonTextField( + value: $email, + placeholder: StringResources.Auth.emailPlaceholder, + type: .email, + isError: viewModel.uiState.emailError != nil, + errorMessage: viewModel.uiState.emailError + ) { text in + viewModel.updateEmail(text) + } + + Spacer() + .frame(height: 16) + + // 비밀번호 입력 + CommonTextField( + value: $password, + placeholder: StringResources.Auth.passwordPlaceholder, + type: .password, + isError: viewModel.uiState.passwordError != nil, + errorMessage: viewModel.uiState.passwordError + ) { text in + viewModel.updatePassword(text) + } + + Spacer() + .frame(height: 48) + + // 로그인 버튼 + CommonButton( + viewModel.uiState.loading + ? StringResources.Auth.loginButtonLoading + : StringResources.Auth.loginButton, + isEnabled: viewModel.uiState.isValid && !viewModel.uiState.loading + ) { + viewModel.submit() + } + + Spacer() + + // 회원가입 안내 + Button(action: onNavigateSignUp) { + HStack(spacing: 4) { + Text(StringResources.Auth.needAccount) + .font(.gmarketBody) + .foregroundColor(.text) + Text(StringResources.Auth.signUpLink) + .font(.gmarketBody) + .foregroundColor(.accentColor) + .underline() + + + Text(StringResources.Auth.signUpDo) + .font(.gmarketBody) + .foregroundColor(.text) + } + } + .padding(.bottom, 32) + } + .padding(.horizontal, 16) + //.offset(y: keyboardObserver.keyboardHeight > 0 ? -keyboardObserver.keyboardHeight / 2.5 : 0) + .contentShape(Rectangle()) + .onTapGesture { + hideKeyboard() + } + .onChange(of: viewModel.uiState.success) { _, success in + if success { + onSuccess() + } + } + .onChange(of: viewModel.uiState.error) { _, error in + if let message = error, !message.isEmpty { + // 타임스탬프 제거하여 순수한 에러 메시지만 표시 + let cleanMessage = message.components(separatedBy: "_").first ?? message + Toast.text(cleanMessage).show() + viewModel.consumeError() + } + } + } + + // MARK: - Helper Methods + + private func hideKeyboard() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } +} diff --git a/SampoomManagement/Features/Auth/UI/LoginViewModel.swift b/SampoomManagement/Features/Auth/UI/LoginViewModel.swift new file mode 100644 index 0000000..0671667 --- /dev/null +++ b/SampoomManagement/Features/Auth/UI/LoginViewModel.swift @@ -0,0 +1,90 @@ +// +// LoginViewModel.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation +import SwiftUI +import Combine + +@MainActor +class LoginViewModel: ObservableObject { + @Published var uiState = LoginUiState() + + private let loginUseCase: LoginUseCase + + init(loginUseCase: LoginUseCase) { + self.loginUseCase = loginUseCase + } + + // 이메일 업데이트 + func updateEmail(_ email: String) { + uiState = uiState.copy(email: email) + validateEmail() + } + + // 비밀번호 업데이트 + func updatePassword(_ password: String) { + uiState = uiState.copy(password: password) + validatePassword() + } + + // 로그인 제출 + func submit() { + Task { + validateEmail() + validatePassword() + + guard uiState.isValid else { return } + + let email = uiState.email + let password = uiState.password + + uiState = uiState.copy(loading: true, error: nil) + + do { + _ = try await loginUseCase.execute(email: email, password: password) + uiState = uiState.copy(loading: false, success: true) + } catch { + uiState = uiState.copy(loading: false) + showError(error.localizedDescription) + } + } + } + + // 에러 소비 (Toast 표시 후 에러 상태 제거) + func consumeError() { + uiState = uiState.copy(error: nil) + } + + // 에러 표시를 위한 강제 상태 변경 + private func showError(_ message: String) { + // 타임스탬프를 추가하여 항상 다른 값으로 만들어 onChange 트리거 보장 + uiState = uiState.copy(error: "\(message)_\(Date().timeIntervalSince1970)") + } + + // MARK: - Private Methods + + private func validateEmail() { + let emptyResult = AuthValidator.validateNotEmpty( + uiState.email, + StringResources.Auth.emailLabel + ) + if !emptyResult.isSuccess { + uiState = uiState.copy(emailError: emptyResult.errorMessage) + return + } + let emailResult = AuthValidator.validateEmail(uiState.email) + uiState = uiState.copy(emailError: emailResult.errorMessage) + } + + private func validatePassword() { + let result = AuthValidator.validateNotEmpty( + uiState.password, + StringResources.Auth.passwordLabel + ) + uiState = uiState.copy(passwordError: result.errorMessage) + } +} diff --git a/SampoomManagement/Features/Auth/UI/SignUpUiEvent.swift b/SampoomManagement/Features/Auth/UI/SignUpUiEvent.swift new file mode 100644 index 0000000..ab58fc8 --- /dev/null +++ b/SampoomManagement/Features/Auth/UI/SignUpUiEvent.swift @@ -0,0 +1,20 @@ +// +// SignUpUiEvent.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +enum SignUpUiEvent { + case nameChanged(String) + case branchChanged(String) + case positionChanged(String) + case emailChanged(String) + case passwordChanged(String) + case passwordCheckChanged(String) + case submit +} + + diff --git a/SampoomManagement/Features/Auth/UI/SignUpUiState.swift b/SampoomManagement/Features/Auth/UI/SignUpUiState.swift new file mode 100644 index 0000000..b24284b --- /dev/null +++ b/SampoomManagement/Features/Auth/UI/SignUpUiState.swift @@ -0,0 +1,121 @@ +// +// SignUpUiState.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +struct SignUpUiState: UIState { + let name: String + let workspace: String + let branch: String + let position: String + let email: String + let password: String + let passwordCheck: String + + // Error message + let nameError: String? + let branchError: String? + let positionError: String? + let emailError: String? + let passwordError: String? + let passwordCheckError: String? + + let loading: Bool + let error: String? + let success: Bool + + init( + name: String = "", + workspace: String = "대리점", + branch: String = "", + position: String = "", + email: String = "", + password: String = "", + passwordCheck: String = "", + nameError: String? = nil, + branchError: String? = nil, + positionError: String? = nil, + emailError: String? = nil, + passwordError: String? = nil, + passwordCheckError: String? = nil, + loading: Bool = false, + error: String? = nil, + success: Bool = false + ) { + self.name = name + self.workspace = workspace + self.branch = branch + self.position = position + self.email = email + self.password = password + self.passwordCheck = passwordCheck + self.nameError = nameError + self.branchError = branchError + self.positionError = positionError + self.emailError = emailError + self.passwordError = passwordError + self.passwordCheckError = passwordCheckError + self.loading = loading + self.error = error + self.success = success + } + + var isValid: Bool { + return !name.isEmpty && + !branch.isEmpty && + !position.isEmpty && + !email.isEmpty && + !password.isEmpty && + !passwordCheck.isEmpty && + nameError == nil && + branchError == nil && + positionError == nil && + emailError == nil && + passwordError == nil && + passwordCheckError == nil + } + + func copy( + name: String? = nil, + workspace: String? = nil, + branch: String? = nil, + position: String? = nil, + email: String? = nil, + password: String? = nil, + passwordCheck: String? = nil, + nameError: String?? = nil, + branchError: String?? = nil, + positionError: String?? = nil, + emailError: String?? = nil, + passwordError: String?? = nil, + passwordCheckError: String?? = nil, + loading: Bool? = nil, + error: String?? = nil, + success: Bool? = nil + ) -> SignUpUiState { + return SignUpUiState( + name: name ?? self.name, + workspace: workspace ?? self.workspace, + branch: branch ?? self.branch, + position: position ?? self.position, + email: email ?? self.email, + password: password ?? self.password, + passwordCheck: passwordCheck ?? self.passwordCheck, + nameError: nameError ?? self.nameError, + branchError: branchError ?? self.branchError, + positionError: positionError ?? self.positionError, + emailError: emailError ?? self.emailError, + passwordError: passwordError ?? self.passwordError, + passwordCheckError: passwordCheckError ?? self.passwordCheckError, + loading: loading ?? self.loading, + error: error ?? self.error, + success: success ?? self.success + ) + } +} + + diff --git a/SampoomManagement/Features/Auth/UI/SignUpView.swift b/SampoomManagement/Features/Auth/UI/SignUpView.swift new file mode 100644 index 0000000..4f1b907 --- /dev/null +++ b/SampoomManagement/Features/Auth/UI/SignUpView.swift @@ -0,0 +1,180 @@ +// +// SignUpView.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import SwiftUI +import Toast + +struct SignUpView: View { + @ObservedObject var viewModel: SignUpViewModel + @StateObject private var keyboardObserver = KeyboardObserver() + @State private var name = "" + @State private var branch = "" + @State private var position = "" + @State private var email = "" + @State private var password = "" + @State private var passwordCheck = "" + + let onSuccess: () -> Void + + private let labelTextSize: CGFloat = 16 + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + // 로고 + Image("oneline_logo") + .resizable() + .scaledToFit() + .frame(width: 120) + .frame(alignment: .leading) + + Spacer() + .frame(height: 48) + + // 이름 + Text(StringResources.Auth.nameLabel) + .font(.gmarketBody) + .foregroundColor(Color("Text")) + .padding(.bottom, 4) + CommonTextField( + value: $name, + placeholder: StringResources.Auth.namePlaceholder, + isError: viewModel.uiState.nameError != nil, + errorMessage: viewModel.uiState.nameError + ) { text in + viewModel.updateName(text) + } + + Spacer() + .frame(height: 8) + + // 지점 + Text(StringResources.Auth.branchLabel) + .font(.gmarketBody) + .foregroundColor(Color("Text")) + .padding(.bottom, 4) + CommonTextField( + value: $branch, + placeholder: StringResources.Auth.branchPlaceholder, + isError: viewModel.uiState.branchError != nil, + errorMessage: viewModel.uiState.branchError + ) { text in + viewModel.updateBranch(text) + } + + Spacer() + .frame(height: 8) + + // 직급 + Text(StringResources.Auth.positionLabel) + .font(.gmarketBody) + .foregroundColor(Color("Text")) + .padding(.bottom, 4) + CommonTextField( + value: $position, + placeholder: StringResources.Auth.positionPlaceholder, + isError: viewModel.uiState.positionError != nil, + errorMessage: viewModel.uiState.positionError + ) { text in + viewModel.updatePosition(text) + } + + Spacer() + .frame(height: 8) + + // 이메일 + Text(StringResources.Auth.emailLabel) + .font(.gmarketBody) + .foregroundColor(Color("Text")) + .padding(.bottom, 4) + CommonTextField( + value: $email, + placeholder: StringResources.Auth.emailPlaceholder, + type: .email, + isError: viewModel.uiState.emailError != nil, + errorMessage: viewModel.uiState.emailError + ) { text in + viewModel.updateEmail(text) + } + + Spacer() + .frame(height: 8) + + // 비밀번호 + Text(StringResources.Auth.passwordLabel) + .font(.gmarketBody) + .foregroundColor(Color("Text")) + .padding(.bottom, 4) + CommonTextField( + value: $password, + placeholder: StringResources.Auth.passwordPlaceholder, + type: .password, + isError: viewModel.uiState.passwordError != nil, + errorMessage: viewModel.uiState.passwordError + ) { text in + viewModel.updatePassword(text) + } + + Spacer() + .frame(height: 8) + + // 비밀번호 확인 + Text(StringResources.Auth.passwordCheckLabel) + .font(.gmarketBody) + .foregroundColor(Color("Text")) + .padding(.bottom, 4) + CommonTextField( + value: $passwordCheck, + placeholder: StringResources.Auth.passwordCheckPlaceholder, + type: .password, + isError: viewModel.uiState.passwordCheckError != nil, + errorMessage: viewModel.uiState.passwordCheckError + ) { text in + viewModel.updatePasswordCheck(text) + } + + Spacer() + .frame(height: 48) + + // 회원가입 버튼 + CommonButton( + viewModel.uiState.loading + ? StringResources.Auth.signUpButtonLoading + : StringResources.Auth.signUpButton, + isEnabled: viewModel.uiState.isValid && !viewModel.uiState.loading + ) { + viewModel.submit() + } + } + .padding(.horizontal, 16) + .padding(.vertical, 16) + } + .contentShape(Rectangle()) + .onTapGesture { + hideKeyboard() + } + .onChange(of: viewModel.uiState.success) { _, success in + if success { + onSuccess() + } + } + .onChange(of: viewModel.uiState.error) { _, error in + if let message = error, !message.isEmpty { + // 타임스탬프 제거하여 순수한 에러 메시지만 표시 + let cleanMessage = message.components(separatedBy: "_").first ?? message + Toast.text(cleanMessage).show() + viewModel.consumeError() + } + } + } + + // MARK: - Helper Methods + + private func hideKeyboard() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } +} diff --git a/SampoomManagement/Features/Auth/UI/SignUpViewModel.swift b/SampoomManagement/Features/Auth/UI/SignUpViewModel.swift new file mode 100644 index 0000000..a11504f --- /dev/null +++ b/SampoomManagement/Features/Auth/UI/SignUpViewModel.swift @@ -0,0 +1,153 @@ +// +// SignUpViewModel.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation +import SwiftUI +import Combine + +@MainActor +class SignUpViewModel: ObservableObject { + @Published var uiState = SignUpUiState() + + private let signUpUseCase: SignUpUseCase + + init(signUpUseCase: SignUpUseCase) { + self.signUpUseCase = signUpUseCase + } + + // 이름 업데이트 + func updateName(_ name: String) { + uiState = uiState.copy(name: name) + validateName() + } + + // 지점 업데이트 + func updateBranch(_ branch: String) { + uiState = uiState.copy(branch: branch) + validateBranch() + } + + // 직급 업데이트 + func updatePosition(_ position: String) { + uiState = uiState.copy(position: position) + validatePosition() + } + + // 이메일 업데이트 + func updateEmail(_ email: String) { + uiState = uiState.copy(email: email) + validateEmail() + } + + // 비밀번호 업데이트 + func updatePassword(_ password: String) { + uiState = uiState.copy(password: password) + validatePassword() + if !uiState.passwordCheck.isEmpty { + validatePasswordCheck() + } + } + + // 비밀번호 확인 업데이트 + func updatePasswordCheck(_ passwordCheck: String) { + uiState = uiState.copy(passwordCheck: passwordCheck) + validatePasswordCheck() + } + + // 회원가입 제출 + func submit() { + Task { + validateName() + validateBranch() + validatePosition() + validateEmail() + validatePassword() + validatePasswordCheck() + + guard uiState.isValid else { return } + + let name = uiState.name + let workspace = uiState.workspace + let branch = uiState.branch + let position = uiState.position + let email = uiState.email + let password = uiState.password + + uiState = uiState.copy(loading: true, error: nil) + + do { + _ = try await signUpUseCase.execute( + userName: name, + workspace: workspace, + branch: branch, + position: position, + email: email, + password: password + ) + uiState = uiState.copy(loading: false, success: true) + } catch { + uiState = uiState.copy(loading: false) + showError(error.localizedDescription) + } + } + } + + // 에러 소비 (Toast 표시 후 에러 상태 제거) + func consumeError() { + uiState = uiState.copy(error: nil) + } + + // 에러 표시를 위한 강제 상태 변경 + private func showError(_ message: String) { + // 타임스탬프를 추가하여 항상 다른 값으로 만들어 onChange 트리거 보장 + uiState = uiState.copy(error: "\(message)_\(Date().timeIntervalSince1970)") + } + + // MARK: - Private Methods + + private func validateName() { + let result = AuthValidator.validateNotEmpty( + uiState.name, + StringResources.Auth.nameLabel + ) + uiState = uiState.copy(nameError: result.errorMessage) + } + + private func validateBranch() { + let result = AuthValidator.validateNotEmpty( + uiState.branch, + StringResources.Auth.branchLabel + ) + uiState = uiState.copy(branchError: result.errorMessage) + } + + private func validatePosition() { + let result = AuthValidator.validateNotEmpty( + uiState.position, + StringResources.Auth.positionLabel + ) + uiState = uiState.copy(positionError: result.errorMessage) + } + + private func validateEmail() { + let result = AuthValidator.validateEmail(uiState.email) + uiState = uiState.copy(emailError: result.errorMessage) + } + + private func validatePassword() { + let result = AuthValidator.validatePassword(uiState.password) + uiState = uiState.copy(passwordError: result.errorMessage) + } + + private func validatePasswordCheck() { + let result = AuthValidator.validatePasswordCheck( + uiState.password, + uiState.passwordCheck + ) + uiState = uiState.copy(passwordCheckError: result.errorMessage) + } +} diff --git a/SampoomManagement/Features/Part/DI/PartDIModule.swift b/SampoomManagement/Features/Part/DI/PartDIModule.swift deleted file mode 100644 index b628e6c..0000000 --- a/SampoomManagement/Features/Part/DI/PartDIModule.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// PartDIModule.swift -// SampoomManagement -// -// Created by 채상윤 on 9/29/25. -// - -import Foundation -import Swinject - -final class PartDIModule: Assembly { - func assemble(container: Container) { - // MARK: - Part Feature Dependencies - - // PartAPI 등록 - container.register(PartAPI.self) { resolver in - PartAPI(networkManager: resolver.resolve(NetworkManager.self)!) - }.inObjectScope(.container) - - // PartRepository 등록 (Interface -> Implementation) - container.register(PartRepository.self) { resolver in - PartRepositoryImpl(api: resolver.resolve(PartAPI.self)!) - }.inObjectScope(.container) - - // GetPartUseCase 등록 - container.register(GetPartUseCase.self) { resolver in - GetPartUseCase(repository: resolver.resolve(PartRepository.self)!) - }.inObjectScope(.container) - - // PartViewModel 등록 - container.register(PartViewModel.self) { resolver in - PartViewModel(getPartUseCase: resolver.resolve(GetPartUseCase.self)!) - }.inObjectScope(.container) - } -} diff --git a/SampoomManagement/Resources/Info.plist b/SampoomManagement/Info.plist similarity index 53% rename from SampoomManagement/Resources/Info.plist rename to SampoomManagement/Info.plist index 781d890..974a51b 100644 --- a/SampoomManagement/Resources/Info.plist +++ b/SampoomManagement/Info.plist @@ -2,6 +2,11 @@ - + UIAppFonts + + GmarketSansBold.otf + GmarketSansLight.otf + GmarketSansMedium.otf + diff --git a/SampoomManagement/Resources/Assets.xcassets/Disable.colorset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/Disable.colorset/Contents.json new file mode 100644 index 0000000..f267be4 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/Disable.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xEC", + "green" : "0xEA", + "red" : "0xE9" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x44", + "green" : "0x44", + "red" : "0x44" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/TextSecondary.colorset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/TextSecondary.colorset/Contents.json new file mode 100644 index 0000000..6e89205 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/TextSecondary.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x7C", + "green" : "0x7C", + "red" : "0x7C" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xCC", + "green" : "0xCC", + "red" : "0xCC" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/oneline_logo.imageset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/oneline_logo.imageset/Contents.json new file mode 100644 index 0000000..993f3bb --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/oneline_logo.imageset/Contents.json @@ -0,0 +1,32 @@ +{ + "images" : [ + { + "filename" : "oneline_logo.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "oneline_logo 1.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "oneline_logo_dark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/oneline_logo.imageset/oneline_logo 1.svg b/SampoomManagement/Resources/Assets.xcassets/oneline_logo.imageset/oneline_logo 1.svg new file mode 100644 index 0000000..81fed1b --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/oneline_logo.imageset/oneline_logo 1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SampoomManagement/Resources/Assets.xcassets/oneline_logo.imageset/oneline_logo.svg b/SampoomManagement/Resources/Assets.xcassets/oneline_logo.imageset/oneline_logo.svg new file mode 100644 index 0000000..81fed1b --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/oneline_logo.imageset/oneline_logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SampoomManagement/Resources/Assets.xcassets/oneline_logo.imageset/oneline_logo_dark.svg b/SampoomManagement/Resources/Assets.xcassets/oneline_logo.imageset/oneline_logo_dark.svg new file mode 100644 index 0000000..0c394f7 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/oneline_logo.imageset/oneline_logo_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SampoomManagement/Resources/Assets.xcassets/square_logo.imageset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/square_logo.imageset/Contents.json new file mode 100644 index 0000000..eb77ad9 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/square_logo.imageset/Contents.json @@ -0,0 +1,32 @@ +{ + "images" : [ + { + "filename" : "square_logo_light.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "square_logo_light 1.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "square_logo_dark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/square_logo.imageset/square_logo_dark.svg b/SampoomManagement/Resources/Assets.xcassets/square_logo.imageset/square_logo_dark.svg new file mode 100644 index 0000000..755b00d --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/square_logo.imageset/square_logo_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SampoomManagement/Resources/Assets.xcassets/square_logo.imageset/square_logo_light 1.svg b/SampoomManagement/Resources/Assets.xcassets/square_logo.imageset/square_logo_light 1.svg new file mode 100644 index 0000000..c4e76e9 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/square_logo.imageset/square_logo_light 1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SampoomManagement/Resources/Assets.xcassets/square_logo.imageset/square_logo_light.svg b/SampoomManagement/Resources/Assets.xcassets/square_logo.imageset/square_logo_light.svg new file mode 100644 index 0000000..c4e76e9 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/square_logo.imageset/square_logo_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SampoomManagement/Resources/Fonts/GmarketSansBold.otf b/SampoomManagement/Resources/Fonts/GmarketSansBold.otf new file mode 100644 index 0000000..3a7ab60 Binary files /dev/null and b/SampoomManagement/Resources/Fonts/GmarketSansBold.otf differ diff --git a/SampoomManagement/Resources/Fonts/GmarketSansLight.otf b/SampoomManagement/Resources/Fonts/GmarketSansLight.otf new file mode 100644 index 0000000..c588d3e Binary files /dev/null and b/SampoomManagement/Resources/Fonts/GmarketSansLight.otf differ diff --git a/SampoomManagement/Resources/Fonts/GmarketSansMedium.otf b/SampoomManagement/Resources/Fonts/GmarketSansMedium.otf new file mode 100644 index 0000000..af2cfc3 Binary files /dev/null and b/SampoomManagement/Resources/Fonts/GmarketSansMedium.otf differ