From d6bab3ac307fe635f41ba07cbf3842f393048f5e Mon Sep 17 00:00:00 2001 From: Rudy-009 Date: Sat, 27 Dec 2025 16:15:52 +0900 Subject: [PATCH 1/7] =?UTF-8?q?[docs]=20README=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- .../Extension/UITextField+.swift | 26 +++++++++++-- .../Presentation/Core/UITextField+.swift | 38 ------------------- .../Seungjun/ViewController_LSJ.swift | 8 ++++ 4 files changed, 33 insertions(+), 43 deletions(-) delete mode 100644 Smashing-Assignment/Presentation/Core/UITextField+.swift create mode 100644 Smashing-Assignment/Presentation/Seungjun/ViewController_LSJ.swift diff --git a/README.md b/README.md index cc59b00..c093576 100644 --- a/README.md +++ b/README.md @@ -12,4 +12,6 @@ SMASHING iOS Assignment 레포입니다 🌀🧪 - GitHub 같이 망쳐보기 (아요 과제) - 코딩 컨벤션 지키기 - 코리 규칙 설정해보기 - - 머지하면서 망해보기 \ No newline at end of file + - 머지하면서 망해보기 + +# 2주차 diff --git a/Smashing-Assignment/Extension/UITextField+.swift b/Smashing-Assignment/Extension/UITextField+.swift index b8372b1..74c36ca 100644 --- a/Smashing-Assignment/Extension/UITextField+.swift +++ b/Smashing-Assignment/Extension/UITextField+.swift @@ -1,12 +1,12 @@ // // UITextField+.swift -// NewCombine +// Smashing-Assignment // -// Created by JIN on 12/26/25. +// Created by 홍준범 on 12/26/25. // -import Combine import UIKit +import Combine extension UITextField { func textDidChangePublisher() -> AnyPublisher { @@ -17,4 +17,22 @@ extension UITextField { } } - +extension UITextField { + func addLeftPadding(_ width: CGFloat = 10) { + let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: width, height: self.frame.height)) + self.leftView = paddingView + self.leftViewMode = ViewMode.always + } + + func addRightPadding(_ width: CGFloat = 10) { + let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: width, height: self.frame.height)) + self.rightView = paddingView + self.rightViewMode = ViewMode.always + } + + /// 텍스트 필드에 좌우 패딩을 한 번에 추가합니다. + func addPadding(leftAmount: CGFloat = 10, rightAmount: CGFloat = 10) { + addLeftPadding(leftAmount) + addRightPadding(rightAmount) + } +} diff --git a/Smashing-Assignment/Presentation/Core/UITextField+.swift b/Smashing-Assignment/Presentation/Core/UITextField+.swift deleted file mode 100644 index 74c36ca..0000000 --- a/Smashing-Assignment/Presentation/Core/UITextField+.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// UITextField+.swift -// Smashing-Assignment -// -// Created by 홍준범 on 12/26/25. -// - -import UIKit -import Combine - -extension UITextField { - func textDidChangePublisher() -> AnyPublisher { - NotificationCenter.default - .publisher(for: UITextField.textDidChangeNotification, object: self) - .map { _ in self.text ?? "" } - .eraseToAnyPublisher() - } -} - -extension UITextField { - func addLeftPadding(_ width: CGFloat = 10) { - let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: width, height: self.frame.height)) - self.leftView = paddingView - self.leftViewMode = ViewMode.always - } - - func addRightPadding(_ width: CGFloat = 10) { - let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: width, height: self.frame.height)) - self.rightView = paddingView - self.rightViewMode = ViewMode.always - } - - /// 텍스트 필드에 좌우 패딩을 한 번에 추가합니다. - func addPadding(leftAmount: CGFloat = 10, rightAmount: CGFloat = 10) { - addLeftPadding(leftAmount) - addRightPadding(rightAmount) - } -} diff --git a/Smashing-Assignment/Presentation/Seungjun/ViewController_LSJ.swift b/Smashing-Assignment/Presentation/Seungjun/ViewController_LSJ.swift new file mode 100644 index 0000000..882c382 --- /dev/null +++ b/Smashing-Assignment/Presentation/Seungjun/ViewController_LSJ.swift @@ -0,0 +1,8 @@ +// +// ViewController_LSJ.swift +// Smashing-Assignment +// +// Created by 이승준 on 12/26/25. +// + +import Foundation From cae5fc6490a073013a4f3ad258ab84281e00283b Mon Sep 17 00:00:00 2001 From: Rudy-009 Date: Mon, 29 Dec 2025 16:06:18 +0900 Subject: [PATCH 2/7] =?UTF-8?q?[feat]=20Factory=20Pattern=20=EC=9D=84=20?= =?UTF-8?q?=EC=9D=B4=EC=9A=A9=ED=95=9C=20Root=20View=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=20=EA=B4=80=EC=8B=AC=EC=82=AC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Application/SceneDelegate.swift | 12 ++-- .../Global/Factory/AppSceneFactory.swift | 34 +++++++++++ .../Global/Factory/RootViewSwitcher.swift | 31 ++++++++++ .../Core/AlertViewController.swift | 8 +++ .../Login/LoginViewController.swift | 8 +++ .../Login/SignUpViewController.swift | 57 +++++++++++++++++++ 6 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 Smashing-Assignment/Global/Factory/AppSceneFactory.swift create mode 100644 Smashing-Assignment/Global/Factory/RootViewSwitcher.swift create mode 100644 Smashing-Assignment/Presentation/Core/AlertViewController.swift create mode 100644 Smashing-Assignment/Presentation/Login/LoginViewController.swift create mode 100644 Smashing-Assignment/Presentation/Login/SignUpViewController.swift diff --git a/Smashing-Assignment/Application/SceneDelegate.swift b/Smashing-Assignment/Application/SceneDelegate.swift index 609b625..016f116 100644 --- a/Smashing-Assignment/Application/SceneDelegate.swift +++ b/Smashing-Assignment/Application/SceneDelegate.swift @@ -9,18 +9,20 @@ import UIKit class SceneDelegate: UIResponder, UIWindowSceneDelegate { + let factory: SceneFactory = AppSceneFactory() var window: UIWindow? - func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). guard let windowScene = (scene as? UIWindowScene) else { return } - - window = UIWindow(windowScene: windowScene) - window?.rootViewController = TabBarController() - window?.makeKeyAndVisible() + + let window = UIWindow(windowScene: windowScene) + let initialVC = factory.makeScene(for: .login) + window.rootViewController = initialVC + self.window = window + window.makeKeyAndVisible() } func sceneDidDisconnect(_ scene: UIScene) { diff --git a/Smashing-Assignment/Global/Factory/AppSceneFactory.swift b/Smashing-Assignment/Global/Factory/AppSceneFactory.swift new file mode 100644 index 0000000..37c8045 --- /dev/null +++ b/Smashing-Assignment/Global/Factory/AppSceneFactory.swift @@ -0,0 +1,34 @@ +// +// AppSceneFactory.swift +// Smashing-Assignment +// +// Created by 이승준 on 12/29/25. +// + +import UIKit + +enum SceneType { + case login + case signup + case main +} + +protocol SceneFactory { + func makeScene(for type: SceneType) -> UIViewController +} + +final class AppSceneFactory: SceneFactory { + func makeScene(for type: SceneType) -> UIViewController { + switch type { + case .login: + let loginVC = LoginViewController() + return UINavigationController(rootViewController: loginVC) + case .signup: + let signUpVC = SignUpViewController() + return UINavigationController(rootViewController: signUpVC) + case .main: + let mainVC = TabBarController() + return UINavigationController(rootViewController: mainVC) + } + } +} diff --git a/Smashing-Assignment/Global/Factory/RootViewSwitcher.swift b/Smashing-Assignment/Global/Factory/RootViewSwitcher.swift new file mode 100644 index 0000000..50fbac1 --- /dev/null +++ b/Smashing-Assignment/Global/Factory/RootViewSwitcher.swift @@ -0,0 +1,31 @@ +// +// RootViewSwitcher.swift +// Smashing-Assignment +// +// Created by 이승준 on 12/29/25. +// + +import UIKit + +final class RootViewSwitcher { + static let shared = RootViewSwitcher() + private init() {} + + func setRoot(_ viewController: UIViewController, animated: Bool = true) { + guard let window = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first?.windows.first else { return } + + window.rootViewController = viewController + + if animated { + UIView.transition(with: window, + duration: 0.3, + options: .transitionCrossDissolve, + animations: nil, + completion: nil) + } + + window.makeKeyAndVisible() + } +} diff --git a/Smashing-Assignment/Presentation/Core/AlertViewController.swift b/Smashing-Assignment/Presentation/Core/AlertViewController.swift new file mode 100644 index 0000000..4068990 --- /dev/null +++ b/Smashing-Assignment/Presentation/Core/AlertViewController.swift @@ -0,0 +1,8 @@ +// +// AlertViewController.swift +// Smashing-Assignment +// +// Created by 이승준 on 12/29/25. +// + +import Foundation diff --git a/Smashing-Assignment/Presentation/Login/LoginViewController.swift b/Smashing-Assignment/Presentation/Login/LoginViewController.swift new file mode 100644 index 0000000..466448d --- /dev/null +++ b/Smashing-Assignment/Presentation/Login/LoginViewController.swift @@ -0,0 +1,8 @@ +// +// LoginViewController.swift +// Smashing-Assignment +// +// Created by 이승준 on 12/29/25. +// + +import Foundation diff --git a/Smashing-Assignment/Presentation/Login/SignUpViewController.swift b/Smashing-Assignment/Presentation/Login/SignUpViewController.swift new file mode 100644 index 0000000..7e231ce --- /dev/null +++ b/Smashing-Assignment/Presentation/Login/SignUpViewController.swift @@ -0,0 +1,57 @@ +// +// LoginViewController.swift +// Smashing-Assignment +// +// Created by 이승준 on 12/29/25. +// + +import UIKit + +final class SignUpViewController: UIViewController { + + // 1. 버튼 생성 + private let loginButton: UIButton = { + let button = UIButton(type: .system) + button.setTitle("로그인 성공 시뮬레이션", for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 18, weight: .bold) + button.backgroundColor = .systemBlue + button.setTitleColor(.white, for: .normal) + button.layer.cornerRadius = 10 + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .white + + setupUI() + } + + private func setupUI() { + // 2. 뷰에 버튼 추가 + view.addSubview(loginButton) + + // 3. 레이아웃 설정 (중앙 배치) + NSLayoutConstraint.activate([ + loginButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + loginButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), + loginButton.widthAnchor.constraint(equalToConstant: 200), + loginButton.heightAnchor.constraint(equalToConstant: 50) + ]) + + // 4. 버튼 클릭 액션 연결 + loginButton.addTarget(self, action: #selector(loginButtonTapped), for: .touchUpInside) + } + + @objc private func loginButtonTapped() { + // 버튼을 누르면 로그인 성공 로직 실행 + didLoginSuccess() + } + + func didLoginSuccess() { + let factory = AppSceneFactory() + let mainScene = factory.makeScene(for: .main) + RootViewSwitcher.shared.setRoot(mainScene) + } +} From a18ffcb9a08af8bdfbd326fe43c921d6745d5ec5 Mon Sep 17 00:00:00 2001 From: Rudy-009 Date: Mon, 29 Dec 2025 16:18:59 +0900 Subject: [PATCH 3/7] =?UTF-8?q?[feat]=20Factory=20=ED=8C=A8=ED=84=B4=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=EC=9D=84=20=ED=86=B5=ED=95=9C=20TabBarContro?= =?UTF-8?q?ller=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=ED=83=AD=20=EC=A0=84=ED=99=98=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/AlertViewController.swift | 47 +++++++++++++++- .../Presentation/Core/TabBarController.swift | 54 +++++++++++-------- .../Login/LoginViewController.swift | 46 +++++++++++++++- .../Login/SignUpViewController.swift | 7 +-- .../Seungjun/ViewController_LSJ.swift | 46 +++++++++++++++- 5 files changed, 170 insertions(+), 30 deletions(-) diff --git a/Smashing-Assignment/Presentation/Core/AlertViewController.swift b/Smashing-Assignment/Presentation/Core/AlertViewController.swift index 4068990..d8e8fc9 100644 --- a/Smashing-Assignment/Presentation/Core/AlertViewController.swift +++ b/Smashing-Assignment/Presentation/Core/AlertViewController.swift @@ -5,4 +5,49 @@ // Created by 이승준 on 12/29/25. // -import Foundation +import UIKit + +final class AlertViewController: UIViewController { + + private let jinjaeButton: UIButton = { + let button = UIButton(type: .system) + button.setTitle("진재 탭으로 이동", for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 18, weight: .bold) + button.backgroundColor = .systemBlue + button.setTitleColor(.white, for: .normal) + button.layer.cornerRadius = 10 + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .white + + setupUI() + } + + private func setupUI() { + view.addSubview(jinjaeButton) + + NSLayoutConstraint.activate([ + jinjaeButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + jinjaeButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), + jinjaeButton.widthAnchor.constraint(equalToConstant: 200), + jinjaeButton.heightAnchor.constraint(equalToConstant: 50) + ]) + + // 4. 버튼 클릭 액션 연결 + jinjaeButton.addTarget(self, action: #selector(jinjaeButtonTapped), for: .touchUpInside) + } + + @objc private func jinjaeButtonTapped() { + // 버튼을 누르면 로그인 성공 로직 실행 + goToJinjae() + } + + func goToJinjae() { + TabBarController.shared?.switchToTab(.jinjae) + self.navigationController?.popViewController(animated: true) + } +} diff --git a/Smashing-Assignment/Presentation/Core/TabBarController.swift b/Smashing-Assignment/Presentation/Core/TabBarController.swift index 9b8dfe3..4aaa52d 100644 --- a/Smashing-Assignment/Presentation/Core/TabBarController.swift +++ b/Smashing-Assignment/Presentation/Core/TabBarController.swift @@ -11,6 +11,10 @@ final class TabBarController: UITabBarController { //MARK: - Properties + var factory: TabBarSceneFactory = DefaultTabBarSceneFactory() + + static weak var shared: TabBarController? + private let defaultTab: Tab = .junbeom enum Tab: Int, CaseIterable { @@ -33,17 +37,6 @@ final class TabBarController: UITabBarController { case .seungjun: return UIImage(systemName: "figure.badminton.circle.fill")! } } - - var viewController: UIViewController { - switch self { - case .jinjae: - return JinJaeViewController() - case .junbeom: - return CombineViewController_HJB() - case .seungjun: - return UIViewController() - } - } } //MARK: - Life Cycle @@ -51,6 +44,7 @@ final class TabBarController: UITabBarController { override func viewDidLoad() { super.viewDidLoad() + TabBarController.shared = self // 인스턴스 할당 필수! self.delegate = self setViewControllers() @@ -58,25 +52,30 @@ final class TabBarController: UITabBarController { selectedIndex = defaultTab.rawValue } + // MARK: - Public Methods + + func switchToTab(_ tab: Tab) { // 외부에서 호출할 탭 전환 메서드 + self.selectedIndex = tab.rawValue + // 필요한 경우 해당 탭의 내비게이션 스택을 루트로 초기화 + if let nav = self.selectedViewController as? UINavigationController { + nav.popToRootViewController(animated: true) + } + } + //MARK: - Private Methods private func setViewControllers() { let topMargin: CGFloat = 7.0 - + self.viewControllers = Tab.allCases.map { tab in - let rootVC = tab.viewController - let nav = UINavigationController(rootViewController: rootVC) - nav.isNavigationBarHidden = true + let nav = factory.makeViewController(for: tab) let icon = resizeImage(image: tab.imageName).withRenderingMode(.alwaysOriginal) let selectedIcon = resizeImage(image: tab.selectedImageName).withRenderingMode(.alwaysOriginal) - nav.tabBarItem = UITabBarItem(title: nil, - image: icon, - selectedImage: selectedIcon) + nav.tabBarItem = UITabBarItem(title: nil, image: icon, selectedImage: selectedIcon) nav.tabBarItem.tag = tab.rawValue - nav.tabBarItem.imageInsets = UIEdgeInsets(top: topMargin, left: 0, - bottom: -topMargin, right: 0) + nav.tabBarItem.imageInsets = UIEdgeInsets(top: topMargin, left: 0, bottom: -topMargin, right: 0) return nav } @@ -95,7 +94,6 @@ final class TabBarController: UITabBarController { tabBar.scrollEdgeAppearance = appearance } - private func resizeImage(image: UIImage) -> UIImage { let targetSize = CGSize(width: 48, height: 48) let size = image.size @@ -118,3 +116,17 @@ final class TabBarController: UITabBarController { extension TabBarController: UITabBarControllerDelegate { } + +protocol TabBarSceneFactory { + func makeViewController(for tab: TabBarController.Tab) -> UIViewController +} + +final class DefaultTabBarSceneFactory: TabBarSceneFactory { + func makeViewController(for tab: TabBarController.Tab) -> UIViewController { + switch tab { + case .jinjae: return JinJaeViewController() + case .junbeom: return CombineViewController_HJB() + case .seungjun: return ViewController_LSJ() + } + } +} diff --git a/Smashing-Assignment/Presentation/Login/LoginViewController.swift b/Smashing-Assignment/Presentation/Login/LoginViewController.swift index 466448d..3ecbb91 100644 --- a/Smashing-Assignment/Presentation/Login/LoginViewController.swift +++ b/Smashing-Assignment/Presentation/Login/LoginViewController.swift @@ -5,4 +5,48 @@ // Created by 이승준 on 12/29/25. // -import Foundation +import UIKit + +final class LoginViewController: UIViewController { + + private let loginButton: UIButton = { + let button = UIButton(type: .system) + button.setTitle("로그인 성공 시뮬레이션", for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 18, weight: .bold) + button.backgroundColor = .systemBlue + button.setTitleColor(.white, for: .normal) + button.layer.cornerRadius = 10 + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .white + + setupUI() + } + + private func setupUI() { + view.addSubview(loginButton) + + NSLayoutConstraint.activate([ + loginButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + loginButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), + loginButton.widthAnchor.constraint(equalToConstant: 200), + loginButton.heightAnchor.constraint(equalToConstant: 50) + ]) + + loginButton.addTarget(self, action: #selector(loginButtonTapped), for: .touchUpInside) + } + + @objc private func loginButtonTapped() { + didLoginSuccess() + } + + func didLoginSuccess() { + let factory = AppSceneFactory() + let mainScene = factory.makeScene(for: .main) + RootViewSwitcher.shared.setRoot(mainScene) + } +} diff --git a/Smashing-Assignment/Presentation/Login/SignUpViewController.swift b/Smashing-Assignment/Presentation/Login/SignUpViewController.swift index 7e231ce..3850934 100644 --- a/Smashing-Assignment/Presentation/Login/SignUpViewController.swift +++ b/Smashing-Assignment/Presentation/Login/SignUpViewController.swift @@ -9,10 +9,9 @@ import UIKit final class SignUpViewController: UIViewController { - // 1. 버튼 생성 private let loginButton: UIButton = { let button = UIButton(type: .system) - button.setTitle("로그인 성공 시뮬레이션", for: .normal) + button.setTitle("회원가입", for: .normal) button.titleLabel?.font = .systemFont(ofSize: 18, weight: .bold) button.backgroundColor = .systemBlue button.setTitleColor(.white, for: .normal) @@ -29,10 +28,8 @@ final class SignUpViewController: UIViewController { } private func setupUI() { - // 2. 뷰에 버튼 추가 view.addSubview(loginButton) - // 3. 레이아웃 설정 (중앙 배치) NSLayoutConstraint.activate([ loginButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), loginButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), @@ -40,12 +37,10 @@ final class SignUpViewController: UIViewController { loginButton.heightAnchor.constraint(equalToConstant: 50) ]) - // 4. 버튼 클릭 액션 연결 loginButton.addTarget(self, action: #selector(loginButtonTapped), for: .touchUpInside) } @objc private func loginButtonTapped() { - // 버튼을 누르면 로그인 성공 로직 실행 didLoginSuccess() } diff --git a/Smashing-Assignment/Presentation/Seungjun/ViewController_LSJ.swift b/Smashing-Assignment/Presentation/Seungjun/ViewController_LSJ.swift index 882c382..f2bbc54 100644 --- a/Smashing-Assignment/Presentation/Seungjun/ViewController_LSJ.swift +++ b/Smashing-Assignment/Presentation/Seungjun/ViewController_LSJ.swift @@ -5,4 +5,48 @@ // Created by 이승준 on 12/26/25. // -import Foundation +import UIKit + +final class ViewController_LSJ: UIViewController { + + private let alertButton: UIButton = { + let button = UIButton(type: .system) + button.setTitle("알림 창 이동", for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 18, weight: .bold) + button.backgroundColor = .systemBlue + button.setTitleColor(.white, for: .normal) + button.layer.cornerRadius = 10 + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .white + + setupUI() + } + + private func setupUI() { + view.addSubview(alertButton) + + NSLayoutConstraint.activate([ + alertButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + alertButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), + alertButton.widthAnchor.constraint(equalToConstant: 200), + alertButton.heightAnchor.constraint(equalToConstant: 50) + ]) + + // 4. 버튼 클릭 액션 연결 + alertButton.addTarget(self, action: #selector(loginButtonTapped), for: .touchUpInside) + } + + @objc private func loginButtonTapped() { + // 버튼을 누르면 로그인 성공 로직 실행 + goToAlert() + } + + func goToAlert() { + self.navigationController?.pushViewController(AlertViewController(), animated: true) + } +} From 9aae0a4c81e242079fc12e441bdd5999dcdc97d9 Mon Sep 17 00:00:00 2001 From: Rudy-009 Date: Tue, 30 Dec 2025 19:39:55 +0900 Subject: [PATCH 4/7] =?UTF-8?q?[feat]=20Moya=20=EC=84=A4=EC=B9=98=20?= =?UTF-8?q?=EB=B0=8F=20=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C=20=ED=99=98=EA=B2=BD=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Smashing-Assignment.xcodeproj/project.pbxproj | 39 ++++++++-- .../xcshareddata/swiftpm/Package.resolved | 38 ++++++++- Smashing-Assignment/Global/Environment.swift | 13 ++++ .../Extensions}/UITextField+.swift | 0 Smashing-Assignment/Global/Info.plist | 4 + .../Networks/Base/BaseTargetType.swift | 29 +++++++ .../Networks/Base/NetworkError.swift | 18 +++++ .../Networks/Base/NetworkLogger.swift | 77 +++++++++++++++++++ .../Networks/Base/NetworkProvider.swift | 61 +++++++++++++++ .../Networks/DTO/MovieDTO.swift | 43 +++++++++++ .../Networks/DTO/PeopleDTO.swift | 27 +++++++ 11 files changed, 342 insertions(+), 7 deletions(-) create mode 100644 Smashing-Assignment/Global/Environment.swift rename Smashing-Assignment/{Extension => Global/Extensions}/UITextField+.swift (100%) create mode 100644 Smashing-Assignment/Networks/Base/BaseTargetType.swift create mode 100644 Smashing-Assignment/Networks/Base/NetworkError.swift create mode 100644 Smashing-Assignment/Networks/Base/NetworkLogger.swift create mode 100644 Smashing-Assignment/Networks/Base/NetworkProvider.swift create mode 100644 Smashing-Assignment/Networks/DTO/MovieDTO.swift create mode 100644 Smashing-Assignment/Networks/DTO/PeopleDTO.swift diff --git a/Smashing-Assignment.xcodeproj/project.pbxproj b/Smashing-Assignment.xcodeproj/project.pbxproj index d6cd818..6271f42 100644 --- a/Smashing-Assignment.xcodeproj/project.pbxproj +++ b/Smashing-Assignment.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 1ECDD5532EFDB564005A9325 /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = 1ECDD5522EFDB564005A9325 /* SnapKit */; }; 1ECDD5562EFDB56C005A9325 /* Then in Frameworks */ = {isa = PBXBuildFile; productRef = 1ECDD5552EFDB56C005A9325 /* Then */; }; + 637B44262F03AC26008C1F7C /* Moya in Frameworks */ = {isa = PBXBuildFile; productRef = 637B44252F03AC26008C1F7C /* Moya */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -43,6 +44,7 @@ files = ( 1ECDD5532EFDB564005A9325 /* SnapKit in Frameworks */, 1ECDD5562EFDB56C005A9325 /* Then in Frameworks */, + 637B44262F03AC26008C1F7C /* Moya in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -87,6 +89,7 @@ packageProductDependencies = ( 1ECDD5522EFDB564005A9325 /* SnapKit */, 1ECDD5552EFDB56C005A9325 /* Then */, + 637B44252F03AC26008C1F7C /* Moya */, ); productName = "Smashing-Assignment"; productReference = 633612112EFBCE6100228B88 /* Smashing-Assignment.app */; @@ -119,6 +122,7 @@ packageReferences = ( 1ECDD5512EFDB564005A9325 /* XCRemoteSwiftPackageReference "SnapKit" */, 1ECDD5542EFDB56C005A9325 /* XCRemoteSwiftPackageReference "Then" */, + 637B44242F03AC26008C1F7C /* XCRemoteSwiftPackageReference "Moya" */, ); preferredProjectObjectVersion = 77; productRefGroup = 633612122EFBCE6100228B88 /* Products */; @@ -153,6 +157,8 @@ /* Begin XCBuildConfiguration section */ 633612252EFBCE6200228B88 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 633612132EFBCE6100228B88 /* Smashing-Assignment */; + baseConfigurationReferenceRelativePath = Global/Config.xcconfig; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -163,8 +169,7 @@ INFOPLIST_FILE = "Smashing-Assignment/Global/Info.plist"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -174,17 +179,23 @@ PRODUCT_BUNDLE_IDENTIFIER = "iseungjun.Smashing-Assignment"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; 633612262EFBCE6200228B88 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 633612132EFBCE6100228B88 /* Smashing-Assignment */; + baseConfigurationReferenceRelativePath = Global/Config.xcconfig; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -195,8 +206,7 @@ INFOPLIST_FILE = "Smashing-Assignment/Global/Info.plist"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -206,12 +216,16 @@ PRODUCT_BUNDLE_IDENTIFIER = "iseungjun.Smashing-Assignment"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; @@ -376,6 +390,14 @@ minimumVersion = 3.0.0; }; }; + 637B44242F03AC26008C1F7C /* XCRemoteSwiftPackageReference "Moya" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Moya/Moya.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 15.0.3; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -389,6 +411,11 @@ package = 1ECDD5542EFDB56C005A9325 /* XCRemoteSwiftPackageReference "Then" */; productName = Then; }; + 637B44252F03AC26008C1F7C /* Moya */ = { + isa = XCSwiftPackageProductDependency; + package = 637B44242F03AC26008C1F7C /* XCRemoteSwiftPackageReference "Moya" */; + productName = Moya; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 633612092EFBCE6100228B88 /* Project object */; diff --git a/Smashing-Assignment.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Smashing-Assignment.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8063172..16f404d 100644 --- a/Smashing-Assignment.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Smashing-Assignment.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,42 @@ { - "originHash" : "2ad69c0deaec8838f2417eb6e8d288b30a36442bc22243bc0f5ee813f88e20a7", + "originHash" : "f97dacaf3e800e209cb53b51747205ca3b96ca62b4c23846fa9ce672e7a255da", "pins" : [ + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire.git", + "state" : { + "revision" : "7be73f6c2b5cd90e40798b06ebd5da8f9f79cf88", + "version" : "5.11.0" + } + }, + { + "identity" : "moya", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Moya/Moya.git", + "state" : { + "revision" : "c263811c1f3dbf002be9bd83107f7cdc38992b26", + "version" : "15.0.3" + } + }, + { + "identity" : "reactiveswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ReactiveCocoa/ReactiveSwift.git", + "state" : { + "revision" : "c43bae3dac73fdd3cb906bd5a1914686ca71ed3c", + "version" : "6.7.0" + } + }, + { + "identity" : "rxswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ReactiveX/RxSwift.git", + "state" : { + "revision" : "5004a18539bd68905c5939aa893075f578f4f03d", + "version" : "6.9.1" + } + }, { "identity" : "snapkit", "kind" : "remoteSourceControl", diff --git a/Smashing-Assignment/Global/Environment.swift b/Smashing-Assignment/Global/Environment.swift new file mode 100644 index 0000000..a9ac593 --- /dev/null +++ b/Smashing-Assignment/Global/Environment.swift @@ -0,0 +1,13 @@ +// +// Environment.swift +// Smashing-Assignment +// +// Created by 이승준 on 12/30/25. +// + +import Foundation + +enum Environment { + static let baseURL: String = Bundle.main.infoDictionary?["BaseURL"] as! String + static let movie_API_Key: String = Bundle.main.infoDictionary?["MOVIE_API_KEY"] as! String +} diff --git a/Smashing-Assignment/Extension/UITextField+.swift b/Smashing-Assignment/Global/Extensions/UITextField+.swift similarity index 100% rename from Smashing-Assignment/Extension/UITextField+.swift rename to Smashing-Assignment/Global/Extensions/UITextField+.swift diff --git a/Smashing-Assignment/Global/Info.plist b/Smashing-Assignment/Global/Info.plist index 0eb786d..e3a1ce4 100644 --- a/Smashing-Assignment/Global/Info.plist +++ b/Smashing-Assignment/Global/Info.plist @@ -2,6 +2,10 @@ + BaseURL + $(BaseURL) + MOVIE_API_KEY + $(MOVIE_API_KEY) UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/Smashing-Assignment/Networks/Base/BaseTargetType.swift b/Smashing-Assignment/Networks/Base/BaseTargetType.swift new file mode 100644 index 0000000..190b2d1 --- /dev/null +++ b/Smashing-Assignment/Networks/Base/BaseTargetType.swift @@ -0,0 +1,29 @@ +// +// BaseTarget.swift +// Smashing-Assignment +// +// Created by 이승준 on 12/30/25. +// + +import Foundation +import Moya + +protocol BaseTargetType: TargetType { } + +extension BaseTargetType{ + + var baseURL: URL { + return URL(string:Environment.baseURL)! + } + + var headers: [String : String]? { + let header = [ + "Content-Type": "application/json" + ] + return header + } + + var sampleData: Data { + return Data() + } +} diff --git a/Smashing-Assignment/Networks/Base/NetworkError.swift b/Smashing-Assignment/Networks/Base/NetworkError.swift new file mode 100644 index 0000000..8ab30de --- /dev/null +++ b/Smashing-Assignment/Networks/Base/NetworkError.swift @@ -0,0 +1,18 @@ +// +// NetworkError.swift +// Smashing-Assignment +// +// Created by 이승준 on 12/30/25. +// + +import Foundation + +public enum NetworkError: Error { + case decoding + case unauthorized + case forbidden + case notFound + case serverError(String) + case networkFail + case unknown +} diff --git a/Smashing-Assignment/Networks/Base/NetworkLogger.swift b/Smashing-Assignment/Networks/Base/NetworkLogger.swift new file mode 100644 index 0000000..503981e --- /dev/null +++ b/Smashing-Assignment/Networks/Base/NetworkLogger.swift @@ -0,0 +1,77 @@ +// +// NetworkLogger.swift +// Smashing-Assignment +// +// Created by 이승준 on 12/30/25. +// + +import Foundation +import Moya + +final class NetworkLogger: PluginType { + + // MARK: - API Calls + + func willSend(_ request: RequestType, target: TargetType) { + guard let req = request.request else { + print("❌ [REQUEST] Invalid request") + return + } + + print("\n====== 📤 Request ======") + print("➡️ URL: \(req.url?.absoluteString ?? "nil")") + print("➡️ METHOD: \(req.httpMethod ?? "nil")") + + if let headers = req.allHTTPHeaderFields, !headers.isEmpty { + print("➡️ HEADERS:") + headers.forEach { key, value in + print(" \(key): \(value)") + } + } + + if let body = req.httpBody, + let pretty = prettyJSONString(from: body) { + print("➡️ BODY:\n\(pretty)") + } + + print("========================\n") + } + + func didReceive(_ result: Result, target: TargetType) { + print("\n====== 📥 Response ======") + + switch result { + case .success(let response): + print("⬅️ STATUS: \(response.statusCode)") + print("⬅️ URL: \(response.request?.url?.absoluteString ?? "nil")") + + if let pretty = prettyJSONString(from: response.data) { + print("⬅️ BODY:\n\(pretty)") + } else { + print("⬅️ BODY: (empty or non-readable)") + } + + case .failure(let error): + print("❌ ERROR:", error.localizedDescription) + + if let data = error.response?.data, + let pretty = prettyJSONString(from: data) { + print("❌ ERROR BODY:\n\(pretty)") + } + } + + print("========================\n") + } + + // MARK: - Private Methods + private func prettyJSONString(from data: Data) -> String? { + guard + let object = try? JSONSerialization.jsonObject(with: data), + let prettyData = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]), + let prettyString = String(data: prettyData, encoding: .utf8) + else { return nil } + + return prettyString + } +} + diff --git a/Smashing-Assignment/Networks/Base/NetworkProvider.swift b/Smashing-Assignment/Networks/Base/NetworkProvider.swift new file mode 100644 index 0000000..3b4e3a1 --- /dev/null +++ b/Smashing-Assignment/Networks/Base/NetworkProvider.swift @@ -0,0 +1,61 @@ +// +// NetworkProvider.swift +// Smashing-Assignment +// +// Created by 이승준 on 12/30/25. +// + +import Foundation +import Moya + +final class NetworkProvider { + + // MARK: - API Calls + + static func request( + _ target: API, + type: T.Type, + completion: @escaping (Result) -> Void + ) { + let provider = MoyaProvider(plugins: [NetworkLogger()]) + + provider.request(target) { result in + switch result { + + case .success(let response): + + switch response.statusCode { + case 200...299: + break + case 401: + completion(.failure(.unauthorized)) + return + case 403: + completion(.failure(.forbidden)) + return + case 404: + completion(.failure(.notFound)) + return + case 500...599: + let msg = String(data: response.data, encoding: .utf8) ?? "Server Error" + completion(.failure(.serverError(msg))) + return + default: + completion(.failure(.unknown)) + return + } + + do { + let result = try JSONDecoder().decode(T.self, from: response.data) + completion(.success(result)) + } catch { + completion(.failure(.decoding)) + } + + case .failure: + completion(.failure(.networkFail)) + } + } + } +} + diff --git a/Smashing-Assignment/Networks/DTO/MovieDTO.swift b/Smashing-Assignment/Networks/DTO/MovieDTO.swift new file mode 100644 index 0000000..b0c7070 --- /dev/null +++ b/Smashing-Assignment/Networks/DTO/MovieDTO.swift @@ -0,0 +1,43 @@ +// +// MovieDTO.swift +// Smashing-Assignment +// +// Created by 이승준 on 12/30/25. +// + +import Foundation + +struct MovieListResponse: Codable { + let movieListResult: MovieListResult +} + +struct MovieListResult: Codable { + let totCnt: Int + let source: String + let movieList: [MovieDTO] +} + +struct MovieDTO: Codable { + let movieCd: String + let movieNm: String + let movieNmEn: String? + let prdtYear: String + let openDt: String + let typeNm: String + let prdtStatNm: String + let nationAlt: String + let genreAlt: String + let repNationNm: String + let repGenreNm: String + let directors: [DirectorDTO] + let companys: [CompanyDTO] +} + +struct DirectorDTO: Codable { + let peopleNm: String +} + +struct CompanyDTO: Codable { + let companyCd: String + let companyNm: String +} diff --git a/Smashing-Assignment/Networks/DTO/PeopleDTO.swift b/Smashing-Assignment/Networks/DTO/PeopleDTO.swift new file mode 100644 index 0000000..6b981fd --- /dev/null +++ b/Smashing-Assignment/Networks/DTO/PeopleDTO.swift @@ -0,0 +1,27 @@ +// +// PeopleDTO.swift +// Smashing-Assignment +// +// Created by 이승준 on 12/30/25. +// + +import Foundation + +struct PeopleListResponse: Codable { + let peopleListResult: PeopleListResult +} + +struct PeopleListResult: Codable { + let totCnt: Int + let source: String + let peopleList: [PeopleDTO] +} + +struct PeopleDTO: Codable { + let peopleCd: String + let peopleNm: String + let peopleNmEn: String + let repRoleNm: String + let filmoNames: String +} + From aabb3992b6b30e05e44dae8ced567982ff81041f Mon Sep 17 00:00:00 2001 From: JIN Date: Sun, 4 Jan 2026 23:26:34 +0900 Subject: [PATCH 5/7] =?UTF-8?q?I/O=ED=8C=A8=ED=84=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Core/TabBarController.swift | 2 +- .../Presentation/Jinjae/AppDIContainer.swift | 18 ++++ .../Presentation/Jinjae/HomeView.swift | 37 +++++--- .../Presentation/Jinjae/HomeViewModel.swift | 91 +++++++++++++++++++ .../Jinjae/JinJaeViewController.swift | 71 ++++++++++++--- 5 files changed, 194 insertions(+), 25 deletions(-) create mode 100644 Smashing-Assignment/Presentation/Jinjae/AppDIContainer.swift create mode 100644 Smashing-Assignment/Presentation/Jinjae/HomeViewModel.swift diff --git a/Smashing-Assignment/Presentation/Core/TabBarController.swift b/Smashing-Assignment/Presentation/Core/TabBarController.swift index 39a1f93..fe1c90d 100644 --- a/Smashing-Assignment/Presentation/Core/TabBarController.swift +++ b/Smashing-Assignment/Presentation/Core/TabBarController.swift @@ -37,7 +37,7 @@ final class TabBarController: UITabBarController { var viewController: UIViewController { switch self { case .jinjae: - return JinJaeViewController() + return AppDIContainer.shared.makeMainViewController() case .junbeom: return UIViewController() case .seungjun: diff --git a/Smashing-Assignment/Presentation/Jinjae/AppDIContainer.swift b/Smashing-Assignment/Presentation/Jinjae/AppDIContainer.swift new file mode 100644 index 0000000..56ef56f --- /dev/null +++ b/Smashing-Assignment/Presentation/Jinjae/AppDIContainer.swift @@ -0,0 +1,18 @@ +// +// AppDIContainer.swift +// Smashing-Assignment +// +// Created by JIN on 1/4/26. +// + +import UIKit + +class AppDIContainer { + static var shared: AppDIContainer = AppDIContainer() + + func makeMainViewController() -> UIViewController { + let viewModel = HomeViewModel() + return JinJaeViewController(viewModel: viewModel) + } + +} diff --git a/Smashing-Assignment/Presentation/Jinjae/HomeView.swift b/Smashing-Assignment/Presentation/Jinjae/HomeView.swift index 4132c9a..7d5aec3 100644 --- a/Smashing-Assignment/Presentation/Jinjae/HomeView.swift +++ b/Smashing-Assignment/Presentation/Jinjae/HomeView.swift @@ -11,20 +11,25 @@ import SnapKit import Then class HomeView: UIView { - + // MARK: - UI - + let textField = UITextField().then { $0.borderStyle = .roundedRect } + + let textField2 = UITextField().then { + $0.borderStyle = .roundedRect + } + let messageLabel = UILabel().then { - $0.text = "3글자 이상 입력해 주세요" + $0.text = "5글자 이상 입력해 주세요" $0.font = .systemFont(ofSize: 14) $0.numberOfLines = 0 $0.textColor = .secondaryLabel } let submitButton = UIButton(type: .system).then { - $0.setTitle("Next", for: .normal) + $0.setTitle("clear", for: .normal) $0.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold) $0.setTitleColor(.white, for: .normal) $0.setTitleColor(.white.withAlphaComponent(0.6), for: .disabled) @@ -32,39 +37,47 @@ class HomeView: UIView { $0.isEnabled = false $0.layer.cornerRadius = 8 } - + // MARK: - Initialize - + override init(frame: CGRect) { super.init(frame: frame) setupUI() setupLayout() } - + required init?(coder: NSCoder) { super.init(coder: coder) setupUI() setupLayout() } - + // MARK: - SetLayout - + private func setupUI() { backgroundColor = .systemBackground addSubview(textField) + addSubview(textField2) addSubview(messageLabel) addSubview(submitButton) } - + private func setupLayout() { textField.snp.makeConstraints { - $0.center.equalToSuperview() + $0.centerY.equalToSuperview().offset(-30) $0.horizontalEdges.equalToSuperview().inset(20) $0.height.equalTo(44) } - messageLabel.snp.makeConstraints { + + textField2.snp.makeConstraints { $0.top.equalTo(textField.snp.bottom).offset(12) $0.horizontalEdges.equalToSuperview().inset(20) + $0.height.equalTo(44) + } + + messageLabel.snp.makeConstraints { + $0.top.equalTo(textField2.snp.bottom).offset(12) + $0.horizontalEdges.equalToSuperview().inset(20) } submitButton.snp.makeConstraints { $0.top.equalTo(messageLabel.snp.bottom).offset(20) diff --git a/Smashing-Assignment/Presentation/Jinjae/HomeViewModel.swift b/Smashing-Assignment/Presentation/Jinjae/HomeViewModel.swift new file mode 100644 index 0000000..fc9ed34 --- /dev/null +++ b/Smashing-Assignment/Presentation/Jinjae/HomeViewModel.swift @@ -0,0 +1,91 @@ +// +// HomeViewModel.swift +// Smashing-Assignment +// +// Created by JIN on 12/30/25. +// + +import UIKit +import Combine + +protocol InputOutputProtocol { + + associatedtype Input + associatedtype Output + + func transform(input: AnyPublisher) -> AnyPublisher + +} + + +final class HomeViewModel: InputOutputProtocol { + + enum Input { + case textFieldChanged(String) + case textField2Changed(String) + case submitButtonTapped + } + + enum Output { + case updateButtonState(isEnabled: Bool) + case updateMessage(String) + case clearTextFields + } + + // MARK: - Properties + + private let output: PassthroughSubject = .init() + private var cancellables = Set() + + private let text1Subject = CurrentValueSubject("") + private let text2Subject = CurrentValueSubject("") + + // MARK: - Transform + + func transform(input: AnyPublisher) -> AnyPublisher { + input.sink { [weak self] event in + guard let self = self else { return } + switch event { + case .textFieldChanged(let text): + self.text1Subject.send(text) + + case .textField2Changed(let text): + self.text2Subject.send(text) + + case .submitButtonTapped: + self.handleSubmitButtonTapped() + } + }.store(in: &cancellables) + + Publishers.CombineLatest(text1Subject, text2Subject) + .map { text1, text2 in + return text1.count >= 5 && text2.count >= 5 + } + .removeDuplicates() + .sink { [weak self] isValid in + guard let self = self else { return } + + self.output.send(.updateButtonState(isEnabled: isValid)) + + if isValid { + self.output.send(.updateMessage("입력 완료")) + } else { + self.output.send(.updateMessage("두 필드 모두 5글자 이상 입력해주세요")) + } + } + .store(in: &cancellables) + + return output.eraseToAnyPublisher() + } + + // MARK: - Private Methods + + private func handleSubmitButtonTapped() { + text1Subject.send("") + text2Subject.send("") + output.send(.clearTextFields) + output.send(.updateMessage("5글자 이상 입력해주세요")) + } +} + + diff --git a/Smashing-Assignment/Presentation/Jinjae/JinJaeViewController.swift b/Smashing-Assignment/Presentation/Jinjae/JinJaeViewController.swift index 8a1b6cd..c26f963 100644 --- a/Smashing-Assignment/Presentation/Jinjae/JinJaeViewController.swift +++ b/Smashing-Assignment/Presentation/Jinjae/JinJaeViewController.swift @@ -9,29 +9,76 @@ import UIKit import Combine class JinJaeViewController: UIViewController { - + + private let viewModel: HomeViewModel + private let input: PassthroughSubject = .init() private var cancellables = Set() let homeView = HomeView() - + + init(viewModel: HomeViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + //MARK: LifeCycle - + override func viewDidLoad() { super.viewDidLoad() self.view = homeView + setupActions() bind() } - + + // MARK: - Setup Actions + + private func setupActions() { + homeView.textField.addTarget(self, action: #selector(textField1DidChange), for: .editingChanged) + homeView.textField2.addTarget(self, action: #selector(textField2DidChange), for: .editingChanged) + homeView.submitButton.addTarget(self, action: #selector(submitButtonTapped), for: .touchUpInside) + } + + @objc private func textField1DidChange() { + let text = homeView.textField.text ?? "" + input.send(.textFieldChanged(text)) + } + + @objc private func textField2DidChange() { + let text = homeView.textField2.text ?? "" + input.send(.textField2Changed(text)) + } + + @objc private func submitButtonTapped() { + input.send(.submitButtonTapped) + } + + // MARK: - Bind + private func bind() { - homeView.textField.textDidChangePublisher() - .map { $0.count >= 5 } - .sink { [weak self] isValid in - guard let self else { return } - self.homeView.submitButton.isEnabled = isValid - self.homeView.submitButton.backgroundColor = isValid ? .systemBlue : .lightGray - } + let output = viewModel.transform(input: input.eraseToAnyPublisher()) + + output + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + guard let self = self else { return } + + switch event { + case .updateButtonState(let isEnabled): + self.homeView.submitButton.isEnabled = isEnabled + self.homeView.submitButton.backgroundColor = isEnabled ? .systemBlue : .lightGray + case .updateMessage(let message): + self.homeView.messageLabel.text = message + case .clearTextFields: + self.homeView.textField.text = "" + self.homeView.textField2.text = "" + } + } .store(in: &cancellables) } } - From c974ad86a37339ccdcc14c0f2f97a31c2295b57a Mon Sep 17 00:00:00 2001 From: JIN Date: Thu, 8 Jan 2026 18:25:00 +0900 Subject: [PATCH 6/7] =?UTF-8?q?[feat]=20=EC=9D=B4=EC=A0=84=EA=B3=BC?= =?UTF-8?q?=EC=A0=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=8F=20?= =?UTF-8?q?=EC=83=88=EB=A1=9C=EC=9A=B4=20=EA=B3=BC=EC=A0=9C=20setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Core/TabBarController.swift | 9 ++- .../Presentation/Jinjae/AppDIContainer.swift | 2 +- .../Presentation/Jinjae/HomeViewModel.swift | 4 +- .../Jinjae/JinJaeViewController.swift | 30 ++++----- .../SearchMovieCollectionViewCell_JIN.swift | 64 +++++++++++++++++++ .../Movie/SearchViewController_JIN.swift | 19 ++++++ .../Jinjae/Movie/SearchViewModel_JIN.swift | 28 ++++++++ .../Jinjae/Movie/SearchView_JIN.swift | 62 ++++++++++++++++++ 8 files changed, 193 insertions(+), 25 deletions(-) create mode 100644 Smashing-Assignment/Presentation/Jinjae/Movie/SearchMovieCollectionViewCell_JIN.swift create mode 100644 Smashing-Assignment/Presentation/Jinjae/Movie/SearchViewController_JIN.swift create mode 100644 Smashing-Assignment/Presentation/Jinjae/Movie/SearchViewModel_JIN.swift create mode 100644 Smashing-Assignment/Presentation/Jinjae/Movie/SearchView_JIN.swift diff --git a/Smashing-Assignment/Presentation/Core/TabBarController.swift b/Smashing-Assignment/Presentation/Core/TabBarController.swift index 80c562e..6a228bd 100644 --- a/Smashing-Assignment/Presentation/Core/TabBarController.swift +++ b/Smashing-Assignment/Presentation/Core/TabBarController.swift @@ -41,7 +41,7 @@ final class TabBarController: UITabBarController { var viewController: UIViewController { switch self { case .jinjae: - return AppDIContainer.shared.makeMainViewController() + return AppDIContainer.shared.makeJInJaeViewController() case .junbeom: return UIViewController() case .seungjun: @@ -55,7 +55,7 @@ final class TabBarController: UITabBarController { override func viewDidLoad() { super.viewDidLoad() - TabBarController.shared = self // 인스턴스 할당 필수! + TabBarController.shared = self self.delegate = self setViewControllers() @@ -65,9 +65,8 @@ final class TabBarController: UITabBarController { // MARK: - Public Methods - func switchToTab(_ tab: Tab) { // 외부에서 호출할 탭 전환 메서드 + func switchToTab(_ tab: Tab) { self.selectedIndex = tab.rawValue - // 필요한 경우 해당 탭의 내비게이션 스택을 루트로 초기화 if let nav = self.selectedViewController as? UINavigationController { nav.popToRootViewController(animated: true) } @@ -135,7 +134,7 @@ protocol TabBarSceneFactory { final class DefaultTabBarSceneFactory: TabBarSceneFactory { func makeViewController(for tab: TabBarController.Tab) -> UIViewController { switch tab { - case .jinjae: return JinJaeViewController() + case .jinjae: return AppDIContainer.shared.makeJInJaeViewController() case .junbeom: return CombineViewController_HJB() case .seungjun: return ViewController_LSJ() } diff --git a/Smashing-Assignment/Presentation/Jinjae/AppDIContainer.swift b/Smashing-Assignment/Presentation/Jinjae/AppDIContainer.swift index 56ef56f..b575a91 100644 --- a/Smashing-Assignment/Presentation/Jinjae/AppDIContainer.swift +++ b/Smashing-Assignment/Presentation/Jinjae/AppDIContainer.swift @@ -10,7 +10,7 @@ import UIKit class AppDIContainer { static var shared: AppDIContainer = AppDIContainer() - func makeMainViewController() -> UIViewController { + func makeJInJaeViewController() -> UIViewController { let viewModel = HomeViewModel() return JinJaeViewController(viewModel: viewModel) } diff --git a/Smashing-Assignment/Presentation/Jinjae/HomeViewModel.swift b/Smashing-Assignment/Presentation/Jinjae/HomeViewModel.swift index fc9ed34..0fad64d 100644 --- a/Smashing-Assignment/Presentation/Jinjae/HomeViewModel.swift +++ b/Smashing-Assignment/Presentation/Jinjae/HomeViewModel.swift @@ -58,9 +58,7 @@ final class HomeViewModel: InputOutputProtocol { }.store(in: &cancellables) Publishers.CombineLatest(text1Subject, text2Subject) - .map { text1, text2 in - return text1.count >= 5 && text2.count >= 5 - } + .map { $0.count >= 5 && $1.count >= 5 } .removeDuplicates() .sink { [weak self] isValid in guard let self = self else { return } diff --git a/Smashing-Assignment/Presentation/Jinjae/JinJaeViewController.swift b/Smashing-Assignment/Presentation/Jinjae/JinJaeViewController.swift index c26f963..fe79edc 100644 --- a/Smashing-Assignment/Presentation/Jinjae/JinJaeViewController.swift +++ b/Smashing-Assignment/Presentation/Jinjae/JinJaeViewController.swift @@ -31,28 +31,16 @@ class JinJaeViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() self.view = homeView - setupActions() + setupButtonAction() bind() } - // MARK: - Setup Actions + // MARK: - Setup Button Action - private func setupActions() { - homeView.textField.addTarget(self, action: #selector(textField1DidChange), for: .editingChanged) - homeView.textField2.addTarget(self, action: #selector(textField2DidChange), for: .editingChanged) + private func setupButtonAction() { homeView.submitButton.addTarget(self, action: #selector(submitButtonTapped), for: .touchUpInside) } - @objc private func textField1DidChange() { - let text = homeView.textField.text ?? "" - input.send(.textFieldChanged(text)) - } - - @objc private func textField2DidChange() { - let text = homeView.textField2.text ?? "" - input.send(.textField2Changed(text)) - } - @objc private func submitButtonTapped() { input.send(.submitButtonTapped) } @@ -60,6 +48,16 @@ class JinJaeViewController: UIViewController { // MARK: - Bind private func bind() { + homeView.textField.textDidChangePublisher() + .map { HomeViewModel.Input.textFieldChanged($0) } + .subscribe(input) + .store(in: &cancellables) + + homeView.textField2.textDidChangePublisher() + .map { HomeViewModel.Input.textField2Changed($0) } + .subscribe(input) + .store(in: &cancellables) + let output = viewModel.transform(input: input.eraseToAnyPublisher()) output @@ -78,7 +76,7 @@ class JinJaeViewController: UIViewController { self.homeView.textField2.text = "" } } - .store(in: &cancellables) + .store(in: &cancellables) } } diff --git a/Smashing-Assignment/Presentation/Jinjae/Movie/SearchMovieCollectionViewCell_JIN.swift b/Smashing-Assignment/Presentation/Jinjae/Movie/SearchMovieCollectionViewCell_JIN.swift new file mode 100644 index 0000000..b47ba88 --- /dev/null +++ b/Smashing-Assignment/Presentation/Jinjae/Movie/SearchMovieCollectionViewCell_JIN.swift @@ -0,0 +1,64 @@ +// +// SearchMovieCollectionViewCell_JIN.swift +// Smashing-Assignment +// +// Created by JIN on 1/8/26. +// + +import UIKit +import Combine + +import Then +import SnapKit + +final class SearchMovieCollectionViewCell_JIN: UICollectionViewCell { + + static let identifier: String = "SearchMovieCollectionViewCell" + + let movieNmLabel = UILabel().then { + $0.font = .systemFont(ofSize: 20, weight: .bold) + } + + let yearLabel = UILabel().then { + $0.textAlignment = .right + $0.font = .systemFont(ofSize: 14, weight: .regular) + } + + override init(frame: CGRect) { + super.init(frame: frame) + self.addSubview(movieNmLabel) + self.addSubview(yearLabel) + + movieNmLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.leading.equalToSuperview().offset(16) + make.trailing.equalTo(yearLabel.snp.leading).offset(-10) + } + + yearLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.trailing.equalToSuperview().offset(-16) + make.width.equalTo(50) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(data: MovieDTO, index: Int) { + switch (index / 10 ) % 3 { + case 0: + movieNmLabel.textColor = .systemPink + case 1: + movieNmLabel.textColor = .systemCyan + case 2: + movieNmLabel.textColor = .systemGreen + default: + movieNmLabel.textColor = .white + } + movieNmLabel.text = String(index + 1) + ": " + data.movieNm + yearLabel.text = data.prdtYear + } + +} diff --git a/Smashing-Assignment/Presentation/Jinjae/Movie/SearchViewController_JIN.swift b/Smashing-Assignment/Presentation/Jinjae/Movie/SearchViewController_JIN.swift new file mode 100644 index 0000000..a3a6a04 --- /dev/null +++ b/Smashing-Assignment/Presentation/Jinjae/Movie/SearchViewController_JIN.swift @@ -0,0 +1,19 @@ +// +// SearchViewController_JIN.swift +// Smashing-Assignment +// +// Created by JIN on 1/8/26. +// + +import UIKit + +class SearchViewController_JIN: UIViewController { + + let searchView = SearchView_JIN() + + override func viewDidLoad() { + super.viewDidLoad() + view = searchView + } + +} diff --git a/Smashing-Assignment/Presentation/Jinjae/Movie/SearchViewModel_JIN.swift b/Smashing-Assignment/Presentation/Jinjae/Movie/SearchViewModel_JIN.swift new file mode 100644 index 0000000..b608710 --- /dev/null +++ b/Smashing-Assignment/Presentation/Jinjae/Movie/SearchViewModel_JIN.swift @@ -0,0 +1,28 @@ +// +// SearchViewModel_JIN.swift +// Smashing-Assignment +// +// Created by JIN on 1/8/26. +// + +import UIKit + +import Combine + +class SearchViewModel_JIN: InputOutputProtocol { + + enum Input { + case searchTextChanged(String) + case loadMoreTriggered + } + + struct Output { + let movies: AnyPublisher<[MovieDTO], Never> + let isLoading: AnyPublisher + let error: AnyPublisher + } + + func transform(input: AnyPublisher) -> AnyPublisher { + <#code#> + } +} diff --git a/Smashing-Assignment/Presentation/Jinjae/Movie/SearchView_JIN.swift b/Smashing-Assignment/Presentation/Jinjae/Movie/SearchView_JIN.swift new file mode 100644 index 0000000..99007c3 --- /dev/null +++ b/Smashing-Assignment/Presentation/Jinjae/Movie/SearchView_JIN.swift @@ -0,0 +1,62 @@ +// +// SearchView_JIN.swift +// Smashing-Assignment +// +// Created by JIN on 1/8/26. +// + +import UIKit +import Combine + +import Then +import SnapKit + +final class SearchView_JIN: UIView { + + let searchBar = UITextField().then { + $0.isUserInteractionEnabled = true + $0.placeholder = "검색어를 입력하세요" + $0.clipsToBounds = true + $0.layer.cornerRadius = 10 + $0.layer.borderWidth = 2 + $0.layer.borderColor = UIColor.white.cgColor + $0.font = .systemFont(ofSize: 20, weight: .bold) + } + + let collectionView: UICollectionView = { + let collection = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + collection.register(SearchMovieCollectionViewCell_JIN.self, + forCellWithReuseIdentifier: SearchMovieCollectionViewCell_JIN.identifier) + return collection + }() + + override init(frame: CGRect) { + super.init(frame: frame) + self.addSubview(searchBar) + self.addSubview(collectionView) + + searchBar.snp.makeConstraints { make in + make.height.equalTo(60) + make.leading.trailing.equalToSuperview().inset(20) + make.top.equalToSuperview().offset(100) + } + + collectionView.snp.makeConstraints { make in + make.top.equalTo(searchBar.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setCollectionViewLayout() { + let flowLayout = UICollectionViewFlowLayout() + let cellWidth: CGFloat = self.bounds.width + flowLayout.itemSize = CGSize(width: cellWidth, height: 100) + flowLayout.minimumLineSpacing = 10 + flowLayout.minimumInteritemSpacing = 0 + self.collectionView.setCollectionViewLayout(flowLayout, animated: false) + } +} From f88e36e902a5943cef09a333eaca7d2fe4a4d18f Mon Sep 17 00:00:00 2001 From: JIN Date: Sat, 10 Jan 2026 21:23:47 +0900 Subject: [PATCH 7/7] =?UTF-8?q?[feat]=20=EA=B3=BC=EC=A0=9C=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Smashing-Assignment.xcodeproj/project.pbxproj | 4 +- Smashing-Assignment/Global/Environment.swift | 4 +- .../Networks/Base/BaseTargetType.swift | 2 +- .../Presentation/Core/TabBarController.swift | 2 +- .../Presentation/Jinjae/AppDIContainer.swift | 9 +- .../Presentation/Jinjae/HomeViewModel.swift | 1 + .../Presentation/Jinjae/Movie/MovieAPI.swift | 44 +++++ .../SearchMovieCollectionViewCell_JIN.swift | 17 +- .../Movie/SearchViewController_JIN.swift | 79 +++++++- .../Jinjae/Movie/SearchViewModel_JIN.swift | 168 +++++++++++++++++- .../Jinjae/Movie/SearchView_JIN.swift | 112 ++++++++++-- 11 files changed, 396 insertions(+), 46 deletions(-) create mode 100644 Smashing-Assignment/Presentation/Jinjae/Movie/MovieAPI.swift diff --git a/Smashing-Assignment.xcodeproj/project.pbxproj b/Smashing-Assignment.xcodeproj/project.pbxproj index 6271f42..5d98d0b 100644 --- a/Smashing-Assignment.xcodeproj/project.pbxproj +++ b/Smashing-Assignment.xcodeproj/project.pbxproj @@ -157,8 +157,6 @@ /* Begin XCBuildConfiguration section */ 633612252EFBCE6200228B88 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReferenceAnchor = 633612132EFBCE6100228B88 /* Smashing-Assignment */; - baseConfigurationReferenceRelativePath = Global/Config.xcconfig; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -231,6 +229,8 @@ }; 633612272EFBCE6200228B88 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 633612132EFBCE6100228B88 /* Smashing-Assignment */; + baseConfigurationReferenceRelativePath = Global/Config.xcconfig; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; diff --git a/Smashing-Assignment/Global/Environment.swift b/Smashing-Assignment/Global/Environment.swift index a9ac593..efad537 100644 --- a/Smashing-Assignment/Global/Environment.swift +++ b/Smashing-Assignment/Global/Environment.swift @@ -8,6 +8,6 @@ import Foundation enum Environment { - static let baseURL: String = Bundle.main.infoDictionary?["BaseURL"] as! String - static let movie_API_Key: String = Bundle.main.infoDictionary?["MOVIE_API_KEY"] as! String + static let baseURL: String = Bundle.main.infoDictionary?["BaseURL"] as! String + static let movie_API_Key: String = Bundle.main.infoDictionary?["MOVIE_API_KEY"] as! String } diff --git a/Smashing-Assignment/Networks/Base/BaseTargetType.swift b/Smashing-Assignment/Networks/Base/BaseTargetType.swift index 190b2d1..d7ed979 100644 --- a/Smashing-Assignment/Networks/Base/BaseTargetType.swift +++ b/Smashing-Assignment/Networks/Base/BaseTargetType.swift @@ -13,7 +13,7 @@ protocol BaseTargetType: TargetType { } extension BaseTargetType{ var baseURL: URL { - return URL(string:Environment.baseURL)! + return URL(string: Environment.baseURL)! } var headers: [String : String]? { diff --git a/Smashing-Assignment/Presentation/Core/TabBarController.swift b/Smashing-Assignment/Presentation/Core/TabBarController.swift index 6a228bd..cdaffe0 100644 --- a/Smashing-Assignment/Presentation/Core/TabBarController.swift +++ b/Smashing-Assignment/Presentation/Core/TabBarController.swift @@ -134,7 +134,7 @@ protocol TabBarSceneFactory { final class DefaultTabBarSceneFactory: TabBarSceneFactory { func makeViewController(for tab: TabBarController.Tab) -> UIViewController { switch tab { - case .jinjae: return AppDIContainer.shared.makeJInJaeViewController() + case .jinjae: return AppDIContainer.shared.makeSearchViewController() case .junbeom: return CombineViewController_HJB() case .seungjun: return ViewController_LSJ() } diff --git a/Smashing-Assignment/Presentation/Jinjae/AppDIContainer.swift b/Smashing-Assignment/Presentation/Jinjae/AppDIContainer.swift index b575a91..a368e75 100644 --- a/Smashing-Assignment/Presentation/Jinjae/AppDIContainer.swift +++ b/Smashing-Assignment/Presentation/Jinjae/AppDIContainer.swift @@ -9,10 +9,15 @@ import UIKit class AppDIContainer { static var shared: AppDIContainer = AppDIContainer() - + func makeJInJaeViewController() -> UIViewController { let viewModel = HomeViewModel() return JinJaeViewController(viewModel: viewModel) } - + + func makeSearchViewController() -> UIViewController { + let viewModel = SearchViewModel_JIN() + return SearchViewController_JIN(viewModel: viewModel) + } + } diff --git a/Smashing-Assignment/Presentation/Jinjae/HomeViewModel.swift b/Smashing-Assignment/Presentation/Jinjae/HomeViewModel.swift index 0fad64d..b31a755 100644 --- a/Smashing-Assignment/Presentation/Jinjae/HomeViewModel.swift +++ b/Smashing-Assignment/Presentation/Jinjae/HomeViewModel.swift @@ -8,6 +8,7 @@ import UIKit import Combine + protocol InputOutputProtocol { associatedtype Input diff --git a/Smashing-Assignment/Presentation/Jinjae/Movie/MovieAPI.swift b/Smashing-Assignment/Presentation/Jinjae/Movie/MovieAPI.swift new file mode 100644 index 0000000..7983b35 --- /dev/null +++ b/Smashing-Assignment/Presentation/Jinjae/Movie/MovieAPI.swift @@ -0,0 +1,44 @@ +// +// MovieAPI.swift +// Smashing-Assignment +// +// Created by JIN on 1/10/26. +// + +import Foundation +import Alamofire +import Moya + +enum MovieAPI { + case searchMovieList(movieNm: String, curPage: Int, itemPerPage: Int) +} + +extension MovieAPI: BaseTargetType { + + var path: String { + switch self { + case .searchMovieList: + return "movie/searchMovieList.json" + } + } + + var method: Moya.Method { + switch self { + case .searchMovieList: + return .get + } + } + + var task: Task { + switch self { + case .searchMovieList(let movieNm, let curPage, let itemPerPage): + let parameters: [String: Any] = [ + "key": Environment.movie_API_Key, + "movieNm": movieNm, + "curPage": curPage, + "itemPerPage": itemPerPage + ] + return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) + } + } +} diff --git a/Smashing-Assignment/Presentation/Jinjae/Movie/SearchMovieCollectionViewCell_JIN.swift b/Smashing-Assignment/Presentation/Jinjae/Movie/SearchMovieCollectionViewCell_JIN.swift index b47ba88..2c80f03 100644 --- a/Smashing-Assignment/Presentation/Jinjae/Movie/SearchMovieCollectionViewCell_JIN.swift +++ b/Smashing-Assignment/Presentation/Jinjae/Movie/SearchMovieCollectionViewCell_JIN.swift @@ -46,19 +46,10 @@ final class SearchMovieCollectionViewCell_JIN: UICollectionViewCell { fatalError("init(coder:) has not been implemented") } - func configure(data: MovieDTO, index: Int) { - switch (index / 10 ) % 3 { - case 0: - movieNmLabel.textColor = .systemPink - case 1: - movieNmLabel.textColor = .systemCyan - case 2: - movieNmLabel.textColor = .systemGreen - default: - movieNmLabel.textColor = .white - } - movieNmLabel.text = String(index + 1) + ": " + data.movieNm - yearLabel.text = data.prdtYear + func configure(with movie: MovieDTO) { + movieNmLabel.text = movie.movieNm + yearLabel.text = movie.prdtYear + movieNmLabel.textColor = .label } } diff --git a/Smashing-Assignment/Presentation/Jinjae/Movie/SearchViewController_JIN.swift b/Smashing-Assignment/Presentation/Jinjae/Movie/SearchViewController_JIN.swift index a3a6a04..0cac986 100644 --- a/Smashing-Assignment/Presentation/Jinjae/Movie/SearchViewController_JIN.swift +++ b/Smashing-Assignment/Presentation/Jinjae/Movie/SearchViewController_JIN.swift @@ -6,14 +6,85 @@ // import UIKit +import Combine -class SearchViewController_JIN: UIViewController { - - let searchView = SearchView_JIN() +@MainActor +final class SearchViewController_JIN: UIViewController { + + // MARK: - Properties + + private let viewModel: SearchViewModel_JIN + private let input: PassthroughSubject = .init() + private var cancellables = Set() + + private let searchView = SearchView_JIN() + + // MARK: - Initializer + + init(viewModel: SearchViewModel_JIN) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - LifeCycle override func viewDidLoad() { super.viewDidLoad() - view = searchView + self.view = searchView + setupNavigationBar() + bind() + } + + // MARK: - Setup + + private func setupNavigationBar() { + navigationController?.navigationBar.prefersLargeTitles = true } + // MARK: - Bind + + private func bind() { + searchView.searchBar.textDidChangePublisher() + .map { SearchViewModel_JIN.Input.searchTextChanged($0) } + .subscribe(input) + .store(in: &cancellables) + + searchView.loadMorePublisher + .map { SearchViewModel_JIN.Input.loadMoreTriggered } + .subscribe(input) + .store(in: &cancellables) + + let output = viewModel.transform(input: input.eraseToAnyPublisher()) + + output.movies + .sink { [weak self] movies in + self?.searchView.updateMovies(movies) + } + .store(in: &cancellables) + + output.isLoading + .sink { [weak self] isLoading in + self?.searchView.setLoading(isLoading) + } + .store(in: &cancellables) + + output.error + .compactMap { $0 } + .sink { [weak self] errorMessage in + self?.showError(errorMessage) + } + .store(in: &cancellables) + } + + // MARK: - Private Methods + + private func showError(_ message: String) { + let alert = UIAlertController(title: "오류", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "확인", style: .default)) + present(alert, animated: true) + } } diff --git a/Smashing-Assignment/Presentation/Jinjae/Movie/SearchViewModel_JIN.swift b/Smashing-Assignment/Presentation/Jinjae/Movie/SearchViewModel_JIN.swift index b608710..ba00dc2 100644 --- a/Smashing-Assignment/Presentation/Jinjae/Movie/SearchViewModel_JIN.swift +++ b/Smashing-Assignment/Presentation/Jinjae/Movie/SearchViewModel_JIN.swift @@ -6,23 +6,177 @@ // import UIKit - import Combine -class SearchViewModel_JIN: InputOutputProtocol { - +protocol InputOutputStructProtocol { + + associatedtype Input + associatedtype Output + + func transform(input: AnyPublisher) -> Output + +} + +final class SearchViewModel_JIN: InputOutputStructProtocol { + + // MARK: - Input + enum Input { case searchTextChanged(String) case loadMoreTriggered } - + + // MARK: - Output + struct Output { let movies: AnyPublisher<[MovieDTO], Never> let isLoading: AnyPublisher - let error: AnyPublisher + let error: AnyPublisher } + + // MARK: - Properties + + private let moviesSubject = CurrentValueSubject<[MovieDTO], Never>([]) + private let isLoadingSubject = CurrentValueSubject(false) + private let errorSubject = CurrentValueSubject(nil) + + private var cancellables = Set() + private var currentQuery: String = "" + private var currentPage: Int = 1 + + // MARK: - Transform + + func transform(input: AnyPublisher) -> Output { + + input + .compactMap { event -> String? in + if case .searchTextChanged(let text) = event { + return text + } + return nil + } + .debounce(for: .seconds(0.3), scheduler: DispatchQueue.main) + .sink { [weak self] text in + self?.handleSearchTextChanged(text) + } + .store(in: &cancellables) + input + .filter { event in + if case .loadMoreTriggered = event { + return true + } + return false + } + .throttle(for: .seconds(0.3), scheduler: DispatchQueue.main, latest: true) + .sink { [weak self] _ in + self?.handleLoadMore() + } + .store(in: &cancellables) + + return Output( + movies: moviesSubject.eraseToAnyPublisher(), + isLoading: isLoadingSubject.eraseToAnyPublisher(), + error: errorSubject.eraseToAnyPublisher() + ) + } + + + // MARK: - Private Methods - func transform(input: AnyPublisher) -> AnyPublisher { - <#code#> + private func handleSearchTextChanged(_ text: String) { + currentQuery = text + currentPage = 1 + fetchMovies(query: text, page: 1, isNewSearch: true) + } + + private func handleLoadMore() { + currentPage += 1 + fetchMovies(query: currentQuery, page: currentPage, isNewSearch: false) + } +} + +// MARK: - API + +extension SearchViewModel_JIN { + + private func fetchMovies(query: String, page: Int, isNewSearch: Bool) { + guard !query.isEmpty else { + moviesSubject.send([]) + return + } + + isLoadingSubject.send(true) + + Task { + do { + let movies = try await loadMovies(query: query, page: page) + if isNewSearch { + self.moviesSubject.send(movies) + } else { + var currentMovies = self.moviesSubject.value + currentMovies.append(contentsOf: movies) + self.moviesSubject.send(currentMovies) + } + self.isLoadingSubject.send(false) + } catch { + self.errorSubject.send("영화 검색 실패: \(error.localizedDescription)") + self.isLoadingSubject.send(false) + } + } + } + + private func loadMovies(query: String, page: Int) async throws -> [MovieDTO] { + + return try await loadMoviesFromAPI(query: query, page: page) + + // Mock 테스트 + // return try await loadMoviesFromMock(query: query, page: page) + } + + // MARK: - API Call + + private func loadMoviesFromAPI(query: String, page: Int) async throws -> [MovieDTO] { + return try await withCheckedThrowingContinuation { continuation in + NetworkProvider.request( + .searchMovieList(movieNm: query, curPage: page, itemPerPage: 10), + type: MovieListResponse.self + ) { result in + switch result { + case .success(let response): + continuation.resume(returning: response.movieListResult.movieList) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + // MARK: - Mock Test + + private func loadMoviesFromMock(query: String, page: Int) async throws -> [MovieDTO] { + + try await Task.sleep(nanoseconds: 1_000_000_000) + + return generateDummyMovies(query: query, page: page) + } + + private func generateDummyMovies(query: String, page: Int) -> [MovieDTO] { + return (1...10).map { index in + MovieDTO( + movieCd: "\((page - 1) * 10 + index)", + movieNm: "\(query) 영화 \((page - 1) * 10 + index)", + movieNmEn: "\(query) Movie \((page - 1) * 10 + index)", + prdtYear: "202\(index % 5)", + openDt: "2024010\(index % 9 + 1)", + typeNm: "장편", + prdtStatNm: "개봉", + nationAlt: "한국", + genreAlt: "드라마", + repNationNm: "한국", + repGenreNm: "드라마", + directors: [DirectorDTO(peopleNm: "감독\(index)")], + companys: [CompanyDTO(companyCd: "C00\(index)", companyNm: "제작사\(index)")] + ) + } } } diff --git a/Smashing-Assignment/Presentation/Jinjae/Movie/SearchView_JIN.swift b/Smashing-Assignment/Presentation/Jinjae/Movie/SearchView_JIN.swift index 99007c3..e00a735 100644 --- a/Smashing-Assignment/Presentation/Jinjae/Movie/SearchView_JIN.swift +++ b/Smashing-Assignment/Presentation/Jinjae/Movie/SearchView_JIN.swift @@ -12,7 +12,9 @@ import Then import SnapKit final class SearchView_JIN: UIView { - + + // MARK: - UI Components + let searchBar = UITextField().then { $0.isUserInteractionEnabled = true $0.placeholder = "검색어를 입력하세요" @@ -30,33 +32,115 @@ final class SearchView_JIN: UIView { return collection }() + private let loadingIndicator = UIActivityIndicatorView(style: .large).then { + $0.hidesWhenStopped = true + } + + // MARK: - Properties + + private var movies: [MovieDTO] = [] + private let loadMoreSubject = PassthroughSubject() + + var loadMorePublisher: AnyPublisher { + return loadMoreSubject.eraseToAnyPublisher() + } + override init(frame: CGRect) { super.init(frame: frame) - self.addSubview(searchBar) - self.addSubview(collectionView) - + setupUI() + setupLayout() + setupCollectionView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI() { + backgroundColor = .systemBackground + addSubview(searchBar) + addSubview(collectionView) + addSubview(loadingIndicator) + } + + private func setupLayout() { searchBar.snp.makeConstraints { make in make.height.equalTo(60) make.leading.trailing.equalToSuperview().inset(20) - make.top.equalToSuperview().offset(100) + make.top.equalTo(safeAreaLayoutGuide).offset(10) } - + collectionView.snp.makeConstraints { make in - make.top.equalTo(searchBar.snp.bottom) + make.top.equalTo(searchBar.snp.bottom).offset(10) make.leading.trailing.bottom.equalToSuperview() } - } - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + loadingIndicator.snp.makeConstraints { make in + make.center.equalToSuperview() + } } - - func setCollectionViewLayout() { + + private func setupCollectionView() { + collectionView.delegate = self + collectionView.dataSource = self + let flowLayout = UICollectionViewFlowLayout() - let cellWidth: CGFloat = self.bounds.width + let cellWidth: CGFloat = UIScreen.main.bounds.width flowLayout.itemSize = CGSize(width: cellWidth, height: 100) flowLayout.minimumLineSpacing = 10 flowLayout.minimumInteritemSpacing = 0 - self.collectionView.setCollectionViewLayout(flowLayout, animated: false) + collectionView.setCollectionViewLayout(flowLayout, animated: false) + } + + // MARK: - Public Methods + + func updateMovies(_ movies: [MovieDTO]) { + self.movies = movies + collectionView.reloadData() + } + + func setLoading(_ isLoading: Bool) { + if isLoading { + loadingIndicator.startAnimating() + } else { + loadingIndicator.stopAnimating() + } + } +} + +// MARK: - UICollectionViewDataSource + +extension SearchView_JIN: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return movies.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: SearchMovieCollectionViewCell_JIN.identifier, + for: indexPath + ) as? SearchMovieCollectionViewCell_JIN else { + return UICollectionViewCell() + } + + let movie = movies[indexPath.item] + cell.configure(with: movie) + return cell + } +} + +// MARK: - UICollectionViewDelegate + +extension SearchView_JIN: UICollectionViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let offsetY = scrollView.contentOffset.y + let contentHeight = scrollView.contentSize.height + let scrollViewHeight = scrollView.frame.height + + if offsetY > contentHeight - scrollViewHeight - 100 { + loadMoreSubject.send(()) + } } }