diff --git a/SodaLive.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SodaLive.xcworkspace/xcshareddata/swiftpm/Package.resolved index 48985a2..3aa7930 100644 --- a/SodaLive.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SodaLive.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -113,8 +113,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/exyte/PopupView.git", "state" : { - "revision" : "68349a0ae704b9a7041f756f3f4f460ddbf7ba8d", - "version" : "2.6.0" + "revision" : "1b99d6e9872ef91fd57aaef657661b5a00069638", + "version" : "1.3.1" } }, { diff --git a/SodaLive/Resources/Assets.xcassets/btn_radio_select_normal.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/btn_radio_select_normal.imageset/Contents.json new file mode 100644 index 0000000..b9e05de --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/btn_radio_select_normal.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "btn_radio_select_normal.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/btn_radio_select_normal.imageset/btn_radio_select_normal.png b/SodaLive/Resources/Assets.xcassets/btn_radio_select_normal.imageset/btn_radio_select_normal.png new file mode 100644 index 0000000..40f508a Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/btn_radio_select_normal.imageset/btn_radio_select_normal.png differ diff --git a/SodaLive/Resources/Assets.xcassets/btn_radio_select_selected.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/btn_radio_select_selected.imageset/Contents.json new file mode 100644 index 0000000..12acf8c --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/btn_radio_select_selected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "btn_radio_select_selected.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/btn_radio_select_selected.imageset/btn_radio_select_selected.png b/SodaLive/Resources/Assets.xcassets/btn_radio_select_selected.imageset/btn_radio_select_selected.png new file mode 100644 index 0000000..57f23d1 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/btn_radio_select_selected.imageset/btn_radio_select_selected.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_camera.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_camera.imageset/Contents.json new file mode 100644 index 0000000..e6dcb1d --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_camera.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_camera.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_camera.imageset/ic_camera.png b/SodaLive/Resources/Assets.xcassets/ic_camera.imageset/ic_camera.png new file mode 100644 index 0000000..3d21d1f Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_camera.imageset/ic_camera.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_logo.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_logo.imageset/Contents.json new file mode 100644 index 0000000..6531421 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_logo.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_logo.imageset/ic_logo.png b/SodaLive/Resources/Assets.xcassets/ic_logo.imageset/ic_logo.png new file mode 100644 index 0000000..e56aed8 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_logo.imageset/ic_logo.png differ diff --git a/SodaLive/Sources/Common/BaseView.swift b/SodaLive/Sources/Common/BaseView.swift new file mode 100644 index 0000000..3a65447 --- /dev/null +++ b/SodaLive/Sources/Common/BaseView.swift @@ -0,0 +1,38 @@ +// +// BaseView.swift +// SodaLive +// +// Created by klaus on 2023/08/09. +// + +import SwiftUI + +struct BaseView: View { + + let content: Content + + @Binding var isLoading: Bool + + init(isLoading: Binding = .constant(false), @ViewBuilder content: () -> Content) { + self._isLoading = isLoading + self.content = content() + } + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + + content + + if isLoading { + LoadingView() + } + } + } +} + +struct BaseView_Previews: PreviewProvider { + static var previews: some View { + BaseView(isLoading: .constant(false)) {} + } +} diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index 45b1a83..35c5288 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -20,6 +20,9 @@ struct ContentView: View { case .splash: SplashView() + case .signUp: + SignUpView() + default: EmptyView() .frame(width: 0, height: 0, alignment: .topLeading) diff --git a/SodaLive/Sources/ImagePicker/ImagePicker.swift b/SodaLive/Sources/ImagePicker/ImagePicker.swift new file mode 100644 index 0000000..728af30 --- /dev/null +++ b/SodaLive/Sources/ImagePicker/ImagePicker.swift @@ -0,0 +1,48 @@ +// +// ImagePicker.swift +// SodaLive +// +// Created by klaus on 2023/08/09. +// + +import SwiftUI + +struct ImagePicker: UIViewControllerRepresentable { + + @Binding var isShowing: Bool + @Binding var selectedImage: UIImage? + + let sourceType: UIImagePickerController.SourceType + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) { + } + + func makeCoordinator() -> Coordinator { + return Coordinator(self) + } +} + +class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + let parent: ImagePicker + + init(_ parent: ImagePicker) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + if let image = info[.originalImage] as? UIImage { + parent.selectedImage = image + parent.isShowing = false + } + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + parent.isShowing = false + } +} diff --git a/SodaLive/Sources/Keyboard/KeyboardHandler.swift b/SodaLive/Sources/Keyboard/KeyboardHandler.swift new file mode 100644 index 0000000..f350acf --- /dev/null +++ b/SodaLive/Sources/Keyboard/KeyboardHandler.swift @@ -0,0 +1,29 @@ +// +// KeyboardHandler.swift +// SodaLive +// +// Created by klaus on 2023/08/09. +// + +import SwiftUI +import Combine + +final class KeyboardHandler: ObservableObject { + @Published private(set) var keyboardHeight: CGFloat = 0 + + private var cancellable: AnyCancellable? + + private let keyboardWillShow = NotificationCenter.default + .publisher(for: UIResponder.keyboardWillShowNotification) + .compactMap { ($0.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect)?.height } + + private let keyboardWillHide = NotificationCenter.default + .publisher(for: UIResponder.keyboardWillHideNotification) + .map { _ in CGFloat.zero } + + init() { + cancellable = Publishers.Merge(keyboardWillShow, keyboardWillHide) + .subscribe(on: DispatchQueue.main) + .assign(to: \.self.keyboardHeight, on: self) + } +} diff --git a/SodaLive/Sources/NavigationBar/DetailNavigationBar.swift b/SodaLive/Sources/NavigationBar/DetailNavigationBar.swift new file mode 100644 index 0000000..1d4d0ba --- /dev/null +++ b/SodaLive/Sources/NavigationBar/DetailNavigationBar.swift @@ -0,0 +1,45 @@ +// +// DetailNavigationBar.swift +// SodaLive +// +// Created by klaus on 2023/08/09. +// + +import SwiftUI + +struct DetailNavigationBar: View { + + let title: String + var backAction: (() -> Void)? = nil + + var body: some View { + HStack(spacing: 0) { + Button { + if let backAction = backAction { + backAction() + } else { + AppState.shared.back() + } + } label: { + Image("ic_back") + .resizable() + .frame(width: 20, height: 20) + + Text(title) + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(Color(hex: "eeeeee")) + } + + Spacer() + } + .padding(.horizontal, 13.3) + .frame(height: 50) + .background(Color.black) + } +} + +struct DetailNavigationBar_Previews: PreviewProvider { + static var previews: some View { + DetailNavigationBar(title: "이전으로") + } +} diff --git a/SodaLive/Sources/User/Gender.swift b/SodaLive/Sources/User/Gender.swift new file mode 100644 index 0000000..104be57 --- /dev/null +++ b/SodaLive/Sources/User/Gender.swift @@ -0,0 +1,12 @@ +// +// Gender.swift +// SodaLive +// +// Created by klaus on 2023/08/09. +// + +import Foundation + +enum Gender: String, Codable { + case MALE, FEMALE, NONE +} diff --git a/SodaLive/Sources/User/Login/LoginView.swift b/SodaLive/Sources/User/Login/LoginView.swift index 211efce..13356ec 100644 --- a/SodaLive/Sources/User/Login/LoginView.swift +++ b/SodaLive/Sources/User/Login/LoginView.swift @@ -13,9 +13,7 @@ struct LoginView: View { @ObservedObject var viewModel = LoginViewModel() var body: some View { - ZStack { - Color.black.ignoresSafeArea() - + BaseView(isLoading: $viewModel.isLoading) { VStack(spacing: 0) { HomeNavigationBar(title: "로그인") {} @@ -69,9 +67,22 @@ struct LoginView: View { Spacer() } - - if viewModel.isLoading { - LoadingView() + } + .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) { + GeometryReader { geo in + HStack { + Spacer() + Text(viewModel.errorMessage) + .padding(.vertical, 13.3) + .frame(width: geo.size.width - 66.7, alignment: .center) + .font(.custom(Font.medium.rawValue, size: 12)) + .background(Color(hex: "9970ff")) + .foregroundColor(Color.white) + .multilineTextAlignment(.center) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } } } } diff --git a/SodaLive/Sources/User/SignUp/SignUpRequest.swift b/SodaLive/Sources/User/SignUp/SignUpRequest.swift new file mode 100644 index 0000000..f43837a --- /dev/null +++ b/SodaLive/Sources/User/SignUp/SignUpRequest.swift @@ -0,0 +1,18 @@ +// +// SignUpRequest.swift +// SodaLive +// +// Created by klaus on 2023/08/09. +// + +import Foundation + +struct SignUpRequest: Encodable { + let email: String + let password: String + let nickname: String + let gender: Gender + let isAgreeTermsOfService: Bool + let isAgreePrivacyPolicy: Bool + let container: String = "ios" +} diff --git a/SodaLive/Sources/User/SignUp/SignUpView.swift b/SodaLive/Sources/User/SignUp/SignUpView.swift new file mode 100644 index 0000000..dc70a50 --- /dev/null +++ b/SodaLive/Sources/User/SignUp/SignUpView.swift @@ -0,0 +1,347 @@ +// +// SignUpView.swift +// SodaLive +// +// Created by klaus on 2023/08/09. +// + +import SwiftUI + +struct SignUpView: View { + + @ObservedObject var viewModel = SignUpViewModel() + + @StateObject var keyboardHandler = KeyboardHandler() + + @State private var isShowPhotoPicker = false + + var body: some View { + BaseView(isLoading: $viewModel.isLoading) { + GeometryReader { proxy in + VStack(spacing: 0) { + DetailNavigationBar(title: viewModel.step == .step2 ? "프로필 설정" : "회원가입") { + viewModel.prevStep() + } + + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 13.3) { + if viewModel.step == .step1 { + EmailPasswordView() + + TermsOfServiceAgreeView() + + Text("다음") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(Color.white) + .padding(.vertical, 16) + .frame(width: screenSize().width - 53.4) + .background(Color(hex: "9970ff")) + .cornerRadius(10) + .padding(.vertical, 13.7) + .frame(width: screenSize().width - 26.7) + .background(Color(hex: "222222")) + .cornerRadius(16.7) + .padding(.top, 13.3) + .onTapGesture { viewModel.nextStep() } + } else { + ZStack { + if let selectedImage = viewModel.profileImage { + Image(uiImage: selectedImage) + .resizable() + .scaledToFill() + .frame(width: 80, height: 116.8, alignment: .top) + .background(Color(hex: "3e3358")) + .cornerRadius(10) + } else { + Image("ic_logo") + .resizable() + .scaledToFill() + .frame(width: 80, height: 116.8, alignment: .top) + .background(Color(hex: "3e3358")) + .cornerRadius(10) + } + + Image("ic_camera") + .padding(10) + .background(Color(hex: "9970ff")) + .cornerRadius(30) + .offset(x: 40, y: 40) + } + .frame(alignment: .bottomTrailing) + .padding(.top, 13.3) + .onTapGesture { + isShowPhotoPicker = true + } + + VStack(spacing: 16.7) { + UserTextField( + title: "닉네임", + hint: "닉네임을 입력해 주세요", + isSecure: false, + variable: $viewModel.nickname + ) + .padding(.horizontal, 13.3) + + GenderSelectView() + } + .padding(.vertical, 20) + .frame(width: screenSize().width - 26.7, alignment: .leading) + .background(Color(hex: "222222")) + .cornerRadius(6.7) + + Text("회원가입") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(Color.white) + .padding(.vertical, 16) + .frame(width: screenSize().width - 53.4) + .background(Color(hex: "9970ff")) + .cornerRadius(10) + .padding(.vertical, 13.7) + .frame(width: screenSize().width - 26.7) + .background(Color(hex: "222222")) + .cornerRadius(16.7) + .padding(.top, 13.3) + .onTapGesture { viewModel.signUp() } + } + + Rectangle() + .foregroundColor(Color.black) + .frame(width: screenSize().width, height: keyboardHandler.keyboardHeight) + + if proxy.safeAreaInsets.bottom > 0 { + Rectangle() + .foregroundColor(Color.black) + .frame(width: proxy.size.width, height: 15.3) + } + } + } + } + .edgesIgnoringSafeArea(.bottom) + .onTapGesture { + hideKeyboard() + } + } + + if isShowPhotoPicker { + ImagePicker( + isShowing: $isShowPhotoPicker, + selectedImage: $viewModel.profileImage, + sourceType: .photoLibrary + ) + } + } + .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) { + GeometryReader { geo in + HStack { + Spacer() + Text(viewModel.errorMessage) + .padding(.vertical, 13.3) + .padding(.horizontal, 6.7) + .frame(width: geo.size.width - 66.7, alignment: .center) + .font(.custom(Font.medium.rawValue, size: 12)) + .background(Color(hex: "9970ff")) + .foregroundColor(Color.white) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + } + } + } + + @ViewBuilder + func EmailPasswordView() -> some View { + VStack(spacing: 26.7) { + UserTextField( + title: "이메일", + hint: "이메일 주소를 입력해 주세요", + isSecure: false, + variable: $viewModel.email, + keyboardType: .emailAddress + ) + .padding(.horizontal, 13.3) + + UserTextField( + title: "비밀번호", + hint: "비밀번호 (영문, 숫자 포함 8~20자)", + isSecure: true, + variable: $viewModel.password, + keyboardType: .emailAddress + ) + .padding(.horizontal, 13.3) + + UserTextField( + title: "비밀번호 확인", + hint: "비밀번호를 다시 입력해 주세요", + isSecure: true, + variable: $viewModel.passwordConfirm, + keyboardType: .emailAddress + ) + .padding(.horizontal, 13.3) + } + .padding(.vertical, 20) + .frame(width: screenSize().width - 26.7) + .background(Color(hex: "222222")) + .cornerRadius(6.7) + .padding(.top, 13.3) + } + + @ViewBuilder + func GenderSelectView() -> some View { + VStack(alignment: .leading, spacing: 13.3) { + Text("성별") + .font(.custom(Font.bold.rawValue, size: 12)) + .foregroundColor(Color(hex: "eeeeee")) + .padding(.leading, 6.7) + + HStack(spacing: 0) { + HStack(spacing: 13.3) { + Image( + viewModel.gender == .FEMALE ? + "btn_radio_select_selected" : + "btn_radio_select_normal" + ).resizable() + .frame(width: 20, height: 20) + + Text("여자") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + } + .contentShape(Rectangle()) + .onTapGesture { + if viewModel.gender != .FEMALE { + viewModel.gender = .FEMALE + } + } + + Spacer() + + HStack(spacing: 13.3) { + Image( + viewModel.gender == .MALE ? + "btn_radio_select_selected" : + "btn_radio_select_normal" + ).resizable() + .frame(width: 20, height: 20) + + Text("남자") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + } + .contentShape(Rectangle()) + .onTapGesture { + if viewModel.gender != .MALE { + viewModel.gender = .MALE + } + } + + Spacer() + + HStack(spacing: 13.3) { + Image( + viewModel.gender == .NONE ? + "btn_radio_select_selected" : + "btn_radio_select_normal" + ).resizable() + .frame(width: 20, height: 20) + + Text("공개 안 함") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + } + .contentShape(Rectangle()) + .onTapGesture { + if viewModel.gender != .NONE { + viewModel.gender = .NONE + } + } + + Spacer() + } + .padding(.leading, 6.7) + } + .frame(width: screenSize().width - 53.4) + } + + @ViewBuilder + func TermsOfServiceAgreeView() -> some View { + VStack(spacing: 0) { + Text("약관 동의") + .font(.custom(Font.bold.rawValue, size: 12)) + .foregroundColor(Color(hex: "eeeeee")) + .frame(width: screenSize().width - 60, height: 50, alignment: .leading) + + Rectangle() + .frame(width: screenSize().width - 53.4, height: 1) + .foregroundColor(Color(hex: "909090")) + + HStack(spacing: 6.7) { + Text("이용약관 (필수)") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "eeeeee")) + + Text("(필수)") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "9970ff")) + + Spacer() + + Image( + viewModel.isAgreeTerms ? + "btn_select_checked" : + "btn_select_normal" + ).resizable() + .frame(width: 20, height: 20) + } + .frame(width: screenSize().width - 60, height: 50, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.isAgreeTerms.toggle() + } + + Rectangle() + .frame(width: screenSize().width - 53.4, height: 1) + .foregroundColor(Color(hex: "909090")) + + HStack(spacing: 6.7) { + Text("개인정보수집 동의") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "eeeeee")) + + Text("(필수)") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "9970ff")) + + Spacer() + + Image( + viewModel.isAgreePrivacyPolicy ? + "btn_select_checked" : + "btn_select_normal" + ).resizable() + .frame(width: 20, height: 20) + } + .frame(width: screenSize().width - 60, height: 50, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.isAgreePrivacyPolicy.toggle() + } + + Rectangle() + .frame(width: screenSize().width - 53.4, height: 1) + .foregroundColor(Color(hex: "909090")) + } + .padding(.vertical, 13.3) + .frame(width: screenSize().width - 26.7) + .background(Color(hex: "222222")) + .cornerRadius(6.7) + } +} + +struct SignUpView_Previews: PreviewProvider { + static var previews: some View { + SignUpView() + } +} diff --git a/SodaLive/Sources/User/SignUp/SignUpViewModel.swift b/SodaLive/Sources/User/SignUp/SignUpViewModel.swift new file mode 100644 index 0000000..910223c --- /dev/null +++ b/SodaLive/Sources/User/SignUp/SignUpViewModel.swift @@ -0,0 +1,187 @@ +// +// SignUpViewModel.swift +// SodaLive +// +// Created by klaus on 2023/08/09. +// + +import UIKit +import Combine + +import Moya + +final class SignUpViewModel: ObservableObject { + private let repository = UserRepository() + private var subscription = Set() + + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + + @Published var email = "" + @Published var nickname = "" + @Published var gender: Gender = .NONE + + @Published var password = "" + @Published var passwordConfirm = "" + + @Published var profileImage: UIImage? = nil + + @Published var isAgreeTerms = false + @Published var isAgreePrivacyPolicy = false + + enum Step { + case step1, step2 + } + + @Published private(set) var step = Step.step1 + + func prevStep() { + if step == .step1 { + AppState.shared.back() + } else { + step = .step1 + } + } + + func nextStep() { + if validateStep1() { + step = .step2 + } + } + + func signUp() { + if validateStep2() { + isLoading = true + + let request = SignUpRequest( + email: email, + password: password, + nickname: nickname, + gender: gender, + isAgreeTermsOfService: true, + isAgreePrivacyPolicy: true + ) + + var multipartData = [MultipartFormData]() + + let encoder = JSONEncoder() + encoder.outputFormatting = .withoutEscapingSlashes + let jsonData = try? encoder.encode(request) + + if let jsonData = jsonData { + if let profileImage = profileImage, let imageData = profileImage.jpegData(compressionQuality: 0.8) { + multipartData.append( + MultipartFormData( + provider: .data(imageData), + name: "profileImage", + fileName: "\(UUID().uuidString)_\(Date().timeIntervalSince1970 * 1000).jpg", + mimeType: "image/*") + ) + } + + multipartData.append(MultipartFormData(provider: .data(jsonData), name: "request")) + + repository.signUp(parameters: multipartData) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { response in + self.isLoading = false + let responseData = response.data + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success { + UserDefaults.set(data.profileImage, forKey: .profileImage) + UserDefaults.set(data.nickname, forKey: .nickname) + UserDefaults.set(data.userId, forKey: .userId) + UserDefaults.set(data.email, forKey: .email) + UserDefaults.set(data.token, forKey: .token) + AppState.shared.back() + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + self.isLoading = false + } + } + } + + private func validateStep1() -> Bool { + if email.trimmingCharacters(in: .whitespaces).isEmpty || !validateEmail() { + errorMessage = "올바른 이메일을 입력하세요" + isShowPopup = true + return false + } + + if password.trimmingCharacters(in: .whitespaces).isEmpty { + errorMessage = "비밀번호를 입력하세요" + isShowPopup = true + return false + } + + if !validatePassword() { + errorMessage = "영문, 숫자 포함 8자 이상의 비밀번호를 입력해 주세요." + isShowPopup = true + return false + } + + if password != passwordConfirm { + errorMessage = "비밀번호가 일치하지 않습니다." + isShowPopup = true + return false + } + + if !isAgreeTerms || !isAgreePrivacyPolicy { + errorMessage = "약관에 동의하셔야 회원가입이 가능합니다." + isShowPopup = true + return false + } + + return true + } + + func validateStep2() -> Bool { + if nickname.trimmingCharacters(in: .whitespaces).isEmpty || nickname.trimmingCharacters(in: .whitespaces).count < 2 { + errorMessage = "닉네임은 2자 이상 입력해 주세요." + isShowPopup = true + return false + } + + return true + } + + private func validateEmail() -> Bool { + let emailRegEx = "^.+@([A-Za-z0-9-]+\\.)+[A-Za-z]{2}[A-Za-z]*$" + + let predicate = NSPredicate(format:"SELF MATCHES %@", emailRegEx) + return predicate.evaluate(with: email) + } + + private func validatePassword() -> Bool { + let passwordRegEx = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d$@!%*#?&]{8,}$" + + let predicate = NSPredicate(format:"SELF MATCHES %@", passwordRegEx) + return predicate.evaluate(with: password) + } +}