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.xcodeproj/project.pbxproj b/Smashing-Assignment.xcodeproj/project.pbxproj index d6cd818..5d98d0b 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 */; @@ -163,8 +167,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 +177,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 +204,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,17 +214,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 = Release; }; 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; @@ -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/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/Extension/UITextField+.swift b/Smashing-Assignment/Extension/UITextField+.swift deleted file mode 100644 index b8372b1..0000000 --- a/Smashing-Assignment/Extension/UITextField+.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// UITextField+.swift -// NewCombine -// -// Created by JIN on 12/26/25. -// - -import Combine -import UIKit - -extension UITextField { - func textDidChangePublisher() -> AnyPublisher { - NotificationCenter.default - .publisher(for: UITextField.textDidChangeNotification, object: self) - .map { _ in self.text ?? "" } - .eraseToAnyPublisher() - } -} - - diff --git a/Smashing-Assignment/Global/Environment.swift b/Smashing-Assignment/Global/Environment.swift new file mode 100644 index 0000000..efad537 --- /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/Presentation/Core/UITextField+.swift b/Smashing-Assignment/Global/Extensions/UITextField+.swift similarity index 100% rename from Smashing-Assignment/Presentation/Core/UITextField+.swift rename to Smashing-Assignment/Global/Extensions/UITextField+.swift 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/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..d7ed979 --- /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 +} + diff --git a/Smashing-Assignment/Presentation/Core/AlertViewController.swift b/Smashing-Assignment/Presentation/Core/AlertViewController.swift new file mode 100644 index 0000000..d8e8fc9 --- /dev/null +++ b/Smashing-Assignment/Presentation/Core/AlertViewController.swift @@ -0,0 +1,53 @@ +// +// AlertViewController.swift +// Smashing-Assignment +// +// Created by 이승준 on 12/29/25. +// + +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..cdaffe0 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 { @@ -37,9 +41,9 @@ final class TabBarController: UITabBarController { var viewController: UIViewController { switch self { case .jinjae: - return JinJaeViewController() + return AppDIContainer.shared.makeJInJaeViewController() case .junbeom: - return CombineViewController_HJB() + return UIViewController() case .seungjun: return UIViewController() } @@ -51,6 +55,7 @@ final class TabBarController: UITabBarController { override func viewDidLoad() { super.viewDidLoad() + TabBarController.shared = self self.delegate = self setViewControllers() @@ -58,25 +63,29 @@ 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 +104,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 +126,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 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 new file mode 100644 index 0000000..a368e75 --- /dev/null +++ b/Smashing-Assignment/Presentation/Jinjae/AppDIContainer.swift @@ -0,0 +1,23 @@ +// +// AppDIContainer.swift +// Smashing-Assignment +// +// Created by JIN on 1/4/26. +// + +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/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..b31a755 --- /dev/null +++ b/Smashing-Assignment/Presentation/Jinjae/HomeViewModel.swift @@ -0,0 +1,90 @@ +// +// 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 { $0.count >= 5 && $1.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..fe79edc 100644 --- a/Smashing-Assignment/Presentation/Jinjae/JinJaeViewController.swift +++ b/Smashing-Assignment/Presentation/Jinjae/JinJaeViewController.swift @@ -9,29 +9,74 @@ 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 + setupButtonAction() bind() } - + + // MARK: - Setup Button Action + + private func setupButtonAction() { + homeView.submitButton.addTarget(self, action: #selector(submitButtonTapped), for: .touchUpInside) + } + + @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 - } - .store(in: &cancellables) + 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 + .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) } } - 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 new file mode 100644 index 0000000..2c80f03 --- /dev/null +++ b/Smashing-Assignment/Presentation/Jinjae/Movie/SearchMovieCollectionViewCell_JIN.swift @@ -0,0 +1,55 @@ +// +// 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(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 new file mode 100644 index 0000000..0cac986 --- /dev/null +++ b/Smashing-Assignment/Presentation/Jinjae/Movie/SearchViewController_JIN.swift @@ -0,0 +1,90 @@ +// +// SearchViewController_JIN.swift +// Smashing-Assignment +// +// Created by JIN on 1/8/26. +// + +import UIKit +import Combine + +@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() + 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 new file mode 100644 index 0000000..ba00dc2 --- /dev/null +++ b/Smashing-Assignment/Presentation/Jinjae/Movie/SearchViewModel_JIN.swift @@ -0,0 +1,182 @@ +// +// SearchViewModel_JIN.swift +// Smashing-Assignment +// +// Created by JIN on 1/8/26. +// + +import UIKit +import Combine + +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 + } + + // 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 + + 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 new file mode 100644 index 0000000..e00a735 --- /dev/null +++ b/Smashing-Assignment/Presentation/Jinjae/Movie/SearchView_JIN.swift @@ -0,0 +1,146 @@ +// +// 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 { + + // MARK: - UI Components + + 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 + }() + + 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) + 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.equalTo(safeAreaLayoutGuide).offset(10) + } + + collectionView.snp.makeConstraints { make in + make.top.equalTo(searchBar.snp.bottom).offset(10) + make.leading.trailing.bottom.equalToSuperview() + } + + loadingIndicator.snp.makeConstraints { make in + make.center.equalToSuperview() + } + } + + private func setupCollectionView() { + collectionView.delegate = self + collectionView.dataSource = self + + let flowLayout = UICollectionViewFlowLayout() + let cellWidth: CGFloat = UIScreen.main.bounds.width + flowLayout.itemSize = CGSize(width: cellWidth, height: 100) + flowLayout.minimumLineSpacing = 10 + flowLayout.minimumInteritemSpacing = 0 + 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(()) + } + } +} diff --git a/Smashing-Assignment/Presentation/Login/LoginViewController.swift b/Smashing-Assignment/Presentation/Login/LoginViewController.swift new file mode 100644 index 0000000..3ecbb91 --- /dev/null +++ b/Smashing-Assignment/Presentation/Login/LoginViewController.swift @@ -0,0 +1,52 @@ +// +// LoginViewController.swift +// Smashing-Assignment +// +// Created by 이승준 on 12/29/25. +// + +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 new file mode 100644 index 0000000..3850934 --- /dev/null +++ b/Smashing-Assignment/Presentation/Login/SignUpViewController.swift @@ -0,0 +1,52 @@ +// +// LoginViewController.swift +// Smashing-Assignment +// +// Created by 이승준 on 12/29/25. +// + +import UIKit + +final class SignUpViewController: 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/Seungjun/ViewController_LSJ.swift b/Smashing-Assignment/Presentation/Seungjun/ViewController_LSJ.swift new file mode 100644 index 0000000..f2bbc54 --- /dev/null +++ b/Smashing-Assignment/Presentation/Seungjun/ViewController_LSJ.swift @@ -0,0 +1,52 @@ +// +// ViewController_LSJ.swift +// Smashing-Assignment +// +// Created by 이승준 on 12/26/25. +// + +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) + } +}