diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift index f12a831..3b943f2 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift @@ -9,10 +9,14 @@ import ProjectDescription public extension TargetDependency { struct Features { + public struct Main {} public struct Home {} public struct TabBar {} public struct Follow {} public struct Travel {} + public struct Search {} + public struct Setting {} + public struct PopularTravel {} } struct Modules {} @@ -63,3 +67,27 @@ public extension TargetDependency.Features.Travel { static let feature = TargetDependency.Features.project(name: "Feature", group: group) } + +public extension TargetDependency.Features.Search { + static let group = "Search" + + static let feature = TargetDependency.Features.project(name: "Feature", group: group) +} + +public extension TargetDependency.Features.Setting { + static let group = "Setting" + + static let feature = TargetDependency.Features.project(name: "Feature", group: group) +} + +public extension TargetDependency.Features.Main { + static let group = "Main" + + static let feature = TargetDependency.Features.project(name: "Feature", group: group) +} + +public extension TargetDependency.Features.PopularTravel { + static let group = "PopularTravel" + + static let feature = TargetDependency.Features.project(name: "Feature", group: group) +} diff --git a/Plugins/EnvPlugin/ProjectDescriptionHelpers/Target+Extension.swift b/Plugins/EnvPlugin/ProjectDescriptionHelpers/Target+Extension.swift index 19f7d80..e472031 100644 --- a/Plugins/EnvPlugin/ProjectDescriptionHelpers/Target+Extension.swift +++ b/Plugins/EnvPlugin/ProjectDescriptionHelpers/Target+Extension.swift @@ -48,7 +48,8 @@ public extension Target { dependencies: [TargetDependency], scripts: [TargetScript], isStatic: Bool = false, - hasResources: Bool = true + hasResources: Bool = true, + settings: Settings? = nil ) -> Target { return .target( name: name, @@ -61,7 +62,7 @@ public extension Target { resources: hasResources ? ["Resources/**"] : nil, scripts: scripts, dependencies: dependencies, - settings: .frameworkSettings + settings: settings ?? .frameworkSettings ) } } diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 582ffcb..eb201ca 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -21,8 +21,7 @@ let project = Project.makeModule( scripts: [.swiftLint], dependencies: [ .data, - .Modules.networks, - .Features.rootFeature, + .Features.rootFeature ], settings: .appSettings() ) diff --git a/Projects/App/Sources/Application/AppComponent.swift b/Projects/App/Sources/Application/AppComponent.swift index 1944d6e..41d4beb 100644 --- a/Projects/App/Sources/Application/AppComponent.swift +++ b/Projects/App/Sources/Application/AppComponent.swift @@ -8,10 +8,15 @@ import Data import Domain -import RIBs import RootFeature +import RIBs + final class AppComponent: Component, RootDependency { + var homeUsecase: HomeUsecaseProtocol { + let homeRepository = HomeRepository(homeService: makeHomeService(tokenProvider: tokenProvider)) + return HomeUsecase(repository: homeRepository) + } var tokenProvider: TokenProviding { shared { TokenRepositoryFactory.makeTokenProvider() } diff --git a/Projects/Core/Sources/Extensions/Foundation+/String+.swift b/Projects/Core/Sources/Extensions/Foundation+/String+.swift new file mode 100644 index 0000000..8286cdd --- /dev/null +++ b/Projects/Core/Sources/Extensions/Foundation+/String+.swift @@ -0,0 +1,34 @@ +// +// String+.swift +// Core +// +// Created by ์ตœ์•ˆ์šฉ on 1/31/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +public extension String { + func toFlag() -> String { + guard self.count == 2 else { return "๐Ÿณ๏ธ" } + + let base: UInt32 = 127397 + var flagString = "" + + for uni in self.uppercased().unicodeScalars { + if let scalar = UnicodeScalar(base + uni.value) { + flagString.append(String(scalar)) + } else { + return "๐Ÿณ๏ธ" + } + } + return flagString + } + + func toKoreanCountryName() -> String { + guard self.count == 2 else { return "์•Œ ์ˆ˜ ์—†์Œ" } + + let locale = Locale(identifier: "ko_KR") + return locale.localizedString(forRegionCode: self) ?? "์•Œ ์ˆ˜ ์—†์Œ" + } +} diff --git a/Projects/Core/Sources/Extensions/UIKit+/UICollectionReusableView+.swift b/Projects/Core/Sources/Extensions/UIKit+/UICollectionReusableView+.swift new file mode 100644 index 0000000..db26157 --- /dev/null +++ b/Projects/Core/Sources/Extensions/UIKit+/UICollectionReusableView+.swift @@ -0,0 +1,15 @@ +// +// UICollectionReusableView+.swift +// Core +// +// Created by ์ตœ์•ˆ์šฉ on 2/3/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +public extension UICollectionReusableView { + static var reusableViewIdentifier : String { + return String(describing: self) + } +} diff --git a/Projects/Core/Sources/Extensions/UIKit+/UICollectionViewCell+.swift b/Projects/Core/Sources/Extensions/UIKit+/UICollectionViewCell+.swift new file mode 100644 index 0000000..db86ea8 --- /dev/null +++ b/Projects/Core/Sources/Extensions/UIKit+/UICollectionViewCell+.swift @@ -0,0 +1,15 @@ +// +// UICollectionViewCell+.swift +// Core +// +// Created by ์ตœ์•ˆ์šฉ on 1/30/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +public extension UICollectionViewCell { + static var cellIdentifier : String { + return String(describing: self) + } +} diff --git a/Projects/Core/Sources/Extensions/UIKit+/UITableViewCell+.swift b/Projects/Core/Sources/Extensions/UIKit+/UITableViewCell+.swift new file mode 100644 index 0000000..5f07558 --- /dev/null +++ b/Projects/Core/Sources/Extensions/UIKit+/UITableViewCell+.swift @@ -0,0 +1,15 @@ +// +// UITableViewCell+.swift +// Core +// +// Created by ์ตœ์•ˆ์šฉ on 2/10/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +public extension UITableViewCell { + static var cellIdentifier: String { + return String(describing: self) + } +} diff --git a/Projects/Data/Sources/Adapter/TokenProviderAdapter.swift b/Projects/Data/Sources/Adapter/TokenProviderAdapter.swift index c440c32..d622da6 100644 --- a/Projects/Data/Sources/Adapter/TokenProviderAdapter.swift +++ b/Projects/Data/Sources/Adapter/TokenProviderAdapter.swift @@ -18,6 +18,7 @@ public final class TokenProviderAdapter: TokenProviding, @unchecked Sendable { } public func accessToken() -> String? { - tokenRepository.get(.accessToken) +// tokenRepository.get(.accessToken) + "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJiYmE3ODIwYS0wMDUzLTQxZDctODdhYi00Zjk2ZWM3ZDI1MTMiLCJpYXQiOjE3NzA5NjQzMDUsImV4cCI6MTc3MTA1MDcwNX0.Sn8wNhZ1Ac-ETZDsOiSMMHHaALJNXxNKrbN_-4xD5REcVa2tJ0NiafhTKlbIuYafL1Acd9dDMIHjx3H33c5w8w" } } diff --git a/Projects/Data/Sources/DI/HomeServiceFactory.swift b/Projects/Data/Sources/DI/HomeServiceFactory.swift new file mode 100644 index 0000000..9c1947d --- /dev/null +++ b/Projects/Data/Sources/DI/HomeServiceFactory.swift @@ -0,0 +1,17 @@ +// +// HomeServiceFactory.swift +// Data +// +// Created by ์ตœ์•ˆ์šฉ on 2/10/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Domain +import Networks + +import Moya + +public func makeHomeService(tokenProvider: TokenProviding) -> HomeServiceProtocol { + let provider: MoyaProvider = NetworkProviderFactory.makeAuthenticatedProvider(tokenProvider: tokenProvider) + return HomeService(provider: provider) +} diff --git a/Projects/Data/Sources/Repository/Home/HomeRepository.swift b/Projects/Data/Sources/Repository/Home/HomeRepository.swift new file mode 100644 index 0000000..cb14917 --- /dev/null +++ b/Projects/Data/Sources/Repository/Home/HomeRepository.swift @@ -0,0 +1,52 @@ +// +// HomeRepository.swift +// Data +// +// Created by ์ตœ์•ˆ์šฉ on 2/4/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +import Domain +import Networks + +public final class HomeRepository: HomeRepositoryInterface { + private let homeService: HomeServiceProtocol + + public init(homeService: HomeServiceProtocol) { + self.homeService = homeService + } + + public func fetchMyTripInfo() async throws -> MyTripSummary { + do { + return try await homeService.getUpcoming().toDomain() + } catch { + throw error.toNDGLError() + } + } + + public func fetchCategoryList() async throws -> [TripCategory] { + do { + return try await homeService.getCategoryList().map { $0.toDomain() } + } catch { + throw error.toNDGLError() + } + } + + public func fetchPopularTripList(id: Int?, page: Int?, size: Int?) async throws -> [TripInfo] { + do { + return try await homeService.getPopularTripList(id: id, page: page, size: size).toDomain() + } catch { + throw error.toNDGLError() + } + } + + public func fetchRecommendTripList(page: Int?, size: Int?) async throws -> [TripInfo] { + do { + return try await homeService.getRecommendTripList(page: page, size: size).toDomain() + } catch { + throw error.toNDGLError() + } + } +} diff --git a/Projects/Data/Sources/Transform/Error+.swift b/Projects/Data/Sources/Transform/Error+.swift new file mode 100644 index 0000000..999a768 --- /dev/null +++ b/Projects/Data/Sources/Transform/Error+.swift @@ -0,0 +1,29 @@ +// +// Error+.swift +// Data +// +// Created by ์ตœ์•ˆ์šฉ on 2/13/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +import Domain +import Networks + +extension Error { + func toNDGLError() -> NDGLError { + if let networkError = self as? NetworkError { + switch networkError { + case .connectionFailed, .decodingFailed, .noData: + return .unknown("\(networkError.message)") + case .unknown(let string): + return .serverError(string) + case .serverError(let errorResponse): + return .serverError(errorResponse.message ?? "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.") + } + } + + return .unknown("์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.") + } +} diff --git a/Projects/Data/Sources/Transform/ProgramResponse+.swift b/Projects/Data/Sources/Transform/ProgramResponse+.swift new file mode 100644 index 0000000..1f91606 --- /dev/null +++ b/Projects/Data/Sources/Transform/ProgramResponse+.swift @@ -0,0 +1,18 @@ +// +// ProgramResponse+.swift +// Data +// +// Created by ์ตœ์•ˆ์šฉ on 2/10/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +import Domain +import Networks + +extension ProgramResponse { + func toDomain() -> TripCategory { + .init(id: self.id, creator: self.name, viedoType: VideoType(rawValue: self.type)) + } +} diff --git a/Projects/Data/Sources/Transform/TripResponse+.swift b/Projects/Data/Sources/Transform/TripResponse+.swift new file mode 100644 index 0000000..84301ff --- /dev/null +++ b/Projects/Data/Sources/Transform/TripResponse+.swift @@ -0,0 +1,29 @@ +// +// TripResponse+.swift +// Data +// +// Created by ์ตœ์•ˆ์šฉ on 2/10/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +import Domain +import Networks + +extension TripResponse { + func toDomain() -> [TripInfo] { + self.content.map { + .init( + id: $0.travelId, + title: $0.title, + thumbnailUrl: $0.thumbnail ?? "", + creator: $0.programName, + country: $0.country, + city: $0.city, + nights: $0.nights, + days: $0.days + ) + } + } +} diff --git a/Projects/Data/Sources/Transform/UpcomingResponse+.swift b/Projects/Data/Sources/Transform/UpcomingResponse+.swift new file mode 100644 index 0000000..18f65bf --- /dev/null +++ b/Projects/Data/Sources/Transform/UpcomingResponse+.swift @@ -0,0 +1,40 @@ +// +// UpcomingResponse+.swift +// Data +// +// Created by ์ตœ์•ˆ์šฉ on 2/13/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +import Domain +import Networks + +extension UpcomingResponse { + func toDomain() -> MyTripSummary { + return .init( + id: self.userTravelId, + title: self.title, + startDay: self.startDate.toDate() ?? .now, + endDay: self.endDate.toDate() ?? .now, + tripSchedule: .init( + id: self.upcomingUserTravelPlace.id, + day: 1, // ์„œ๋ฒ„์—์„œ ์ฒซ ์ผ์ •๋งŒ ๋ณด๋‚ด์ฃผ๊ณ  ์žˆ์Œ + placeName: self.upcomingUserTravelPlace.place.name, + thumbnailUrl: self.upcomingUserTravelPlace.place.thumbnail ?? "", + transport: self.upcomingUserTravelPlace.place.category, + estimatedDuration: self.upcomingUserTravelPlace.estimatedDuration + ) + ) + } +} + +extension String { + func toDate() -> Date? { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter.date(from: self) + } +} diff --git a/Projects/Domain/Sources/Error/NDGLError.swift b/Projects/Domain/Sources/Error/NDGLError.swift new file mode 100644 index 0000000..b7f68ca --- /dev/null +++ b/Projects/Domain/Sources/Error/NDGLError.swift @@ -0,0 +1,15 @@ +// +// NDGLError.swift +// Domain +// +// Created by ์ตœ์•ˆ์šฉ on 2/13/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +public enum NDGLError: Error { + case serverError(String) + case unknown(String) + case authenticationFailed +} diff --git a/Projects/Domain/Sources/Interface/Home/HomeRepositoryInterface.swift b/Projects/Domain/Sources/Interface/Home/HomeRepositoryInterface.swift new file mode 100644 index 0000000..b051de7 --- /dev/null +++ b/Projects/Domain/Sources/Interface/Home/HomeRepositoryInterface.swift @@ -0,0 +1,17 @@ +// +// HomeRepositoryInterface.swift +// Domain +// +// Created by ์ตœ์•ˆ์šฉ on 2/4/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +// MARK: - API ๋‚˜์˜ค๊ธฐ ์ „ ์ž„์‹œ +public protocol HomeRepositoryInterface { + func fetchMyTripInfo() async throws -> MyTripSummary + func fetchCategoryList() async throws -> [TripCategory] + func fetchPopularTripList(id: Int?, page: Int?, size: Int?) async throws -> [TripInfo] + func fetchRecommendTripList(page: Int?, size: Int?) async throws -> [TripInfo] +} diff --git a/Projects/Domain/Sources/Interface/Home/HomeServiceProtocol.swift b/Projects/Domain/Sources/Interface/Home/HomeServiceProtocol.swift index 75c59a5..981b935 100644 --- a/Projects/Domain/Sources/Interface/Home/HomeServiceProtocol.swift +++ b/Projects/Domain/Sources/Interface/Home/HomeServiceProtocol.swift @@ -6,18 +6,18 @@ // Copyright ยฉ 2026 NDGL-iOS. All rights reserved. // -import Foundation - -public protocol HomeServiceProtocol: Sendable { - /// ๋‚ด๊ฐ€ ๋“ฑ๋กํ•œ ์—ฌํ–‰์ง€ ๋ชฉ๋ก ์กฐํšŒ - func fetchMyTrips() async -> Result<[MyTrip], HomeError> - - /// ์ธ๊ธฐ ์—ฌํ–‰ ๋”ฐ๋ผ๊ฐ€๊ธฐ ๋ชฉ๋ก ์กฐํšŒ (๋‹จ์ผ ์นดํ…Œ๊ณ ๋ฆฌ) - func fetchPopularTrips(category: TripCategory) async -> Result<[PopularTrip], HomeError> - - /// ์ธ๊ธฐ ์—ฌํ–‰ ๋”ฐ๋ผ๊ฐ€๊ธฐ ์ „์ฒด ์กฐํšŒ (๋ชจ๋“  ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„๋กœ ๊ทธ๋ฃนํ™”) - func fetchAllPopularTrips() async -> Result<[TripCategory: [PopularTrip]], HomeError> - - /// ์ถ”์ฒœ ๋”ฐ๋ผํ•˜๊ธฐ ์ฝ˜ํ…์ธ  ๋ชฉ๋ก ์กฐํšŒ - func fetchRecommendations() async -> Result<[Recommendation], HomeError> -} +//import Foundation +// +//public protocol HomeServiceProtocol: Sendable { +// /// ๋‚ด๊ฐ€ ๋“ฑ๋กํ•œ ์—ฌํ–‰์ง€ ๋ชฉ๋ก ์กฐํšŒ +// func fetchMyTrips() async -> Result<[MyTrip], HomeError> +// +// /// ์ธ๊ธฐ ์—ฌํ–‰ ๋”ฐ๋ผ๊ฐ€๊ธฐ ๋ชฉ๋ก ์กฐํšŒ (๋‹จ์ผ ์นดํ…Œ๊ณ ๋ฆฌ) +// func fetchPopularTrips(category: TripCategory) async -> Result<[PopularTrip], HomeError> +// +// /// ์ธ๊ธฐ ์—ฌํ–‰ ๋”ฐ๋ผ๊ฐ€๊ธฐ ์ „์ฒด ์กฐํšŒ (๋ชจ๋“  ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„๋กœ ๊ทธ๋ฃนํ™”) +// func fetchAllPopularTrips() async -> Result<[TripCategory: [PopularTrip]], HomeError> +// +// /// ์ถ”์ฒœ ๋”ฐ๋ผํ•˜๊ธฐ ์ฝ˜ํ…์ธ  ๋ชฉ๋ก ์กฐํšŒ +// func fetchRecommendations() async -> Result<[Recommendation], HomeError> +//} diff --git a/Projects/Domain/Sources/Model/Home/HomeError.swift b/Projects/Domain/Sources/Model/Home/HomeError.swift index a7443df..bb5ecda 100644 --- a/Projects/Domain/Sources/Model/Home/HomeError.swift +++ b/Projects/Domain/Sources/Model/Home/HomeError.swift @@ -8,8 +8,8 @@ import Foundation -public enum HomeError: Error, Sendable { - case networkError(message: String) - case serverError(message: String) - case unknown(code: String, message: String) -} +//public enum HomeError: Error, Sendable { +// case networkError(message: String) +// case serverError(message: String) +// case unknown(code: String, message: String) +//} diff --git a/Projects/Domain/Sources/Model/Home/MyTripSummary.swift b/Projects/Domain/Sources/Model/Home/MyTripSummary.swift new file mode 100644 index 0000000..894937f --- /dev/null +++ b/Projects/Domain/Sources/Model/Home/MyTripSummary.swift @@ -0,0 +1,52 @@ +// +// MyTripSummary.swift +// Domain +// +// Created by ์ตœ์•ˆ์šฉ on 2/4/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +// MARK: - API ๋‚˜์˜ค๊ธฐ ์ „ ์ž„์‹œ +public struct MyTripSummary { + public let id: Int + public let title: String + public let startDay: Date + public let endDay: Date + public let tripSchedule: Schedule + + public init(id: Int, title: String, startDay: Date, endDay: Date, tripSchedule: Schedule) { + self.id = id + self.title = title + self.startDay = startDay + self.endDay = endDay + self.tripSchedule = tripSchedule + } +} + +// MARK: - API ๋‚˜์˜ค๊ธฐ ์ „ ์ž„์‹œ +public struct Schedule { + public let id: Int + public let day: Int + public let placeName: String + public let thumbnailUrl: String + public let transport: String + public let estimatedDuration: Int + + public init( + id: Int, + day: Int, + placeName: String, + thumbnailUrl: String, + transport: String, + estimatedDuration: Int + ) { + self.id = id + self.day = day + self.placeName = placeName + self.thumbnailUrl = thumbnailUrl + self.transport = transport + self.estimatedDuration = estimatedDuration + } +} diff --git a/Projects/Domain/Sources/Model/Home/TripCategory.swift b/Projects/Domain/Sources/Model/Home/TripCategory.swift new file mode 100644 index 0000000..8e4f56f --- /dev/null +++ b/Projects/Domain/Sources/Model/Home/TripCategory.swift @@ -0,0 +1,21 @@ +// +// TripCategory.swift +// Domain +// +// Created by ์ตœ์•ˆ์šฉ on 2/4/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +public struct TripCategory { + public let id: Int + public let creator: String + public let viedoType: VideoType + + public init(id: Int, creator: String, viedoType: VideoType) { + self.id = id + self.creator = creator + self.viedoType = viedoType + } +} diff --git a/Projects/Domain/Sources/Model/Home/TripInfo.swift b/Projects/Domain/Sources/Model/Home/TripInfo.swift new file mode 100644 index 0000000..d9911b4 --- /dev/null +++ b/Projects/Domain/Sources/Model/Home/TripInfo.swift @@ -0,0 +1,40 @@ +// +// TripInfo.swift +// Domain +// +// Created by ์ตœ์•ˆ์šฉ on 2/4/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +public struct TripInfo { + public let id: String + public let title: String + public let thumbnailUrl: String + public let creator: String + public let country: String + public let city: String + public let nights: Int + public let days: Int + + public init( + id: String, + title: String, + thumbnailUrl: String, + creator: String, + country: String, + city: String, + nights: Int, + days: Int + ) { + self.id = id + self.title = title + self.thumbnailUrl = thumbnailUrl + self.creator = creator + self.country = country + self.city = city + self.nights = nights + self.days = days + } +} diff --git a/Projects/Domain/Sources/Model/Home/VideoType.swift b/Projects/Domain/Sources/Model/Home/VideoType.swift new file mode 100644 index 0000000..428fcdb --- /dev/null +++ b/Projects/Domain/Sources/Model/Home/VideoType.swift @@ -0,0 +1,26 @@ +// +// VideoType.swift +// Domain +// +// Created by ์ตœ์•ˆ์šฉ on 2/10/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +public enum VideoType: String { + case youtube = "YOUTUBE" + case tv = "TV" + case none + + public init(rawValue: String) { + switch rawValue { + case "YOUTUBE": + self = .youtube + case "TV": + self = .tv + default: + self = .none + } + } +} diff --git a/Projects/Domain/Sources/Model/Travel/PopularTrip.swift b/Projects/Domain/Sources/Model/Travel/PopularTrip.swift index b8f370f..5406d1f 100644 --- a/Projects/Domain/Sources/Model/Travel/PopularTrip.swift +++ b/Projects/Domain/Sources/Model/Travel/PopularTrip.swift @@ -8,40 +8,40 @@ import Foundation -/// ์ธ๊ธฐ ์—ฌํ–‰ ๋”ฐ๋ผ๊ฐ€๊ธฐ -public struct PopularTrip: Hashable { - public let id: Int - public let title: String - public let authorName: String - public let destination: String - public let duration: String - public let thumbnailURL: String? - public let category: TripCategory - - public init( - id: Int, - title: String, - authorName: String, - destination: String, - duration: String, - thumbnailURL: String?, - category: TripCategory - ) { - self.id = id - self.title = title - self.authorName = authorName - self.destination = destination - self.duration = duration - self.thumbnailURL = thumbnailURL - self.category = category - } -} - -public enum TripCategory: String, CaseIterable, Hashable { - case all = "์ „์ฒด" - case vietnam = "๋ฒ ํŠธ๋‚จ" - case europe = "์œ ๋Ÿฝ" - case hongkong = "ํ™์ฝฉ/๋งˆ์นด์˜ค" - case singapore = "์‹ฑ๊ฐ€ํฌ๋ฅด" - case japan = "์ผ๋ณธ" -} +///// ์ธ๊ธฐ ์—ฌํ–‰ ๋”ฐ๋ผ๊ฐ€๊ธฐ +//public struct PopularTrip: Hashable { +// public let id: Int +// public let title: String +// public let authorName: String +// public let destination: String +// public let duration: String +// public let thumbnailURL: String? +// public let category: TripCategory +// +// public init( +// id: Int, +// title: String, +// authorName: String, +// destination: String, +// duration: String, +// thumbnailURL: String?, +// category: TripCategory +// ) { +// self.id = id +// self.title = title +// self.authorName = authorName +// self.destination = destination +// self.duration = duration +// self.thumbnailURL = thumbnailURL +// self.category = category +// } +//} +// +//public enum TripCategory: String, CaseIterable, Hashable { +// case all = "์ „์ฒด" +// case vietnam = "๋ฒ ํŠธ๋‚จ" +// case europe = "์œ ๋Ÿฝ" +// case hongkong = "ํ™์ฝฉ/๋งˆ์นด์˜ค" +// case singapore = "์‹ฑ๊ฐ€ํฌ๋ฅด" +// case japan = "์ผ๋ณธ" +//} diff --git a/Projects/Domain/Sources/Model/Travel/Recommendation.swift b/Projects/Domain/Sources/Model/Travel/Recommendation.swift index 2307ecc..54565c5 100644 --- a/Projects/Domain/Sources/Model/Travel/Recommendation.swift +++ b/Projects/Domain/Sources/Model/Travel/Recommendation.swift @@ -9,27 +9,27 @@ import Foundation /// ์ถ”์ฒœ ๋”ฐ๋ผํ•˜๊ธฐ ์ฝ˜ํ…์ธ  -public struct Recommendation: Hashable { - public let id: Int - public let title: String - public let authorName: String - public let destination: String - public let duration: String - public let thumbnailURL: String? - - public init( - id: Int, - title: String, - authorName: String, - destination: String, - duration: String, - thumbnailURL: String? - ) { - self.id = id - self.title = title - self.authorName = authorName - self.destination = destination - self.duration = duration - self.thumbnailURL = thumbnailURL - } -} +//public struct Recommendation: Hashable { +// public let id: Int +// public let title: String +// public let authorName: String +// public let destination: String +// public let duration: String +// public let thumbnailURL: String? +// +// public init( +// id: Int, +// title: String, +// authorName: String, +// destination: String, +// duration: String, +// thumbnailURL: String? +// ) { +// self.id = id +// self.title = title +// self.authorName = authorName +// self.destination = destination +// self.duration = duration +// self.thumbnailURL = thumbnailURL +// } +//} diff --git a/Projects/Domain/Sources/UseCase/HomeUsecase.swift b/Projects/Domain/Sources/UseCase/HomeUsecase.swift new file mode 100644 index 0000000..9944dec --- /dev/null +++ b/Projects/Domain/Sources/UseCase/HomeUsecase.swift @@ -0,0 +1,60 @@ +// +// HomeUsecase.swift +// Domain +// +// Created by ์ตœ์•ˆ์šฉ on 2/4/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +// MARK: - API ๋‚˜์˜ค๊ธฐ ์ „ ์ž„์‹œ +public protocol HomeUsecaseProtocol { + func fetchMyTripInfo() async throws -> MyTripSummary + func fetchCategoryList() async throws -> [TripCategory] + func fetchPopularTripList(id: Int?, page: Int?, size: Int?) async throws -> [TripInfo] + func fetchRecommendTripList(page: Int?, size: Int?) async throws -> [TripInfo] +} + +public extension HomeUsecaseProtocol { + func fetchPopularTripList( + id: Int? = nil, + page: Int? = nil, + size: Int? = nil + ) async throws -> [TripInfo] { + try await fetchPopularTripList(id: id, page: page, size: size) + } + + func fetchRecommendTripList( + page: Int? = nil, + size: Int? = nil + ) async throws -> [TripInfo] { + try await fetchRecommendTripList(page: page, size: size) + } +} + +public final class HomeUsecase { + private let repository: HomeRepositoryInterface + + public init(repository: HomeRepositoryInterface) { + self.repository = repository + } +} + +extension HomeUsecase: HomeUsecaseProtocol { + public func fetchMyTripInfo() async throws -> MyTripSummary { + try await repository.fetchMyTripInfo() + } + + public func fetchCategoryList() async throws -> [TripCategory] { + try await repository.fetchCategoryList() + } + + public func fetchPopularTripList(id: Int?, page: Int?, size: Int?) async throws -> [TripInfo] { + try await repository.fetchPopularTripList(id: id, page: page, size: size) + } + + public func fetchRecommendTripList(page: Int?, size: Int?) async throws -> [TripInfo] { + try await repository.fetchRecommendTripList(page: page, size: size) + } +} diff --git a/Projects/Features/BaseFeatureDependency/Project.swift b/Projects/Features/BaseFeatureDependency/Project.swift index a0b7d45..584bfc9 100644 --- a/Projects/Features/BaseFeatureDependency/Project.swift +++ b/Projects/Features/BaseFeatureDependency/Project.swift @@ -15,7 +15,6 @@ let project = Project.makeModule( .makeFrameworkTarget( name: "BaseFeatureDependency", dependencies: [ - .core, .domain, .Modules.dsKit ], diff --git a/Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift b/Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift index 41615a3..b4e91a1 100644 --- a/Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift +++ b/Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift @@ -9,7 +9,6 @@ import Core import Domain import DSKit -import Kingfisher import SnapKit import Then import UIKit diff --git a/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift b/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift index a0d1032..e88e556 100644 --- a/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift +++ b/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift @@ -9,7 +9,6 @@ import Core import Domain import DSKit -import Kingfisher import SnapKit import Then import UIKit diff --git a/Projects/Features/HomeFeature/Project.swift b/Projects/Features/HomeFeature/Project.swift index 7bd4377..ad3ee86 100644 --- a/Projects/Features/HomeFeature/Project.swift +++ b/Projects/Features/HomeFeature/Project.swift @@ -16,8 +16,10 @@ let project = Project.makeModule( name: "HomeFeature", dependencies: [ .Features.baseFeatureDependency, - .Features.Follow.feature, - .data + + // TODO: - ์ง€์›Œ์•ผ๋จ + .data, + .Modules.networks ], scripts: [.swiftLint], isStatic: true, diff --git a/Projects/Features/HomeFeature/Sources/HomeBuilder.swift b/Projects/Features/HomeFeature/Sources/HomeBuilder.swift index 91b90dc..e59cab2 100644 --- a/Projects/Features/HomeFeature/Sources/HomeBuilder.swift +++ b/Projects/Features/HomeFeature/Sources/HomeBuilder.swift @@ -6,31 +6,24 @@ // Copyright ยฉ 2026 NDGL-iOS. All rights reserved. // -import Data import Domain -import FollowFeature + +// TODO: - ์ง€์›Œ์•ผ๋จ +import Data import RIBs // MARK: - HomeDependency public protocol HomeDependency: Dependency { var tokenProvider: TokenProviding { get } + var homeUsecase: HomeUsecaseProtocol { get } } // MARK: - HomeComponent -final class HomeComponent: Component, FollowDetailDependency { - var homeService: HomeServiceProtocol { - // TODO: ์‹ค์ œ API ์—ฐ๋™ ์‹œ ์‹ค์ œ Service๋กœ ๊ต์ฒด - MockHomeService() - } - - var followService: FollowServiceProtocol { - makeFollowService() - } - - var travelService: TravelServiceProtocol { - makeTravelService(tokenProvider: dependency.tokenProvider) +final class HomeComponent: Component { + var homeUsecase: HomeUsecaseProtocol { + dependency.homeUsecase } } @@ -53,18 +46,13 @@ public final class HomeBuilder: Builder, HomeBuildable { let viewController = HomeViewController() let interactor = HomeInteractor( presenter: viewController, - homeService: component.homeService + usecase: component.homeUsecase ) interactor.listener = listener - - let followDetailBuilder = FollowDetailBuilder(dependency: component) - - let router = HomeRouter( + + return HomeRouter( interactor: interactor, - viewController: viewController, - followDetailBuilder: followDetailBuilder + viewController: viewController ) - - return router } } diff --git a/Projects/Features/HomeFeature/Sources/HomeInteractor.swift b/Projects/Features/HomeFeature/Sources/HomeInteractor.swift index ffa65f7..0d32331 100644 --- a/Projects/Features/HomeFeature/Sources/HomeInteractor.swift +++ b/Projects/Features/HomeFeature/Sources/HomeInteractor.swift @@ -6,166 +6,178 @@ // Copyright ยฉ 2026 NDGL-iOS. All rights reserved. // -import Domain -import FollowFeature import Foundation + +import Domain + import RIBs +import RxCocoa +import RxRelay import RxSwift -// MARK: - HomeListener +struct HomeSectionModel { + let section: HomeSectionKind + let items: [HomeItem] +} -public protocol HomeListener: AnyObject { - func homeDidAddTrip(title: String, startDate: Date, endDate: Date) +// MARK: - HomeRouting +public protocol HomeRouting: ViewableRouting { + } // MARK: - HomePresentable - protocol HomePresentable: Presentable { var listener: HomePresentableListener? { get set } - - func updateCategories(_ categories: [TripCategory], selectedIndex: Int) - func updateMyTrips(_ trips: [MyTrip]) - func updatePopularTrips(_ tripsByCategory: [TripCategory: [PopularTrip]], categories: [TripCategory]) - func updateRecommendations(_ recommendations: [Recommendation]) - func scrollToCategory(at index: Int) - func showLoading() - func hideLoading() + + func update(with sections: [HomeSectionModel]) + func setLoading(_ isLoading: Bool) + func showErrorView(_ isError: Bool) } -// MARK: - HomePresentableListener - -protocol HomePresentableListener: AnyObject { - func didSelectCategory(at index: Int) - func didScrollToCategory(at index: Int) - func didSelectPopularTrip(at index: Int, in section: Int) - func didSelectRecommendation(at index: Int) - func didTapShowMoreTrips() - func didTapAddButton() - func didTapRefresh() +// MARK: - HomeListener +public protocol HomeListener: AnyObject { + func homeDidTapFollowDetail(with recommendationId: Int) + func homeDidTapSearch() + func homeDidTapSetting() + func homeDidTapPopularTravel() } // MARK: - HomeInteractor final class HomeInteractor: PresentableInteractor, HomeInteractable { - weak var router: HomeRouting? weak var listener: HomeListener? - private let homeService: HomeServiceProtocol + private var fetchDataTask: Task? + private let usecase: HomeUsecaseProtocol private let disposeBag = DisposeBag() // MARK: - Data (Source of Truth) + private let homeDataRelay = BehaviorRelay(value: nil) + private let selectedCategoryRelay = BehaviorRelay(value: nil) - private let categories: [TripCategory] = TripCategory.allCases - private var selectedCategoryIndex: Int = 0 - private var myTrips: [MyTrip] = [] - private var tripsByCategory: [TripCategory: [PopularTrip]] = [:] - private var recommendations: [Recommendation] = [] - - init(presenter: HomePresentable, homeService: HomeServiceProtocol) { - self.homeService = homeService + init(presenter: HomePresentable, usecase: HomeUsecaseProtocol) { + self.usecase = usecase super.init(presenter: presenter) presenter.listener = self } override func didBecomeActive() { super.didBecomeActive() - presenter.updateCategories(categories, selectedIndex: selectedCategoryIndex) - loadHomeData() + + setupStream() + fetchHomeData() } override func willResignActive() { super.willResignActive() + + fetchDataTask?.cancel() + fetchDataTask = nil } - // MARK: - Private Methods - - private func loadHomeData() { - Task { - await MainActor.run { - presenter.showLoading() + private func setupStream() { + Observable.combineLatest( + homeDataRelay.compactMap { $0 }, + selectedCategoryRelay + ) + .map { model, selectedId -> [HomeSectionModel] in + return [ + .init(section: .banner, items: [.banner(model.banner)]), + .init(section: .category, items: model.category.map { + .category($0, isSelected: $0.id == selectedId) + }), + .init(section: .popularTrip, items: model.popularTrip.map { .popularTrip($0) }), + .init(section: .recommendedTrip, items: model.recommendedTrip.map { .recommendedTrip($0) }) + ] + } + .subscribe(with: self) { owner, sections in + owner.presenter.update(with: sections) + } + .disposed(by: disposeBag) + + homeDataRelay + .map { $0 == nil } + .subscribe(with: self) { owner, isLoading in + owner.presenter.setLoading(isLoading) } - - async let myTripsResult = homeService.fetchMyTrips() - async let tripsByCategoryResult = homeService.fetchAllPopularTrips() - async let recommendationsResult = homeService.fetchRecommendations() - - let (myTripsData, tripsByCategoryData, recommendationsData) = await ( - (try? myTripsResult.get()) ?? [], - (try? tripsByCategoryResult.get()) ?? [:], - (try? recommendationsResult.get()) ?? [] - ) - - await MainActor.run { - self.myTrips = myTripsData - self.tripsByCategory = tripsByCategoryData - self.recommendations = recommendationsData - presenter.hideLoading() - presenter.updateMyTrips(myTripsData) - presenter.updatePopularTrips(tripsByCategoryData, categories: categories) - presenter.updateRecommendations(recommendationsData) + .disposed(by: disposeBag) + } + + private func fetchHomeData() { + fetchDataTask?.cancel() + + presenter.setLoading(true) + presenter.showErrorView(false) + + fetchDataTask = Task { [weak self] in + guard let self, !Task.isCancelled else { return } + + do { + let myTripBanner: HomePresentationModel.Banner = await { + do { + return try await self.usecase.fetchMyTripInfo().toPresention() + } catch { + + return .empty + } + }() + + async let categories = self.usecase.fetchCategoryList().map { $0.toHomeModel() } + async let populars = self.usecase.fetchPopularTripList().map { $0.toPopularHomeModel() } + async let recommended = self.usecase.fetchRecommendTripList().map { $0.toRecommendHomeModel() } + + let model = try await HomePresentationModel( + banner: myTripBanner, + category: categories, + popularTrip: populars, + recommendedTrip: recommended + ) + + guard !Task.isCancelled else { return } + + if self.selectedCategoryRelay.value == nil, let firstId = model.category.first?.id { + self.selectedCategoryRelay.accept(firstId) + } + + homeDataRelay.accept(model) + presenter.setLoading(false) + } catch let error { + print(error) + presenter.setLoading(false) + presenter.showErrorView(true) } } } } // MARK: - HomePresentableListener - extension HomeInteractor: HomePresentableListener { - func didSelectCategory(at index: Int) { - guard index != selectedCategoryIndex, index < categories.count else { return } - selectedCategoryIndex = index - presenter.updateCategories(categories, selectedIndex: index) - presenter.scrollToCategory(at: index) - } - - func didScrollToCategory(at index: Int) { - guard index != selectedCategoryIndex, index < categories.count else { return } - selectedCategoryIndex = index - presenter.updateCategories(categories, selectedIndex: index) - } - - func didSelectPopularTrip(at index: Int, in section: Int) { - guard section < categories.count else { return } - let category = categories[section] - guard let trips = tripsByCategory[category], index < trips.count else { return } - // TODO: ์‹ค์ œ API ์—ฐ๋™ ์‹œ trip.id ์‚ฌ์šฉ - // ํ˜„์žฌ๋Š” ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•ด ํ•ญ์ƒ id 2๋กœ ์ด๋™ - router?.routeToFollowDetail(with: 2) - } - - func didSelectRecommendation(at index: Int) { - guard index < recommendations.count else { return } - // TODO: ์‹ค์ œ API ์—ฐ๋™ ์‹œ recommendation.id ์‚ฌ์šฉ - // ํ˜„์žฌ๋Š” ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•ด ํ•ญ์ƒ id 2๋กœ ์ด๋™ - router?.routeToFollowDetail(with: 2) + func reloadBtnTapped() { + fetchHomeData() } - - func didTapShowMoreTrips() { - // TODO: ๋”๋ณด๊ธฐ ํ™”๋ฉด์œผ๋กœ ์ด๋™ - print("Show more trips tapped") + + func searchBtnTapped() { + listener?.homeDidTapSearch() } - - func didTapAddButton() { - // TODO: ์—ฌํ–‰ ์ถ”๊ฐ€ ํ™”๋ฉด์œผ๋กœ ์ด๋™ - print("Add button tapped") + + func settingBtnTapped() { + listener?.homeDidTapSetting() } - - func didTapRefresh() { - loadHomeData() - } -} - -// MARK: - FollowDetailListener - -extension HomeInteractor: FollowDetailListener { - func followDetailDidTapClose() { - router?.detachFollowDetail() + + func itemSelected(item: HomeItem) { + switch item { + case .category(let category, _): + selectedCategoryRelay.accept(category.id) + case .popularTrip(let trip): + listener?.homeDidTapFollowDetail(with: Int(trip.id) ?? 2) + case .recommendedTrip(let trip): + listener?.homeDidTapFollowDetail(with: Int(trip.id) ?? 2) + default: break + } } - - func followDetailDidAddTrip(title: String, startDate: Date, endDate: Date) { - router?.detachFollowDetail() - // TabBar์— ์•Œ๋ ค์„œ Travel ํƒญ์œผ๋กœ ์ด๋™ - listener?.homeDidAddTrip(title: title, startDate: startDate, endDate: endDate) + + func moreBtnTapped() { + listener?.homeDidTapPopularTravel() } } diff --git a/Projects/Features/HomeFeature/Sources/HomeRouter.swift b/Projects/Features/HomeFeature/Sources/HomeRouter.swift index 480e5e4..4204d82 100644 --- a/Projects/Features/HomeFeature/Sources/HomeRouter.swift +++ b/Projects/Features/HomeFeature/Sources/HomeRouter.swift @@ -8,11 +8,9 @@ import RIBs -import FollowFeature - // MARK: - HomeInteractable -protocol HomeInteractable: Interactable, FollowDetailListener { +public protocol HomeInteractable: Interactable { var router: HomeRouting? { get set } var listener: HomeListener? { get set } } @@ -20,55 +18,15 @@ protocol HomeInteractable: Interactable, FollowDetailListener { // MARK: - HomeViewControllable public protocol HomeViewControllable: ViewControllable { - func push(_ viewController: ViewControllable) - func pop() -} - -// MARK: - HomeRouting - -public protocol HomeRouting: ViewableRouting { - func routeToFollowDetail(with recommendationId: Int) - func detachFollowDetail() + } // MARK: - HomeRouter final class HomeRouter: ViewableRouter, HomeRouting { - - private let followDetailBuilder: FollowDetailBuildable - private var followDetailRouter: FollowDetailRouting? - - init( - interactor: HomeInteractable, - viewController: HomeViewControllable, - followDetailBuilder: FollowDetailBuildable - ) { - self.followDetailBuilder = followDetailBuilder + + override init(interactor: HomeInteractable, viewController: HomeViewControllable) { super.init(interactor: interactor, viewController: viewController) interactor.router = self } - - // MARK: - HomeRouting - - func routeToFollowDetail(with recommendationId: Int) { - guard followDetailRouter == nil else { return } - - let router = followDetailBuilder.build(withListener: interactor, recommendationId: recommendationId) - followDetailRouter = router - attachChild(router) - viewController.push(router.viewControllable) - } - - func detachFollowDetail() { - guard let router = followDetailRouter else { return } - - // FollowDetail VC๊ฐ€ ์•„์ง ๋„ค๋น„๊ฒŒ์ด์…˜ ์Šคํƒ์— ์žˆ๋Š” ๊ฒฝ์šฐ์—๋งŒ pop - if let navController = viewController.uiviewController.navigationController, - navController.viewControllers.contains(router.viewControllable.uiviewController) { - viewController.pop() - } - - detachChild(router) - followDetailRouter = nil - } } diff --git a/Projects/Features/HomeFeature/Sources/HomeViewController.swift b/Projects/Features/HomeFeature/Sources/HomeViewController.swift index 7ada74a..90910ee 100644 --- a/Projects/Features/HomeFeature/Sources/HomeViewController.swift +++ b/Projects/Features/HomeFeature/Sources/HomeViewController.swift @@ -6,256 +6,292 @@ // Copyright ยฉ 2026 NDGL-iOS. All rights reserved. // -import Core +import UIKit + import Domain import DSKit -import UIKit + import RIBs +import RxCocoa import RxSwift -import SnapKit -import Then -// MARK: - HomeViewController +// MARK: - HomePresentableListener -final class HomeViewController: UIViewController, HomePresentable, HomeViewControllable { +protocol HomePresentableListener: AnyObject { + func searchBtnTapped() + func settingBtnTapped() + func itemSelected(item: HomeItem) + func moreBtnTapped() + func reloadBtnTapped() +} - // MARK: - Properties +// MARK: - HomeViewController +final class HomeViewController: UIViewController, HomeViewControllable { + // MARK: - Properties weak var listener: HomePresentableListener? - + private let disposeBag = DisposeBag() - - // MARK: - UI Components - - private let scrollView = UIScrollView().then { - $0.showsVerticalScrollIndicator = false - $0.contentInset.bottom = 79 - } - - private let contentView = UIView() - - private let loadingIndicator = UIActivityIndicatorView(style: .large).then { - $0.hidesWhenStopped = true - } - - private let myTravelView = MyTravelView() - - private let followGuideLabel = UILabel().then { - $0.setText(.subTitleLSB, text: "์ธ๊ธฐ ์—ฌํ–‰ ๋”ฐ๋ผ๊ฐ€๊ธฐ", color: UIColor(hexCode: "#111111")) - } - - private let categoryCollectionView = CategoryCollectionView() - - private let youtuberContentCollectionView = YoutuberContentCollectionView() - - private let showOtherTravelButton = UIButton().then { - $0.setTitle("์—ฌํ–‰ ๋”ฐ๋ผ๊ฐ€๊ธฐ ๋”๋ณด๊ธฐ", for: .normal) - $0.setTitleColor(UIColor(hexCode: "#2C2C2C"), for: .normal) - $0.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) - $0.backgroundColor = UIColor(hexCode: "#FFFFFF") - $0.layer.cornerRadius = 8 - $0.layer.borderWidth = 1.0 - $0.layer.borderColor = UIColor(hexCode: "#D9D9D9").cgColor - } - - private let recommendContentGuideLabel = UILabel().then { - $0.setText(.subTitleLSB, text: "OOO๋‹˜๊ป˜ ์ถ”์ฒœํ•˜๋Š”\n๋”ฐ๋ผ๊ฐ€๊ธฐ ์—ฌํ–‰ ์ฝ˜ํ…์ธ ์—์š”!", color: UIColor(hexCode: "#111111")) - $0.numberOfLines = 2 - } - - private let recommendContentCollectionView = RecommendContentCollectionView() - - private let addFloatingButton = UIButton().then { - $0.backgroundColor = UIColor(hexCode: "#28A745") - $0.layer.cornerRadius = 28 - $0.setImage(DSKitAsset.Assets.icPlus2.image, for: .normal) - $0.tintColor = .white - } - + let moreButtonTapped = PublishSubject() + + // MARK: - UI Components + private let navigationBar = NDGLNavigationBar( + style: .white, + trailingIcon: DSKitAsset.Assets.icSearch2.image, + trailing2Icon: DSKitAsset.Assets.icSettings1.image + ) + private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout()) + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + private let networkErrorView = NDGLErrorView() + + private var dataSource: UICollectionViewDiffableDataSource? + // MARK: - Lifecycle - + override func viewDidLoad() { super.viewDidLoad() - setupDelegates() - setupActions() - setupUI() - setupConstraints() + setStyle() + setUI() + setLayout() + + setCollectionView() + setDataSource() + bindInteractor() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController?.setNavigationBarHidden(true, animated: animated) } - - // MARK: - Setup - - private func setupDelegates() { - categoryCollectionView.categoryDelegate = self - youtuberContentCollectionView.contentDelegate = self - recommendContentCollectionView.contentDelegate = self - } - - private func setupActions() { - showOtherTravelButton.addTarget(self, action: #selector(showOtherTravelButtonTapped), for: .touchUpInside) - addFloatingButton.addTarget(self, action: #selector(addFloatingButtonTapped), for: .touchUpInside) - } - - // MARK: - Actions - - @objc private func showOtherTravelButtonTapped() { - listener?.didTapShowMoreTrips() - } - - @objc private func addFloatingButtonTapped() { - listener?.didTapAddButton() - } } -// MARK: - HomePresentable +// MARK: - UI Setup -extension HomeViewController { - func updateMyTrips(_ trips: [Domain.MyTrip]) { +private extension HomeViewController { + func setStyle() { + view.backgroundColor = DSKitAsset.Colors.white.color + + collectionView.do { + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false + $0.backgroundColor = .clear + $0.contentInset = .zero + $0.isScrollEnabled = true + } + loadingIndicator.do { + $0.color = DSKitAsset.Colors.green300.color + } + + networkErrorView.do { + $0.isHidden = true + } } - func updateCategories(_ categories: [TripCategory], selectedIndex: Int) { - let categoryNames = categories.map { $0.rawValue } - categoryCollectionView.applySnapshot(categories: categoryNames, selectedIndex: selectedIndex) - } - - func updatePopularTrips(_ tripsByCategory: [TripCategory: [PopularTrip]], categories: [TripCategory]) { - youtuberContentCollectionView.applySnapshot(tripsByCategory: tripsByCategory, categories: categories) - } - - func updateRecommendations(_ recommendations: [Recommendation]) { - recommendContentCollectionView.applySnapshot(recommendations: recommendations) - } - - func scrollToCategory(at index: Int) { - youtuberContentCollectionView.scrollToCategory(at: index, animated: true) - } - - func showLoading() { - loadingIndicator.startAnimating() - } - - func hideLoading() { - loadingIndicator.stopAnimating() + func setUI() { + view.addSubviews(collectionView, navigationBar, loadingIndicator, networkErrorView) } -} - -// MARK: - UI Setup - -extension HomeViewController { - private func setupUI() { - view.backgroundColor = .white - [scrollView, loadingIndicator, addFloatingButton].forEach { - view.addSubview($0) + + func setLayout() { + navigationBar.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide) + $0.directionalHorizontalEdges.equalToSuperview() } - scrollView.addSubview(contentView) - - [myTravelView, followGuideLabel, categoryCollectionView, youtuberContentCollectionView, showOtherTravelButton, recommendContentGuideLabel, recommendContentCollectionView].forEach { - contentView.addSubview($0) + + collectionView.snp.makeConstraints { + $0.top.equalTo(navigationBar.snp.bottom) + $0.bottom.equalToSuperview() + $0.directionalHorizontalEdges.equalToSuperview() } - - } - - private func setupConstraints() { + loadingIndicator.snp.makeConstraints { $0.center.equalToSuperview() } - scrollView.snp.makeConstraints { - $0.top.bottom.equalTo(view.safeAreaLayoutGuide) - $0.leading.trailing.equalToSuperview() - } - contentView.snp.makeConstraints { - $0.edges.equalToSuperview() - $0.width.equalToSuperview() - } - myTravelView.snp.makeConstraints { - $0.top.equalToSuperview().offset(18) - $0.leading.equalToSuperview().offset(24) - $0.trailing.equalToSuperview().offset(-24) - $0.height.equalTo(80) - } - followGuideLabel.snp.makeConstraints { - $0.top.equalTo(myTravelView.snp.bottom).offset(40) - $0.leading.equalToSuperview().offset(24) - $0.height.equalTo(28) - } - categoryCollectionView.snp.makeConstraints { - $0.top.equalTo(followGuideLabel.snp.bottom).offset(16) - $0.leading.equalToSuperview().offset(24) - $0.trailing.equalToSuperview() - $0.height.equalTo(36) - } - youtuberContentCollectionView.snp.makeConstraints { - $0.top.equalTo(categoryCollectionView.snp.bottom).offset(16) - $0.leading.equalToSuperview().offset(24) - $0.trailing.equalToSuperview().offset(-24) - $0.height.equalTo(288) - } - showOtherTravelButton.snp.makeConstraints { - $0.top.equalTo(youtuberContentCollectionView.snp.bottom).offset(24) - $0.leading.equalToSuperview().offset(24) - $0.trailing.equalToSuperview().offset(-24) - $0.height.equalTo(40) - } - recommendContentGuideLabel.snp.makeConstraints { - $0.top.equalTo(showOtherTravelButton.snp.bottom).offset(40) - $0.leading.equalToSuperview().offset(24) - } - recommendContentCollectionView.snp.makeConstraints { - $0.top.equalTo(recommendContentGuideLabel.snp.bottom).offset(24) - $0.leading.equalToSuperview().offset(24) - $0.trailing.equalToSuperview() - $0.height.equalTo(260) - $0.bottom.equalToSuperview().offset(-20) + + networkErrorView.snp.makeConstraints { + $0.top.equalTo(navigationBar.snp.bottom) + $0.directionalHorizontalEdges.equalToSuperview() + $0.bottom.equalTo(view.safeAreaLayoutGuide).offset(-68.adjustedH) } - addFloatingButton.snp.makeConstraints { - $0.size.equalTo(56) - $0.trailing.equalToSuperview().offset(-24) - $0.bottom.equalTo(view.safeAreaLayoutGuide).offset(-70) + } + + func setCollectionView() { + collectionView.do { + $0.register( + PopularInfoCell.self, + forCellWithReuseIdentifier: PopularInfoCell.cellIdentifier + ) + $0.register( + CategoryChipCell.self, + forCellWithReuseIdentifier: CategoryChipCell.cellIdentifier + ) + $0.register( + HomeBannerCell.self, + forCellWithReuseIdentifier: HomeBannerCell.cellIdentifier + ) + $0.register( + RecommendInfoCell.self, + forCellWithReuseIdentifier: RecommendInfoCell.cellIdentifier + ) + $0.register( + HomeHeaderView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: HomeHeaderView.reusableViewIdentifier + ) + $0.register( + HomeFooterButtonView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, + withReuseIdentifier: HomeFooterButtonView.reusableViewIdentifier + ) } } } -// MARK: - CategoryCollectionViewDelegate - -extension HomeViewController: CategoryCollectionViewDelegate { - func categoryCollectionView(_ collectionView: CategoryCollectionView, didSelectCategoryAt index: Int) { - listener?.didSelectCategory(at: index) +// MARK: - Bind +private extension HomeViewController { + func bindInteractor() { + navigationBar.trailingButtonDidTap + .subscribe(with: self) { owner, _ in + owner.listener?.searchBtnTapped() + } + .disposed(by: disposeBag) + + navigationBar.trailing2ButtonDidTap + .subscribe(with: self) { owner, _ in + owner.listener?.settingBtnTapped() + } + .disposed(by: disposeBag) + + moreButtonTapped + .subscribe(with: self) { owner, _ in + owner.listener?.moreBtnTapped() + } + .disposed(by: disposeBag) + + collectionView.rx.itemSelected + .compactMap { [weak self] indexPath in + self?.dataSource?.itemIdentifier(for: indexPath) + } + .subscribe(with: self) { owner, item in + owner.listener?.itemSelected(item: item) + } + .disposed(by: disposeBag) + + networkErrorView.buttonDidTap + .subscribe(with: self) { owner, _ in + owner.listener?.reloadBtnTapped() + } + .disposed(by: disposeBag) + } + + func applySnapshot(with sections: [HomeSectionModel]) { + var snapshot = NSDiffableDataSourceSnapshot() + sections.forEach { + snapshot.appendSections([$0.section]) + snapshot.appendItems($0.items, toSection: $0.section) + } + dataSource?.apply(snapshot, animatingDifferences: true) } } -// MARK: - YoutuberContentCollectionViewDelegate - -extension HomeViewController: YoutuberContentCollectionViewDelegate { - func youtuberContentCollectionView(_ collectionView: YoutuberContentCollectionView, didSelectItemAt index: Int, in section: Int) { - listener?.didSelectPopularTrip(at: index, in: section) +private extension HomeViewController { + func setDataSource() { + let bannerRegistration = createBannerCellRegistration() + let categoryRegistration = createCategoryCellRegistration() + let popularTripRegistration = createPopularTripCellRegistration() + let recommendedTripRegistration = createRecommedTripCellRegistration() + + dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item in + switch item { + case .banner(let banner): + return collectionView.dequeueConfiguredReusableCell( + using: bannerRegistration, + for: indexPath, + item: banner + ) + case .category(let category): + return collectionView.dequeueConfiguredReusableCell( + using: categoryRegistration, + for: indexPath, + item: category + ) + case .popularTrip(let tripList): + return collectionView.dequeueConfiguredReusableCell( + using: popularTripRegistration, + for: indexPath, + item: tripList + ) + case .recommendedTrip(let tripList): + return collectionView.dequeueConfiguredReusableCell( + using: recommendedTripRegistration, + for: indexPath, + item: tripList + ) + } + } + + configureSupplementaryView() } - - func youtuberContentCollectionView(_ collectionView: YoutuberContentCollectionView, didScrollToSection section: Int) { - listener?.didScrollToCategory(at: section) + + func configureSupplementaryView() { + let headerRegistration = createHeaderRegistration() + let popularFooterRegistration = createPopularFooterRegistration() + + dataSource?.supplementaryViewProvider = { collectionView, kind, indexPath in + guard HomeSectionKind(rawValue: indexPath.section) != nil else { + return UICollectionReusableView() + } + + if kind == UICollectionView.elementKindSectionHeader { + return collectionView.dequeueConfiguredReusableSupplementary( + using: headerRegistration, + for: indexPath + ) + } + + if kind == UICollectionView.elementKindSectionFooter { + return collectionView.dequeueConfiguredReusableSupplementary( + using: popularFooterRegistration, + for: indexPath + ) + } + + return nil + } } } -// MARK: - RecommendContentCollectionViewDelegate - -extension HomeViewController: RecommendContentCollectionViewDelegate { - func recommendContentCollectionView(_ collectionView: RecommendContentCollectionView, didSelectItemAt index: Int) { - listener?.didSelectRecommendation(at: index) +extension HomeViewController: HomePresentable { + func showErrorView(_ isError: Bool) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + self.networkErrorView.isHidden = !isError + + self.collectionView.isHidden = isError + if isError { + self.loadingIndicator.stopAnimating() + } + } } -} - -// MARK: - HomeViewControllable - -extension HomeViewController { - func push(_ viewController: ViewControllable) { - navigationController?.pushViewController(viewController.uiviewController, animated: true) + + func update(with sections: [HomeSectionModel]) { + DispatchQueue.main.async { [weak self] in + self?.applySnapshot(with: sections) + } } - - func pop() { - navigationController?.popViewController(animated: true) + + func setLoading(_ isLoading: Bool) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + if isLoading { + self.loadingIndicator.startAnimating() + self.collectionView.alpha = 0.5 + } else { + self.loadingIndicator.stopAnimating() + self.collectionView.alpha = 1.0 + } + } } } diff --git a/Projects/Features/HomeFeature/Sources/Mock/MockHomeService.swift b/Projects/Features/HomeFeature/Sources/Mock/MockHomeService.swift deleted file mode 100644 index 7da54b2..0000000 --- a/Projects/Features/HomeFeature/Sources/Mock/MockHomeService.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// MockHomeService.swift -// HomeFeature -// -// Created by kimnahun on 2026-01-21. -// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. -// - -import Domain -import Foundation - -final class MockHomeService: HomeServiceProtocol { - - private let mockImageURLs = [ - "https://picsum.photos/400/300?random=1", - "https://picsum.photos/400/300?random=2", - "https://picsum.photos/400/300?random=3", - "https://picsum.photos/400/300?random=4", - "https://picsum.photos/400/300?random=5", - "https://picsum.photos/400/300?random=6", - "https://picsum.photos/400/300?random=7", - "https://picsum.photos/400/300?random=8", - "https://picsum.photos/400/300?random=9", - "https://picsum.photos/400/300?random=10", - "https://picsum.photos/400/300?random=11", - "https://picsum.photos/400/300?random=12", - "https://picsum.photos/400/300?random=13", - "https://picsum.photos/400/300?random=14", - "https://picsum.photos/400/300?random=15" - ] - - func fetchMyTrips() async -> Result<[MyTrip], HomeError> { - try? await Task.sleep(nanoseconds: 300_000_000) - - return .success([ - MyTrip( - id: 1, - title: "๋„์ฟ„ ์—ฌํ–‰", - destination: "์ผ๋ณธ ๋„์ฟ„", - startDate: Date(), - endDate: Calendar.current.date(byAdding: .day, value: 5, to: Date()) ?? Date(), - thumbnailURL: mockImageURLs[0] - ), - MyTrip( - id: 2, - title: "ํŒŒ๋ฆฌ ์—ฌํ–‰", - destination: "ํ”„๋ž‘์Šค ํŒŒ๋ฆฌ", - startDate: Calendar.current.date(byAdding: .day, value: 30, to: Date()) ?? Date(), - endDate: Calendar.current.date(byAdding: .day, value: 37, to: Date()) ?? Date(), - thumbnailURL: mockImageURLs[1] - ) - ]) - } - - func fetchPopularTrips(category: TripCategory) async -> Result<[PopularTrip], HomeError> { - let allTripsResult = await fetchAllPopularTrips() - guard case .success(let allTrips) = allTripsResult else { - return .success([]) - } - - if category == .all { - return .success(allTrips.values.flatMap { $0 }) - } else { - return .success(allTrips[category] ?? []) - } - } - - func fetchAllPopularTrips() async -> Result<[TripCategory: [PopularTrip]], HomeError> { - try? await Task.sleep(nanoseconds: 300_000_000) - - return .success([ - .all: [ - PopularTrip(id: 100, title: "๊ณฝ์ค€๋นˆ์˜ ์‹ ํ˜ผ์—ฌํ–‰", authorName: "๊ณฝํŠœ๋ธŒ", destination: "ํŒŒ๋ฆฌ", duration: "2๋ฐ•3์ผ", thumbnailURL: mockImageURLs[0], category: .all), - PopularTrip(id: 101, title: "6๋ฐ•7์ผ ์Šค์œ„์Šค ์—ฌํ–‰", authorName: "์ฐฐ์Šค์—”ํ„ฐ", destination: "์Šค์œ„์Šค", duration: "5๋ฐ•6์ผ", thumbnailURL: mockImageURLs[1], category: .all), - PopularTrip(id: 102, title: "์ถฉ๊ฒฉ์ ์ธ ๋ถ์œ ๋Ÿฝ ๋ฌผ๊ฐ€", authorName: "๊ณฝํŠœ๋ธŒ", destination: "๋ถ์œ ๋Ÿฝ", duration: "6๋ฐ•7์ผ", thumbnailURL: mockImageURLs[2], category: .all) - ], - .vietnam: [ - PopularTrip(id: 1, title: "๋‹ค๋‚ญ ํž๋ง ์—ฌํ–‰", authorName: "๋น ๋‹ˆ๋ณดํ‹€", destination: "๋‹ค๋‚ญ", duration: "3๋ฐ•4์ผ", thumbnailURL: mockImageURLs[3], category: .vietnam), - PopularTrip(id: 2, title: "ํ˜ธ์น˜๋ฏผ ๋จน๋ฐฉ ํˆฌ์–ด", authorName: "๋จน๋ฐฉ์ž‘๊ฐ€", destination: "ํ˜ธ์น˜๋ฏผ", duration: "4๋ฐ•5์ผ", thumbnailURL: mockImageURLs[4], category: .vietnam), - PopularTrip(id: 3, title: "ํ•˜๋…ธ์ด ์—ญ์‚ฌ ํƒ๋ฐฉ", authorName: "์—ญ์‚ฌํƒ๋ฐฉ๊ฐ€", destination: "ํ•˜๋…ธ์ด", duration: "3๋ฐ•4์ผ", thumbnailURL: mockImageURLs[5], category: .vietnam) - ], - .europe: [ - PopularTrip(id: 4, title: "ํŒŒ๋ฆฌ ๋กœ๋งจํ‹ฑ ์—ฌํ–‰", authorName: "๊ณฝํŠœ๋ธŒ", destination: "ํŒŒ๋ฆฌ", duration: "5๋ฐ•6์ผ", thumbnailURL: mockImageURLs[6], category: .europe), - PopularTrip(id: 5, title: "์Šค์œ„์Šค ์•Œํ”„์Šค ํˆฌ์–ด", authorName: "์ฝฉ์ฝฉํŒกํŒก", destination: "์Šค์œ„์Šค", duration: "6๋ฐ•7์ผ", thumbnailURL: mockImageURLs[7], category: .europe), - PopularTrip(id: 6, title: "์ดํƒˆ๋ฆฌ์•„ ๋ฏธ์‹ ์—ฌํ–‰", authorName: "์‹ ์„œ์œ ๊ธฐ", destination: "์ดํƒˆ๋ฆฌ์•„", duration: "7๋ฐ•8์ผ", thumbnailURL: mockImageURLs[8], category: .europe) - ], - .hongkong: [ - PopularTrip(id: 7, title: "ํ™์ฝฉ ์•ผ๊ฒฝ ํˆฌ์–ด", authorName: "์—ฌํ–‰์ž‘๊ฐ€", destination: "ํ™์ฝฉ", duration: "2๋ฐ•3์ผ", thumbnailURL: mockImageURLs[9], category: .hongkong), - PopularTrip(id: 8, title: "๋งˆ์นด์˜ค ์นด์ง€๋…ธ ์—ฌํ–‰", authorName: "๋Ÿญ์…”๋ฆฌํŠธ๋ž˜๋ธ”", destination: "๋งˆ์นด์˜ค", duration: "2๋ฐ•3์ผ", thumbnailURL: mockImageURLs[10], category: .hongkong), - PopularTrip(id: 9, title: "ํ™์ฝฉ ๋ง›์ง‘ ํƒ๋ฐฉ", authorName: "๋ง›์ง‘ํ—Œํ„ฐ", destination: "ํ™์ฝฉ", duration: "3๋ฐ•4์ผ", thumbnailURL: mockImageURLs[11], category: .hongkong) - ], - .singapore: [ - PopularTrip(id: 10, title: "์‹ฑ๊ฐ€ํฌ๋ฅด ๊ฐ€์กฑ์—ฌํ–‰", authorName: "๊ฐ€์กฑ์—ฌํ–‰์ „๋ฌธ", destination: "์‹ฑ๊ฐ€ํฌ๋ฅด", duration: "4๋ฐ•5์ผ", thumbnailURL: mockImageURLs[12], category: .singapore), - PopularTrip(id: 11, title: "๋งˆ๋ฆฌ๋‚˜๋ฒ ์ด ์•ผ๊ฒฝ", authorName: "์•ผ๊ฒฝ์ „๋ฌธ๊ฐ€", destination: "์‹ฑ๊ฐ€ํฌ๋ฅด", duration: "3๋ฐ•4์ผ", thumbnailURL: mockImageURLs[13], category: .singapore), - PopularTrip(id: 12, title: "์„ผํ† ์‚ฌ ๋ฆฌ์กฐํŠธ ํž๋ง", authorName: "ํž๋ง์—ฌํ–‰", destination: "์‹ฑ๊ฐ€ํฌ๋ฅด", duration: "4๋ฐ•5์ผ", thumbnailURL: mockImageURLs[14], category: .singapore) - ], - .japan: [ - PopularTrip(id: 13, title: "์˜ค์‚ฌ์นด ๋ง›์ง‘ ํˆฌ์–ด", authorName: "์ผ๋ณธํ†ต", destination: "์˜ค์‚ฌ์นด", duration: "3๋ฐ•4์ผ", thumbnailURL: mockImageURLs[0], category: .japan), - PopularTrip(id: 14, title: "๋„์ฟ„ ์‡ผํ•‘ ์—ฌํ–‰", authorName: "์‡ผํ•‘ํ€ธ", destination: "๋„์ฟ„", duration: "4๋ฐ•5์ผ", thumbnailURL: mockImageURLs[1], category: .japan), - PopularTrip(id: 15, title: "๊ตํ†  ์ „ํ†ต ๋ฌธํ™”", authorName: "๋ฌธํ™”ํƒ๋ฐฉ", destination: "๊ตํ† ", duration: "3๋ฐ•4์ผ", thumbnailURL: mockImageURLs[2], category: .japan) - ] - ]) - } - - func fetchRecommendations() async -> Result<[Recommendation], HomeError> { - try? await Task.sleep(nanoseconds: 300_000_000) - - return .success([ - Recommendation( - id: 1, - title: "์ธํ”Œ๋ฃจ์–ธ์„œ A์˜ ๋ฐœ๋ฆฌ ์—ฌํ–‰๊ธฐ", - authorName: "์ธํ”Œ๋ฃจ์–ธ์„œ A", - destination: "์ธ๋„๋„ค์‹œ์•„ ๋ฐœ๋ฆฌ", - duration: "7๋ฐ• 8์ผ", - thumbnailURL: mockImageURLs[0] - ), - Recommendation( - id: 2, - title: "์ž‘๊ฐ€ B์˜ ์œ ๋Ÿฝ ๋ฐฐ๋‚ญ์—ฌํ–‰", - authorName: "์—ฌํ–‰์ž‘๊ฐ€ B", - destination: "์œ ๋Ÿฝ 5๊ฐœ๊ตญ", - duration: "14๋ฐ• 15์ผ", - thumbnailURL: mockImageURLs[1] - ), - Recommendation( - id: 3, - title: "์…ฐํ”„ C์˜ ํƒœ๊ตญ ๋ฏธ์‹ ์—ฌํ–‰", - authorName: "์…ฐํ”„ C", - destination: "ํƒœ๊ตญ ๋ฐฉ์ฝ•", - duration: "4๋ฐ• 5์ผ", - thumbnailURL: mockImageURLs[2] - ) - ]) - } -} diff --git a/Projects/Features/HomeFeature/Sources/Models/HomePresentationModel.swift b/Projects/Features/HomeFeature/Sources/Models/HomePresentationModel.swift new file mode 100644 index 0000000..7d0832a --- /dev/null +++ b/Projects/Features/HomeFeature/Sources/Models/HomePresentationModel.swift @@ -0,0 +1,154 @@ +// +// HomePresentationModel.swift +// HomeFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/4/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +import Domain + +struct HomePresentationModel { + let banner: HomePresentationModel.Banner + let category: [HomePresentationModel.Category] + let popularTrip: [HomePresentationModel.PopularTrip] + let recommendedTrip: [HomePresentationModel.RecommendedTrip] + + struct Banner: Hashable { + let id: Int + let title: String + let startDay: Date + let endDay: Date + let duration: String + let tripSchedule: Schedule + } + + struct Schedule: Hashable { + let id: Int + let day: Int + let placeName: String + let thumbnailUrl: String + let transport: String + let estimatedDuration: Int + } + + struct Category: Hashable { + let id: Int + let creator: String + let viedoType: VideoType + } + + struct PopularTrip: Hashable { + let id: String + let title: String + let thumbnailUrl: String + let creator: String + let schedule: String + let country: String + let city: String + } + + struct RecommendedTrip: Hashable { + let id: String + let title: String + let thumbnailUrl: String + let creator: String + let country: String + let schedule: String + let city: String + } +} + +extension HomePresentationModel.Banner { + static var empty: Self { + return .init( + id: 0, // 0์œผ๋กœ ์„ค์ •ํ•˜์—ฌ empty ๋ฐฐ๋„ˆ์ž„์„ ๊ตฌ๋ถ„ + title: "๋‹ค๊ฐ€์˜ค๋Š” ์—ฌํ–‰์ด ์—†์Šต๋‹ˆ๋‹ค.", + startDay: Date(), + endDay: Date(), + duration: "", + tripSchedule: .empty + ) + } +} + +extension HomePresentationModel.Schedule { + static var empty: Self { + return .init( + id: 0, + day: 0, + placeName: "", + thumbnailUrl: "", + transport: "", + estimatedDuration: 0 + ) + } +} + +extension MyTripSummary { + func toPresention() -> HomePresentationModel.Banner { + return HomePresentationModel.Banner( + id: self.id, + title: self.title, + startDay: self.startDay, + endDay: self.endDay, + duration: "\(self.startDay.toKoreanMMdd())~\(self.endDay.toKoreanMMdd())", + tripSchedule: + .init( + id: self.tripSchedule.id, + day: self.tripSchedule.day, + placeName: self.tripSchedule.placeName, + thumbnailUrl: self.tripSchedule.thumbnailUrl, + transport: self.tripSchedule.transport, + estimatedDuration: self.tripSchedule.estimatedDuration + ) + ) + } +} + +extension TripCategory { + func toHomeModel() -> HomePresentationModel.Category { + return HomePresentationModel.Category( + id: self.id, + creator: self.creator, + viedoType: self.viedoType + ) + } +} + +extension TripInfo { + func toPopularHomeModel() -> HomePresentationModel.PopularTrip { + return HomePresentationModel.PopularTrip( + id: self.id, + title: self.title, + thumbnailUrl: self.thumbnailUrl, + creator: self.creator, + schedule: "\(self.nights)๋ฐ• \(self.days)์ผ", + country: self.country, + city: self.city + ) + } + + func toRecommendHomeModel() -> HomePresentationModel.RecommendedTrip { + return HomePresentationModel.RecommendedTrip( + id: self.id, + title: self.title, + thumbnailUrl: self.thumbnailUrl, + creator: self.creator, + country: self.country, + schedule: "\(self.nights)๋ฐ• \(self.days)์ผ", + city: self.city + ) + } +} + +extension Date { + func toKoreanMMdd() -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "M์›” d์ผ" + return formatter.string(from: self) + } +} diff --git a/Projects/Features/HomeFeature/Sources/Views/Cells/CategoryCell.swift b/Projects/Features/HomeFeature/Sources/Views/Cells/CategoryCell.swift deleted file mode 100644 index 6e09893..0000000 --- a/Projects/Features/HomeFeature/Sources/Views/Cells/CategoryCell.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// CategoryCell.swift -// HomeFeature -// -// Created by kimnahun on 2026-01-22. -// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. -// - -import Core -import DSKit -import UIKit -import SnapKit -import Then - -final class CategoryCell: UICollectionViewCell { - - static let identifier = "CategoryCell" - - // MARK: - UI Components - - private let containerView = UIView().then { - $0.layer.borderWidth = 1 - $0.clipsToBounds = true - } - - private let iconImageView = UIImageView().then { - $0.contentMode = .scaleAspectFit - $0.image = UIImage(systemName: "play.rectangle.fill") - } - - private let titleLabel = UILabel() - - private let stackView = UIStackView().then { - $0.axis = .horizontal - $0.spacing = 4 - $0.alignment = .center - } - - // MARK: - Initialization - - override init(frame: CGRect) { - super.init(frame: frame) - setupUI() - setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Layout - - override func layoutSubviews() { - super.layoutSubviews() - contentView.layoutIfNeeded() - containerView.layer.cornerRadius = containerView.bounds.height / 2 - } - - // MARK: - Setup - - private func setupUI() { - contentView.addSubview(containerView) - containerView.addSubview(stackView) - [iconImageView, titleLabel].forEach { - stackView.addArrangedSubview($0) - } - } - - private func setupConstraints() { - containerView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - stackView.snp.makeConstraints { - $0.horizontalEdges.equalToSuperview().inset(14) - $0.verticalEdges.equalToSuperview().inset(6) - } - - iconImageView.snp.makeConstraints { - $0.size.equalTo(20).priority(.high) - } - } - - // MARK: - Configuration - - func configure(title: String, isSelected: Bool, isFirstItem: Bool) { - titleLabel.setText(.bodyMSB, text: title, color: isSelected ? UIColor(hexCode: "#FFFFFF") : UIColor(hexCode: "#757575")) - iconImageView.isHidden = isFirstItem - - if isSelected { - containerView.backgroundColor = UIColor(hexCode: "#2C2C2C") - containerView.layer.borderColor = UIColor.clear.cgColor - iconImageView.tintColor = .white - } else { - containerView.backgroundColor = UIColor(hexCode: "#FFFFFF") - containerView.layer.borderColor = UIColor(hexCode: "#D9D9D9").cgColor - iconImageView.tintColor = UIColor(hexCode: "#2C2C2C") - } - } -} diff --git a/Projects/Features/HomeFeature/Sources/Views/Cells/HomeBannerCell.swift b/Projects/Features/HomeFeature/Sources/Views/Cells/HomeBannerCell.swift new file mode 100644 index 0000000..2ff02ec --- /dev/null +++ b/Projects/Features/HomeFeature/Sources/Views/Cells/HomeBannerCell.swift @@ -0,0 +1,152 @@ +// +// HomeBannerCell.swift +// HomeFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/3/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +final class HomeBannerCell: UICollectionViewCell { + static let defaultWidth = 327.adjusted + + private var type: HomeBannerType = .empty + + private let emptyView = HomeBannerEmptyView() + private let upCommingView = HomeBannerUpCommingView() + private let onGoingView = HomeBannerOnGoingView() + private let stackView = UIStackView() + + override init(frame: CGRect) { + super.init(frame: frame) + + setStyle() + setUI() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + [emptyView, upCommingView, onGoingView].forEach { $0.isHidden = true } + + upCommingView.prepareForReuse() + onGoingView.prepareForReuse() + } + + func configure(_ model: HomePresentationModel.Banner) { + [emptyView, upCommingView, onGoingView].forEach { $0.isHidden = true } + + let now = Date() + let calendar = Calendar.current + + if model.id == 0 { + self.type = .empty + emptyView.isHidden = false + return + } + + let startOfToday = calendar.startOfDay(for: now) + let startOfTravel = calendar.startOfDay(for: model.startDay) + let startOfEnd = calendar.startOfDay(for: model.endDay) + + if startOfToday >= startOfTravel && startOfToday <= startOfEnd { + let schedule = model.tripSchedule + + self.type = .onGoing( + title: model.title, + date: model.duration, + transportIcon: DSKitAsset.Assets.icBus2.image, + duration: "\(schedule.estimatedDuration)๋ถ„", + place: schedule.placeName, + imageUrl: schedule.thumbnailUrl + ) + onGoingView.isHidden = false + + } + + else if startOfToday < startOfTravel { + let dDayValue = calendar.dateComponents([.day], from: startOfToday, to: startOfTravel).day ?? 0 + + self.type = .upComming( + title: model.title, + date: model.duration, + dDay: dDayValue, + imageUrl: model.tripSchedule.thumbnailUrl + ) + upCommingView.isHidden = false + } + + else { + self.type = .empty + emptyView.isHidden = false + } + + updateViewWithCurrentType() + } +} + +private extension HomeBannerCell { + func updateViewWithCurrentType() { + switch type { + case .upComming(let title, let date, let dDay, let imageUrl): + upCommingView.configure(title: title, date: date, dDay: dDay, imageUrl: imageUrl) + case .onGoing(let title, let date, let transportIcon, let duration, let place, let imageUrl): + onGoingView.configure( + title: title, + date: date, + transportIcon: transportIcon, + transport: "๋Œ€์ค‘๊ตํ†ต", + duration: duration, + place: place, + imageUrl: imageUrl + ) + case .empty: + break + } + } + func setStyle() { + contentView.do { + $0.backgroundColor = DSKitAsset.Colors.black50.color + $0.layer.cornerRadius = 8.adjustedH + $0.clipsToBounds = true + } + + stackView.do { + $0.axis = .vertical + $0.alignment = .fill + $0.distribution = .fill + } + } + + func setUI() { + contentView.addSubview(stackView) + stackView.addArrangedSubviews(emptyView, upCommingView, onGoingView) + } + + func setLayout() { + stackView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } +} + +enum HomeBannerType: Hashable { + case empty + case upComming(title: String, date: String, dDay: Int, imageUrl: String) + case onGoing( + title: String, + date: String, + transportIcon: UIImage?, + duration: String, + place: String, + imageUrl: String + ) +} diff --git a/Projects/Features/HomeFeature/Sources/Views/Cells/HomeFooterButtonView.swift b/Projects/Features/HomeFeature/Sources/Views/Cells/HomeFooterButtonView.swift new file mode 100644 index 0000000..edcdb6c --- /dev/null +++ b/Projects/Features/HomeFeature/Sources/Views/Cells/HomeFooterButtonView.swift @@ -0,0 +1,72 @@ +// +// HomeFooterButtonView.swift +// HomeFeature +// +// Created by ์ตœ์•ˆ์šฉ on 1/31/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +import RxCocoa +import RxSwift + +final class HomeFooterButtonView: UICollectionReusableView { + private let plusButton = UIButton() + + var disposeBag = DisposeBag() + var plusBtnTapped: Observable { + plusButton.rx.tap.asObservable() + } + + override init(frame: CGRect) { + super.init(frame: frame) + + setStyle() + setUI() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + disposeBag = DisposeBag() + } +} + +private extension HomeFooterButtonView { + func setStyle() { + plusButton.do { + var configure = UIButton.Configuration.plain() + let fontAttributes = UIFont.NDGL.bodyMSB.attributes + configure.baseForegroundColor = DSKitAsset.Colors.black600.color + configure.attributedTitle = AttributedString( + "์—ฌํ–‰ ๋”ฐ๋ผ๊ฐ€๊ธฐ ๋”๋ณด๊ธฐ", + attributes: AttributeContainer(fontAttributes) + ) + configure.background.cornerRadius = 8.adjustedH + configure.background.strokeWidth = 1 + configure.background.strokeColor = DSKitAsset.Colors.black200.color + $0.configuration = configure + } + } + + func setUI() { + addSubview(plusButton) + } + + func setLayout() { + plusButton.snp.makeConstraints { + $0.top.equalToSuperview() + $0.height.equalTo(40.adjustedH) + $0.directionalHorizontalEdges.equalToSuperview() + } + } +} + diff --git a/Projects/Features/HomeFeature/Sources/Views/Cells/HomeHeaderView.swift b/Projects/Features/HomeFeature/Sources/Views/Cells/HomeHeaderView.swift new file mode 100644 index 0000000..06733bf --- /dev/null +++ b/Projects/Features/HomeFeature/Sources/Views/Cells/HomeHeaderView.swift @@ -0,0 +1,58 @@ +// +// HomeHeaderView.swift +// HomeFeature +// +// Created by ์ตœ์•ˆ์šฉ on 1/30/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +final class HomeHeaderView: UICollectionReusableView { + private let titleLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + + setStyle() + setUI() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + titleLabel.text = nil + } + + func configure(title: String) { + titleLabel.do { + $0.setText(.subTitleLSB, text: title, color: DSKitAsset.Colors.black900.color) + } + } +} + +private extension HomeHeaderView { + func setStyle() { + titleLabel.do { + $0.numberOfLines = 2 + } + } + + func setUI() { + addSubview(titleLabel) + } + + func setLayout() { + titleLabel.snp.makeConstraints { + $0.leading.equalToSuperview() + $0.directionalVerticalEdges.equalToSuperview() + } + } +} diff --git a/Projects/Features/HomeFeature/Sources/Views/Cells/RecommendContentCell.swift b/Projects/Features/HomeFeature/Sources/Views/Cells/RecommendContentCell.swift deleted file mode 100644 index f09209b..0000000 --- a/Projects/Features/HomeFeature/Sources/Views/Cells/RecommendContentCell.swift +++ /dev/null @@ -1,141 +0,0 @@ -// -// RecommendContentCell.swift -// HomeFeature -// -// Created by kimnahun on 2026-01-22. -// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. -// - -import Domain -import UIKit -import DSKit -import Kingfisher -import SnapKit -import Then - -final class RecommendContentCell: UICollectionViewCell { - - static let identifier = "RecommendContentCell" - - // MARK: - Properties - - private var currentThumbnailURL: String? - - // MARK: - UI Components - - private let thumbnailImageView = UIImageView().then { - $0.contentMode = .scaleAspectFill - $0.backgroundColor = .systemGray5 - $0.layer.cornerRadius = 12 - $0.clipsToBounds = true - } - - private let titleLabel = UILabel().then { - $0.numberOfLines = 2 - } - - private let authorInfoView = UIStackView().then { - $0.axis = .horizontal - $0.spacing = 4 - $0.alignment = .center - } - - private let playIcon = UIImageView().then { - $0.image = UIImage(systemName: "play.rectangle.fill") - $0.tintColor = UIColor(hexCode: "#444444") - $0.contentMode = .scaleAspectFit - } - - private let authorLabel = UILabel() - - private let durationLabel = UILabel() - - // MARK: - Initialization - - override init(frame: CGRect) { - super.init(frame: frame) - setupUI() - setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - currentThumbnailURL = nil - thumbnailImageView.kf.cancelDownloadTask() - thumbnailImageView.image = nil - thumbnailImageView.backgroundColor = .systemGray5 - } - - // MARK: - Setup - - private func setupUI() { - contentView.addSubview(thumbnailImageView) - contentView.addSubview(titleLabel) - contentView.addSubview(authorInfoView) - - [playIcon, authorLabel, durationLabel].forEach { - authorInfoView.addArrangedSubview($0) - } - } - - private func setupConstraints() { - thumbnailImageView.snp.makeConstraints { - $0.top.leading.trailing.equalToSuperview() - $0.height.equalTo(180) - } - - titleLabel.snp.makeConstraints { - $0.top.equalTo(thumbnailImageView.snp.bottom).offset(12) - $0.leading.trailing.equalToSuperview() - } - - authorInfoView.snp.makeConstraints { - $0.top.equalTo(titleLabel.snp.bottom).offset(6) - $0.leading.equalToSuperview() - $0.bottom.lessThanOrEqualToSuperview() - } - - playIcon.snp.makeConstraints { - $0.size.equalTo(14) - } - } - - // MARK: - Configuration - - func configure(with recommendation: Recommendation) { - titleLabel.setText(.bodyMSB, text: recommendation.title, color: UIColor(hexCode: "#2C2C2C")) - authorLabel.setText(.bodySR, text: recommendation.authorName, color: UIColor(hexCode: "#2C2C2C")) - durationLabel.setText(.bodySR, text: " ยท \(recommendation.duration)", color: UIColor(hexCode: "#2C2C2C")) - - // URL ์ €์žฅ ๋ฐ ์ด๋ฏธ์ง€ ๋กœ๋”ฉ (๊ต์ฐจ ๊ฒ€์ฆ) - let thumbnailURL = recommendation.thumbnailURL - currentThumbnailURL = thumbnailURL - - if let urlString = thumbnailURL, let url = URL(string: urlString) { - thumbnailImageView.kf.setImage( - with: url, - placeholder: nil, - options: [ - .transition(.fade(0.2)), - .cacheOriginalImage - ] - ) { [weak self] result in - guard let self = self else { return } - guard self.currentThumbnailURL == urlString else { return } - - switch result { - case .success: - break - case .failure: - self.thumbnailImageView.backgroundColor = .systemGray5 - } - } - } else { - thumbnailImageView.backgroundColor = .systemGray5 - } - } -} diff --git a/Projects/Features/HomeFeature/Sources/Views/Cells/RecommendInfoCell.swift b/Projects/Features/HomeFeature/Sources/Views/Cells/RecommendInfoCell.swift new file mode 100644 index 0000000..48714d3 --- /dev/null +++ b/Projects/Features/HomeFeature/Sources/Views/Cells/RecommendInfoCell.swift @@ -0,0 +1,149 @@ +// +// RecommendInfoCell.swift +// HomeFeature +// +// Created by ์ตœ์•ˆ์šฉ on 1/31/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +final class RecommendInfoCell: UICollectionViewCell { + static let defaultWidth = 240.adjusted + static let defaultHeight = 253.adjustedH + + // MARK: - UI Components + private let thumbnailView = UIImageView() + private let nationalFlagLabel = UILabel() + private let nationLabel = UILabel() + private let titleLabel = UILabel() + private let firstDotLabel = UILabel() + private let secondDotLabel = UILabel() + private let nameLabel = UILabel() + private let scheduleLabel = UILabel() + private let cityLabel = UILabel() + + private let nationStackView = UIStackView() + private let subInfoStackView = UIStackView() + private let infoStackView = UIStackView() + + // MARK: - Init + override init(frame: CGRect) { + super.init(frame: frame) + setStyle() + setUI() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func prepareForReuse() { + super.prepareForReuse() + + thumbnailView.kf.cancelDownloadTask() + thumbnailView.image = nil + nationalFlagLabel.text = nil + cityLabel.text = nil + nameLabel.text = nil + titleLabel.text = nil + nationLabel.text = nil + scheduleLabel.text = nil + } + + // MARK: - Configure + func configure(_ model: HomePresentationModel.RecommendedTrip) { + if let url = URL(string: model.thumbnailUrl) { + thumbnailView.kf.setImage(with: url, options: [.transition(.fade(0.3))]) + } + nationalFlagLabel.text = model.country.toFlag() + nationLabel.setText(.bodyMM, text: model.country.toKoreanCountryName(), color: DSKitAsset.Colors.black400.color) + titleLabel.setText(.bodyLSB, text: model.title, color: DSKitAsset.Colors.black700.color) + nameLabel.setText(.bodyMM, text: model.creator, color: DSKitAsset.Colors.black400.color) + cityLabel.setText(.bodyMM, text: model.city, color: DSKitAsset.Colors.black400.color) + scheduleLabel.setText(.bodyMM, text: model.schedule, color: DSKitAsset.Colors.black400.color) + } +} + +private extension RecommendInfoCell { + func setStyle() { + thumbnailView.do { + $0.layer.cornerRadius = 8 + $0.layer.maskedCorners = CACornerMask(arrayLiteral: [.layerMinXMinYCorner, .layerMaxXMinYCorner]) + $0.clipsToBounds = true + $0.contentMode = .scaleAspectFill + $0.backgroundColor = .systemGray6 + } + + nationalFlagLabel.do { + $0.font = .systemFont(ofSize: 10.5 * max(1.adjusted, 1.adjustedH)) + } + + titleLabel.do { + $0.numberOfLines = 2 + $0.lineBreakMode = .byTruncatingTail + } + + firstDotLabel.do { + $0.setText(.bodyMM, text: "โ€ข", color: DSKitAsset.Colors.black400.color) + } + + secondDotLabel.do { + $0.setText(.bodyMM, text: "โ€ข", color: DSKitAsset.Colors.black400.color) + } + + nationStackView.do { + $0.axis = .horizontal + $0.spacing = 4 + $0.alignment = .center + } + + subInfoStackView.do { + $0.axis = .horizontal + $0.spacing = 4 + $0.alignment = .center + } + + infoStackView.do { + $0.axis = .vertical + $0.alignment = .leading + $0.spacing = 4 + } + } + + func setUI() { + contentView.addSubviews(thumbnailView, nationStackView, infoStackView) + + nationStackView.addArrangedSubviews(nationalFlagLabel, nationLabel) + + subInfoStackView.addArrangedSubviews( + nameLabel, + firstDotLabel, + cityLabel, + secondDotLabel, + scheduleLabel + ) + infoStackView.addArrangedSubviews(titleLabel, subInfoStackView) + } + + func setLayout() { + thumbnailView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + $0.height.equalTo(thumbnailView.snp.width).multipliedBy(140.0 / 240.0) + } + + nationStackView.snp.makeConstraints { + $0.leading.equalToSuperview() + $0.top.equalTo(thumbnailView.snp.bottom).offset(16.adjustedH) + } + + infoStackView.snp.makeConstraints { + $0.top.equalTo(nationStackView.snp.bottom).offset(10.adjustedH) + $0.leading.trailing.equalToSuperview() + $0.bottom.lessThanOrEqualToSuperview().inset(18.adjustedH) + } + } +} diff --git a/Projects/Features/HomeFeature/Sources/Views/Cells/YoutuberContentCell.swift b/Projects/Features/HomeFeature/Sources/Views/Cells/YoutuberContentCell.swift deleted file mode 100644 index ec38db7..0000000 --- a/Projects/Features/HomeFeature/Sources/Views/Cells/YoutuberContentCell.swift +++ /dev/null @@ -1,132 +0,0 @@ -// -// YoutuberContentCell.swift -// HomeFeature -// -// Created by kimnahun on 2026-01-22. -// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. -// - -import Domain -import UIKit -import DSKit -import Kingfisher -import SnapKit -import Then - -final class YoutuberContentCell: UICollectionViewCell { - - static let identifier = "YoutuberContentCell" - - // MARK: - Properties - - private var currentThumbnailURL: String? - - // MARK: - UI Components - - private let thumbnailImageView = UIImageView().then { - $0.contentMode = .scaleAspectFill - $0.backgroundColor = .systemGray5 - $0.layer.cornerRadius = 8 - $0.clipsToBounds = true - } - - private let titleLabel = UILabel().then { - $0.numberOfLines = 1 - } - - private let infoLabel = UILabel().then { - $0.numberOfLines = 1 - } - - private let textStackView = UIStackView().then { - $0.axis = .vertical - $0.spacing = 4 - $0.alignment = .leading - } - - // MARK: - Initialization - - override init(frame: CGRect) { - super.init(frame: frame) - setupUI() - setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - currentThumbnailURL = nil - thumbnailImageView.kf.cancelDownloadTask() - thumbnailImageView.image = nil - thumbnailImageView.backgroundColor = .systemGray5 - } - - // MARK: - Setup - - private func setupUI() { - [thumbnailImageView, textStackView].forEach { - contentView.addSubview($0) - } - [titleLabel, infoLabel].forEach { - textStackView.addArrangedSubview($0) - } - } - - private func setupConstraints() { - thumbnailImageView.snp.makeConstraints { - $0.leading.top.bottom.equalToSuperview() - $0.width.equalTo(136) - } - - textStackView.snp.makeConstraints { - $0.leading.equalTo(thumbnailImageView.snp.trailing).offset(12) - $0.trailing.equalToSuperview() - $0.centerY.equalToSuperview() - } - } - - // MARK: - Configuration - - func configure(with trip: PopularTrip) { - // placeholder trip์ธ ๊ฒฝ์šฐ ์ˆจ๊น€ ์ฒ˜๋ฆฌ - guard trip.id >= 0 else { - contentView.isHidden = true - return - } - contentView.isHidden = false - - titleLabel.setText(.bodyMSB, text: trip.title, color: UIColor(hexCode: "#2C2C2C")) - infoLabel.setText(.bodySM, text: "\(trip.authorName) ยท \(trip.destination) ยท \(trip.duration)", color: UIColor(hexCode: "#2C2C2C")) - - // URL ์ €์žฅ ๋ฐ ์ด๋ฏธ์ง€ ๋กœ๋”ฉ (๊ต์ฐจ ๊ฒ€์ฆ) - let thumbnailURL = trip.thumbnailURL - currentThumbnailURL = thumbnailURL - - if let urlString = thumbnailURL, let url = URL(string: urlString) { - thumbnailImageView.kf.setImage( - with: url, - placeholder: nil, - options: [ - .transition(.fade(0.2)), - .cacheOriginalImage - ] - ) { [weak self] result in - guard let self = self else { return } - // ๊ต์ฐจ ๊ฒ€์ฆ: ํ˜„์žฌ URL์ด ์š”์ฒญํ•œ URL๊ณผ ๊ฐ™์€์ง€ ํ™•์ธ - guard self.currentThumbnailURL == urlString else { return } - - switch result { - case .success: - break - case .failure: - self.thumbnailImageView.backgroundColor = .systemGray5 - } - } - } else { - thumbnailImageView.backgroundColor = .systemGray5 - } - } -} diff --git a/Projects/Features/HomeFeature/Sources/Views/CollectionView/CompositionalLayout/HomeCompositionalLayout.swift b/Projects/Features/HomeFeature/Sources/Views/CollectionView/CompositionalLayout/HomeCompositionalLayout.swift new file mode 100644 index 0000000..66a8991 --- /dev/null +++ b/Projects/Features/HomeFeature/Sources/Views/CollectionView/CompositionalLayout/HomeCompositionalLayout.swift @@ -0,0 +1,187 @@ +// +// HomeCompositionalLayout.swift +// HomeFeature +// +// Created by ์ตœ์•ˆ์šฉ on 1/30/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +extension HomeViewController { + func createLayout() -> UICollectionViewCompositionalLayout { + return UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in + guard let sectionKind = HomeSectionKind(rawValue: sectionIndex) else { + return self?.emptyLayout() + } + + switch sectionKind { + case .banner: + return self?.createBannerSection() + case .category: + return self?.createCategorySection() + case .popularTrip: + return self?.createPopularTripSection() + case .recommendedTrip: + return self?.createRecommendedTripSection() + } + } + } +} + +private extension HomeViewController { + func createBannerSection() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(80) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .absolute(HomeBannerCell.defaultWidth), + heightDimension: .estimated(80) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, + repeatingSubitem: item, + count: 1 + ) + + let section = NSCollectionLayoutSection(group: group) + section.orthogonalScrollingBehavior = .none + section.contentInsets = .init( + top: 21.adjustedH, + leading: 24.adjusted, + bottom: 40.adjustedH, + trailing: 24.adjusted + ) + return section + } + + func createCategorySection() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .estimated(72), + heightDimension: .absolute(CategoryChipCell.defaultHeight) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .estimated(72), + heightDimension: .absolute(CategoryChipCell.defaultHeight) + ) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + + let section = NSCollectionLayoutSection(group: group) + section.orthogonalScrollingBehavior = .continuous + section.interGroupSpacing = 8.adjusted + section.contentInsets = .init( + top: 24.adjustedH, + leading: 24.adjusted, + bottom: 24.adjustedH, + trailing: 24.adjusted + ) + section.boundarySupplementaryItems = [createHeaderLayout()] + return section + } + + func createPopularTripSection() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(PopularInfoCell.defaultHeight) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let itemSpacing: CGFloat = 12.adjustedH + let totalGroupHeight = (PopularInfoCell.defaultHeight * 3) + (itemSpacing * 2) + let groupSize = NSCollectionLayoutSize( + widthDimension: .absolute(311.adjusted), + heightDimension: .estimated(totalGroupHeight) + ) + let group = NSCollectionLayoutGroup.vertical( + layoutSize: groupSize, + repeatingSubitem: item, + count: 3 + ) + group.interItemSpacing = .fixed(itemSpacing) + + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = 8.adjusted + section.contentInsets = .init( + top: 0, + leading: 16.adjusted, + bottom: 24.adjustedH, + trailing: 24.adjusted + ) + section.orthogonalScrollingBehavior = .groupPagingCentered + + let footerSize = NSCollectionLayoutSize( + widthDimension: .absolute(327.adjusted), + heightDimension: .estimated(80.adjustedH) + ) + + let footer = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: footerSize, + elementKind: UICollectionView.elementKindSectionFooter, + alignment: .bottom + ) + footer.contentInsets = .init(top: 0, leading: 8.adjusted, bottom: 0, trailing: 0) + section.boundarySupplementaryItems = [footer] + + return section + } + + func createRecommendedTripSection() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(RecommendInfoCell.defaultHeight) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .absolute(RecommendInfoCell.defaultWidth), + heightDimension: .estimated(RecommendInfoCell.defaultHeight) + ) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = 16.adjusted + section.contentInsets = .init(top: 24, leading: 24, bottom: 81.adjustedH, trailing: 24) + section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary + + section.boundarySupplementaryItems = [createHeaderLayout()] + return section + } + + func createHeaderLayout() -> NSCollectionLayoutBoundarySupplementaryItem { + let headerSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(60) + ) + + return NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: headerSize, + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .topLeading + ) + } + + func emptyLayout() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .fractionalHeight(1.0) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .fractionalHeight(1.0) + ) + let group = NSCollectionLayoutGroup(layoutSize: groupSize) + + let section = NSCollectionLayoutSection(group: group) + + return section + } +} diff --git a/Projects/Features/HomeFeature/Sources/Views/CollectionView/Item/HomeItem.swift b/Projects/Features/HomeFeature/Sources/Views/CollectionView/Item/HomeItem.swift new file mode 100644 index 0000000..9a7b881 --- /dev/null +++ b/Projects/Features/HomeFeature/Sources/Views/CollectionView/Item/HomeItem.swift @@ -0,0 +1,16 @@ +// +// HomeItem.swift +// HomeFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/4/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +enum HomeItem: Hashable { + case banner(HomePresentationModel.Banner) + case category(HomePresentationModel.Category, isSelected: Bool) + case popularTrip(HomePresentationModel.PopularTrip) + case recommendedTrip(HomePresentationModel.RecommendedTrip) +} diff --git a/Projects/Features/HomeFeature/Sources/Views/CollectionView/Registration/HomeRegistration.swift b/Projects/Features/HomeFeature/Sources/Views/CollectionView/Registration/HomeRegistration.swift new file mode 100644 index 0000000..f1558f3 --- /dev/null +++ b/Projects/Features/HomeFeature/Sources/Views/CollectionView/Registration/HomeRegistration.swift @@ -0,0 +1,71 @@ +// +// HomeRegistration.swift +// HomeFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/6/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +extension HomeViewController { + func createBannerCellRegistration() -> UICollectionView.CellRegistration { + return UICollectionView.CellRegistration { cell, indexPath, item in + cell.configure(item) + } + } + + func createCategoryCellRegistration() -> UICollectionView.CellRegistration { + return UICollectionView.CellRegistration { cell, indexPath, item in + let (chip, isSelected) = item + + let chipType: ChipIconType = { + switch chip.viedoType { + case .tv: ChipIconType.tv + case .youtube: ChipIconType.youtube + case .none: ChipIconType.none + } + }() + + cell.configure(chipType, chip.creator, isSelected) + } + } + + func createPopularTripCellRegistration() -> UICollectionView.CellRegistration { + return UICollectionView.CellRegistration { cell, indexPath, item in + cell.configure( + thumbnailUrl: item.thumbnailUrl, + city: item.city, + title: item.title, + nation: item.country, + schedule: item.schedule + ) + } + } + + func createRecommedTripCellRegistration() -> UICollectionView.CellRegistration { + return UICollectionView.CellRegistration { cell, indexPath, item in + cell.configure(item) + } + } + + func createHeaderRegistration() -> UICollectionView.SupplementaryRegistration { + return UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionHeader) { headerView,elementKind,indexPath in + guard let sectionKind = HomeSectionKind(rawValue: indexPath.section) else { return } + + headerView.configure(title: sectionKind.headerTitle) + } + } + + func createPopularFooterRegistration() -> UICollectionView.SupplementaryRegistration { + return UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionFooter) { [weak self] footerView,elementKind,indexPath in + guard let self = self else { return } + + footerView.plusBtnTapped + .bind(to: self.moreButtonTapped) + .disposed(by: footerView.disposeBag) + } + } +} diff --git a/Projects/Features/HomeFeature/Sources/Views/CollectionView/SectionKind/HomeSectionKind.swift b/Projects/Features/HomeFeature/Sources/Views/CollectionView/SectionKind/HomeSectionKind.swift new file mode 100644 index 0000000..1dc7642 --- /dev/null +++ b/Projects/Features/HomeFeature/Sources/Views/CollectionView/SectionKind/HomeSectionKind.swift @@ -0,0 +1,26 @@ +// +// HomeSectionKind.swift +// HomeFeature +// +// Created by ์ตœ์•ˆ์šฉ on 1/30/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +enum HomeSectionKind: Int, CaseIterable { + case banner + case category + case popularTrip + case recommendedTrip + + var headerTitle: String { + switch self { + case .category: + "์ธ๊ธฐ ์—ฌํ–‰ ๋”ฐ๋ผ๊ฐ€๊ธฐ" + case .recommendedTrip: + "๋‚˜ํ˜œ์ฃผ๋‹˜๊ป˜ ์ถ”์ฒœํ•˜๋Š”\n๋”ฐ๋ผ๊ฐ€๊ธฐ ์—ฌํ–‰ ์ฝ˜ํ…์ธ ์—์š”!" + default: "" + } + } +} diff --git a/Projects/Features/HomeFeature/Sources/Views/CollectionViews/CategoryCollectionView.swift b/Projects/Features/HomeFeature/Sources/Views/CollectionViews/CategoryCollectionView.swift deleted file mode 100644 index f7e7283..0000000 --- a/Projects/Features/HomeFeature/Sources/Views/CollectionViews/CategoryCollectionView.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// CategoryCollectionView.swift -// HomeFeature -// -// Created by kimnahun on 2026-01-22. -// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. -// - -import UIKit - -protocol CategoryCollectionViewDelegate: AnyObject { - func categoryCollectionView(_ collectionView: CategoryCollectionView, didSelectCategoryAt index: Int) -} - -final class CategoryCollectionView: UICollectionView { - - // MARK: - Models - - private struct CategoryItem: Hashable { - let index: Int - let title: String - let isSelected: Bool - let isFirstItem: Bool - } - - // MARK: - Properties - - weak var categoryDelegate: CategoryCollectionViewDelegate? - - private var diffableDataSource: UICollectionViewDiffableDataSource? - - // MARK: - Initialization - - init() { - let layout = UICollectionViewFlowLayout() - layout.scrollDirection = .horizontal - layout.minimumInteritemSpacing = 8 - layout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize - - super.init(frame: .zero, collectionViewLayout: layout) - setupCollectionView() - setupDataSource() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Setup - - private func setupCollectionView() { - backgroundColor = .clear - showsHorizontalScrollIndicator = false - delegate = self - register(CategoryCell.self, forCellWithReuseIdentifier: CategoryCell.identifier) - } - - private func setupDataSource() { - diffableDataSource = UICollectionViewDiffableDataSource( - collectionView: self - ) { collectionView, indexPath, item in - guard let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: CategoryCell.identifier, - for: indexPath - ) as? CategoryCell else { - return UICollectionViewCell() - } - cell.configure(title: item.title, isSelected: item.isSelected, isFirstItem: item.isFirstItem) - return cell - } - } - - // MARK: - Public Methods - - func applySnapshot(categories: [String], selectedIndex: Int) { - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([0]) - - let items = categories.enumerated().map { index, title in - CategoryItem( - index: index, - title: title, - isSelected: index == selectedIndex, - isFirstItem: index == 0 - ) - } - snapshot.appendItems(items) - - diffableDataSource?.apply(snapshot, animatingDifferences: false) - } -} - -// MARK: - UICollectionViewDelegate - -extension CategoryCollectionView: UICollectionViewDelegate { - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - categoryDelegate?.categoryCollectionView(self, didSelectCategoryAt: indexPath.item) - } -} diff --git a/Projects/Features/HomeFeature/Sources/Views/CollectionViews/RecommendContentCollectionView.swift b/Projects/Features/HomeFeature/Sources/Views/CollectionViews/RecommendContentCollectionView.swift deleted file mode 100644 index ec29737..0000000 --- a/Projects/Features/HomeFeature/Sources/Views/CollectionViews/RecommendContentCollectionView.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// RecommendContentCollectionView.swift -// HomeFeature -// -// Created by kimnahun on 2026-01-22. -// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. -// - -import Domain -import UIKit - -protocol RecommendContentCollectionViewDelegate: AnyObject { - func recommendContentCollectionView(_ collectionView: RecommendContentCollectionView, didSelectItemAt index: Int) -} - -final class RecommendContentCollectionView: UICollectionView { - - // MARK: - Properties - - weak var contentDelegate: RecommendContentCollectionViewDelegate? - - private var diffableDataSource: UICollectionViewDiffableDataSource? - - // MARK: - Initialization - - init() { - let layout = UICollectionViewFlowLayout() - layout.scrollDirection = .horizontal - layout.minimumInteritemSpacing = 12 - layout.itemSize = CGSize(width: 200, height: 260) - - super.init(frame: .zero, collectionViewLayout: layout) - setupCollectionView() - setupDataSource() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Setup - - private func setupCollectionView() { - backgroundColor = .clear - showsHorizontalScrollIndicator = false - contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 24) - delegate = self - register(RecommendContentCell.self, forCellWithReuseIdentifier: RecommendContentCell.identifier) - } - - private func setupDataSource() { - diffableDataSource = UICollectionViewDiffableDataSource( - collectionView: self - ) { collectionView, indexPath, recommendation in - guard let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: RecommendContentCell.identifier, - for: indexPath - ) as? RecommendContentCell else { - return UICollectionViewCell() - } - cell.configure(with: recommendation) - return cell - } - } - - // MARK: - Public Methods - - func applySnapshot(recommendations: [Recommendation]) { - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([0]) - snapshot.appendItems(recommendations) - diffableDataSource?.apply(snapshot, animatingDifferences: false) - } -} - -// MARK: - UICollectionViewDelegate - -extension RecommendContentCollectionView: UICollectionViewDelegate { - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - contentDelegate?.recommendContentCollectionView(self, didSelectItemAt: indexPath.item) - } -} diff --git a/Projects/Features/HomeFeature/Sources/Views/CollectionViews/YoutuberContentCollectionView.swift b/Projects/Features/HomeFeature/Sources/Views/CollectionViews/YoutuberContentCollectionView.swift deleted file mode 100644 index 0ee929e..0000000 --- a/Projects/Features/HomeFeature/Sources/Views/CollectionViews/YoutuberContentCollectionView.swift +++ /dev/null @@ -1,150 +0,0 @@ -// -// YoutuberContentCollectionView.swift -// HomeFeature -// -// Created by kimnahun on 2026-01-22. -// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. -// - -import Domain -import UIKit - -protocol YoutuberContentCollectionViewDelegate: AnyObject { - func youtuberContentCollectionView(_ collectionView: YoutuberContentCollectionView, didSelectItemAt index: Int, in section: Int) - func youtuberContentCollectionView(_ collectionView: YoutuberContentCollectionView, didScrollToSection section: Int) -} - -final class YoutuberContentCollectionView: UICollectionView { - - // MARK: - Properties - - weak var contentDelegate: YoutuberContentCollectionViewDelegate? - - private let maxItemCountPerSection = 3 - private let peekWidth: CGFloat = 10 - private var diffableDataSource: UICollectionViewDiffableDataSource? - private var categories: [TripCategory] = [] - - // MARK: - Initialization - - init() { - super.init(frame: .zero, collectionViewLayout: UICollectionViewLayout()) - collectionViewLayout = createLayout() - setupCollectionView() - setupDataSource() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Layout - - private func createLayout() -> UICollectionViewCompositionalLayout { - let layout = UICollectionViewCompositionalLayout { [weak self] sectionIndex, environment in - guard let self = self else { return nil } - - let containerWidth = environment.container.contentSize.width - let sectionWidth = containerWidth - self.peekWidth - - // Item - ํ•œ ์…€์˜ ํฌ๊ธฐ - let itemSize = NSCollectionLayoutSize( - widthDimension: .fractionalWidth(1.0), - heightDimension: .absolute(88) - ) - let item = NSCollectionLayoutItem(layoutSize: itemSize) - - // Group - ์„ธ๋กœ๋กœ 3๊ฐœ์”ฉ ๋ฌถ์Œ - let groupSize = NSCollectionLayoutSize( - widthDimension: .absolute(sectionWidth), - heightDimension: .absolute(88 * 3 + 12 * 2) - ) - let group = NSCollectionLayoutGroup.vertical( - layoutSize: groupSize, - subitems: [item] - ) - group.interItemSpacing = .fixed(12) - - let section = NSCollectionLayoutSection(group: group) - section.orthogonalScrollingBehavior = .groupPaging - - return section - } - - return layout - } - - // MARK: - Setup - - private func setupCollectionView() { - backgroundColor = .clear - showsHorizontalScrollIndicator = false - isScrollEnabled = false - delegate = self - register(YoutuberContentCell.self, forCellWithReuseIdentifier: YoutuberContentCell.identifier) - } - - private func setupDataSource() { - diffableDataSource = UICollectionViewDiffableDataSource( - collectionView: self - ) { collectionView, indexPath, trip in - guard let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: YoutuberContentCell.identifier, - for: indexPath - ) as? YoutuberContentCell else { - return UICollectionViewCell() - } - cell.configure(with: trip) - return cell - } - } - - // MARK: - Public Methods - - func applySnapshot(tripsByCategory: [TripCategory: [PopularTrip]], categories: [TripCategory]) { - self.categories = categories - - var snapshot = NSDiffableDataSourceSnapshot() - - for category in categories { - snapshot.appendSections([category]) - let trips = tripsByCategory[category] ?? [] - let limitedTrips = Array(trips.prefix(maxItemCountPerSection)) - snapshot.appendItems(limitedTrips, toSection: category) - } - - diffableDataSource?.apply(snapshot, animatingDifferences: false) - } - - func scrollToCategory(at index: Int, animated: Bool = true) { - guard index < categories.count else { return } - - let indexPath = IndexPath(item: 0, section: index) - scrollToItem(at: indexPath, at: .left, animated: animated) - } -} - -// MARK: - UICollectionViewDelegate - -extension YoutuberContentCollectionView: UICollectionViewDelegate { - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - contentDelegate?.youtuberContentCollectionView(self, didSelectItemAt: indexPath.item, in: indexPath.section) - } - - func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { - updateCurrentSection() - } - - func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { - updateCurrentSection() - } - - private func updateCurrentSection() { - let visibleRect = CGRect(origin: contentOffset, size: bounds.size) - let visiblePoint = CGPoint(x: visibleRect.midX, y: visibleRect.midY) - - if let indexPath = indexPathForItem(at: visiblePoint) { - contentDelegate?.youtuberContentCollectionView(self, didScrollToSection: indexPath.section) - } - } -} diff --git a/Projects/Features/HomeFeature/Sources/Views/Component/HomeBannerEmptyView.swift b/Projects/Features/HomeFeature/Sources/Views/Component/HomeBannerEmptyView.swift new file mode 100644 index 0000000..6971486 --- /dev/null +++ b/Projects/Features/HomeFeature/Sources/Views/Component/HomeBannerEmptyView.swift @@ -0,0 +1,86 @@ +// +// HomeBannerEmptyView.swift +// HomeFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/3/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +final class HomeBannerEmptyView: UIView { + private let titleLabel = UILabel() + private let subTitleLabel = UILabel() + private let titleStackView = UIStackView() + private let stackView = UIStackView() + private let imageView = UIImageView() + + override init(frame: CGRect) { + super.init(frame: frame) + + setStyle() + setUI() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private extension HomeBannerEmptyView { + func setStyle() { + backgroundColor = .clear + + titleLabel.do { + $0.setText( + .bodyLSB, + text: "์•„์ง ๋“ฑ๋ก๋œ ์—ฌํ–‰์ง€๊ฐ€ ์—†์–ด์š”", + color: DSKitAsset.Colors.black700.color + ) + } + + subTitleLabel.do { + $0.setText( + .bodyMM, + text: "์ƒˆ ์—ฌํ–‰ ์ผ์ •์„ ๋งŒ๋“ค์–ด ๋ณด์„ธ์š”!", + color: DSKitAsset.Colors.black400.color + ) + } + + titleStackView.do { + $0.axis = .vertical + $0.spacing = 6.adjustedH + $0.alignment = .leading + } + + stackView.do { + $0.axis = .horizontal + $0.spacing = 4.adjusted + $0.alignment = .center + } + + imageView.do { + $0.image = DSKitAsset.Assets.icEmptyTrip.image + } + } + + func setUI() { + titleStackView.addArrangedSubviews(titleLabel, subTitleLabel) + stackView.addArrangedSubviews(titleStackView, imageView) + addSubviews(stackView) + } + + func setLayout() { + imageView.snp.makeConstraints { + $0.size.equalTo(76.adjustedH) + } + + stackView.snp.makeConstraints { + $0.directionalHorizontalEdges.equalToSuperview().inset(16) + $0.directionalVerticalEdges.equalToSuperview().inset(2).priority(.high) + } + } +} diff --git a/Projects/Features/HomeFeature/Sources/Views/Component/HomeBannerOnGoingView.swift b/Projects/Features/HomeFeature/Sources/Views/Component/HomeBannerOnGoingView.swift new file mode 100644 index 0000000..7c75970 --- /dev/null +++ b/Projects/Features/HomeFeature/Sources/Views/Component/HomeBannerOnGoingView.swift @@ -0,0 +1,169 @@ +// +// HomeBannerOnGoingView.swift +// HomeFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/3/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit +final class HomeBannerOnGoingView: UIView { + private let titleLabel = UILabel() + private let dateLabel = UILabel() + private let iconImageView = UIImageView() + private let transportLabel = UILabel() + private let dotLabel = UILabel() + private let durationLabel = UILabel() + private let placeLabel = UILabel() + private let imageView = UIImageView() + private let titleStackView = UIStackView() + private let subInfoStackView = UIStackView() + private let infoStackView = UIStackView() + private let routeStackView = UIStackView() + private let routeCardView = UIView() + private let containerStackView = UIStackView() + + override init(frame: CGRect) { + super.init(frame: frame) + + setStyle() + setUI() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure( + title: String, + date: String, + transportIcon: UIImage?, + transport: String, + duration: String, + place: String, + imageUrl: String + ) { + titleLabel.setText(.bodyMSB, text: title, color: DSKitAsset.Colors.black700.color) + dateLabel.setText(.bodyMR, text: date, color: DSKitAsset.Colors.black500.color) + iconImageView.image = transportIcon?.withRenderingMode(.alwaysTemplate) + transportLabel.setText(.bodySM, text: transport, color: DSKitAsset.Colors.black400.color) + durationLabel.setText(.bodySM, text: "\(duration) ์ฒด๋ฅ˜ ์˜ˆ์ƒ", color: DSKitAsset.Colors.black400.color) + placeLabel.setText(.bodyLSB, text: place, color: DSKitAsset.Colors.black900.color) + + if let url = URL(string: imageUrl) { + imageView.kf.setImage(with: url) + } else { + imageView.backgroundColor = .systemGray5 + } + } + + func prepareForReuse() { + imageView.kf.cancelDownloadTask() + titleLabel.text = nil + dateLabel.text = nil + placeLabel.text = nil + durationLabel.text = nil + imageView.image = nil + iconImageView.image = nil + transportLabel.text = nil + } +} + +private extension HomeBannerOnGoingView { + func setStyle() { + backgroundColor = .clear + + dotLabel.do { + $0.setText(.bodyMM, text: "โ€ข", color: DSKitAsset.Colors.black400.color) + } + + iconImageView.do { + $0.tintColor = DSKitAsset.Colors.black500.color + } + + imageView.do { + $0.layer.cornerRadius = 4.adjustedH + $0.backgroundColor = .systemGray6 + $0.clipsToBounds = true + } + + titleStackView.do { + $0.axis = .vertical + $0.spacing = 4.adjustedH + $0.alignment = .leading + } + + subInfoStackView.do { + $0.axis = .horizontal + $0.spacing = 4.adjusted + $0.alignment = .center + } + + infoStackView.do { + $0.axis = .vertical + $0.spacing = 10.adjustedH + $0.alignment = .leading + } + + routeStackView.do { + $0.axis = .horizontal + $0.spacing = 12.adjusted + $0.alignment = .center + } + + routeCardView.do { + $0.backgroundColor = DSKitAsset.Colors.white.color + $0.layer.cornerRadius = 16.adjustedH + $0.clipsToBounds = true + } + + containerStackView.do { + $0.axis = .vertical + $0.spacing = 16.adjustedH + $0.alignment = .leading + } + } + + func setUI() { + titleStackView.addArrangedSubviews(titleLabel, dateLabel) + subInfoStackView.addArrangedSubviews(iconImageView, transportLabel, dotLabel, durationLabel) + infoStackView.addArrangedSubviews(subInfoStackView, placeLabel) + routeStackView.addArrangedSubviews(infoStackView, imageView) + routeCardView.addSubview(routeStackView) + containerStackView.addArrangedSubviews(titleStackView, routeCardView) + addSubview(containerStackView) + } + + func setLayout() { + iconImageView.snp.makeConstraints { + $0.size.equalTo(14.adjustedH) + } + + imageView.snp.makeConstraints { + $0.size.equalTo(56.adjustedH) + } + + routeStackView.snp.makeConstraints { + $0.directionalHorizontalEdges.equalToSuperview().inset(16.adjusted) + $0.top.equalToSuperview().inset(12.adjustedH) + $0.bottom.equalToSuperview().inset(16.adjustedH) + } + + routeCardView.snp.makeConstraints { + $0.width.equalToSuperview() + } + + titleStackView.snp.makeConstraints { + $0.directionalHorizontalEdges.equalToSuperview() + } + + containerStackView.snp.makeConstraints { + $0.directionalHorizontalEdges.equalToSuperview().inset(16.adjusted) + $0.top.equalToSuperview().inset(16.adjustedH) + $0.bottom.equalToSuperview().inset(23.adjustedH).priority(.low) + } + } +} diff --git a/Projects/Features/HomeFeature/Sources/Views/Component/HomeBannerUpCommingView.swift b/Projects/Features/HomeFeature/Sources/Views/Component/HomeBannerUpCommingView.swift new file mode 100644 index 0000000..b1756bb --- /dev/null +++ b/Projects/Features/HomeFeature/Sources/Views/Component/HomeBannerUpCommingView.swift @@ -0,0 +1,120 @@ +// +// HomeBannerUpCommingView.swift +// HomeFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/3/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit +final class HomeBannerUpCommingView: UIView { + private let imageView = UIImageView() + private let badge = UIView() + private let dDayLabel = UILabel() + private let titleLabel = UILabel() + private let dateLabel = UILabel() + private let titleStackView = UIStackView() + private let infoStackView = UIStackView() + private let stackView = UIStackView() + + override init(frame: CGRect) { + super.init(frame: frame) + + setStyle() + setUI() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(title: String, date: String, dDay: Int, imageUrl: String) { + titleLabel.setText(.subTitleMSB, text: title, color: DSKitAsset.Colors.black700.color) + dateLabel.setText(.bodyMR, text: date, color: DSKitAsset.Colors.black600.color) + dDayLabel.setText(.bodyMM, text: "D-\(dDay)", color: DSKitAsset.Colors.black400.color) + + if let url = URL(string: imageUrl) { + imageView.kf.setImage(with: url) + } else { + imageView.backgroundColor = .systemGray5 + } + } + + func prepareForReuse() { + imageView.kf.cancelDownloadTask() + titleLabel.text = nil + dateLabel.text = nil + dDayLabel.text = nil + imageView.image = nil + } +} + +private extension HomeBannerUpCommingView { + func setStyle() { + backgroundColor = .clear + + imageView.do { + $0.layer.cornerRadius = 64.adjustedH / 2 + $0.clipsToBounds = true + $0.contentMode = .scaleAspectFill + } + + badge.do { + $0.backgroundColor = DSKitAsset.Colors.black100.color + $0.layer.cornerRadius = 26.adjustedH / 2 + $0.clipsToBounds = true + $0.setContentCompressionResistancePriority(.required, for: .horizontal) + $0.setContentHuggingPriority(.required, for: .horizontal) + } + + titleLabel.do { + $0.numberOfLines = 1 + $0.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + } + + titleStackView.do { + $0.axis = .horizontal + $0.spacing = 8.adjusted + $0.alignment = .center + } + + infoStackView.do { + $0.axis = .vertical + $0.spacing = 6.adjustedH + $0.alignment = .leading + } + + stackView.do { + $0.axis = .horizontal + $0.spacing = 12.adjusted + $0.alignment = .center + } + } + + func setUI() { + badge.addSubview(dDayLabel) + titleStackView.addArrangedSubviews(badge, titleLabel) + infoStackView.addArrangedSubviews(titleStackView, dateLabel) + stackView.addArrangedSubviews(imageView, infoStackView) + addSubview(stackView) + } + + func setLayout() { + imageView.snp.makeConstraints { + $0.size.equalTo(64.adjustedH) + } + + dDayLabel.snp.makeConstraints { + $0.directionalHorizontalEdges.equalToSuperview().inset(12.adjusted) + $0.directionalVerticalEdges.equalToSuperview().inset(4.adjustedH) + } + + stackView.snp.makeConstraints { + $0.directionalHorizontalEdges.equalToSuperview().inset(16.adjusted) + $0.directionalVerticalEdges.equalToSuperview().inset(8.adjustedH).priority(.high) + } + } +} diff --git a/Projects/Features/HomeFeature/Sources/Views/MyTravelView.swift b/Projects/Features/HomeFeature/Sources/Views/MyTravelView.swift deleted file mode 100644 index e9e56c9..0000000 --- a/Projects/Features/HomeFeature/Sources/Views/MyTravelView.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// MyTravelView.swift -// HomeFeature -// -// Created by kimnahun on 1/22/26. -// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. -// - -import Core -import DSKit -import UIKit -import SnapKit -import Then - -final class MyTravelView: UIView { - - private let messageLabel = UILabel().then { - $0.setText(.bodyLSB, text: "์•„์ง ๋“ฑ๋ก๋œ ์—ฌํ–‰์ง€๊ฐ€ ์—†์–ด์š”", color: UIColor(hexCode: "#2C2C2C")) - } - private let subMessageLabel = UILabel().then { - $0.setText(.bodyMM, text: "์ƒˆ ์—ฌํ–‰ ์ผ์ •์„ ๋งŒ๋“ค์–ด ๋ณด์„ธ์š”!", color: UIColor(hexCode: "#2C2C2C")) - } - - private let imageView = UIImageView(image: DSKitAsset.Assets.icAirplane1.image) - - override init(frame: CGRect) { - super.init(frame: frame) - setupUI() - setupConstraints() - } - - required init?(coder: NSCoder) { fatalError() } - - private func setupUI() { - backgroundColor = UIColor(hexCode: "#2C2C2C") - layer.cornerRadius = 4 - layer.borderWidth = 1.0 - layer.borderColor = UIColor.init(hexCode: "#F1F1F1").cgColor - - addSubview(messageLabel) - addSubview(subMessageLabel) - addSubview(imageView) - - } - - private func setupConstraints() { - messageLabel.snp.makeConstraints { - $0.top.equalToSuperview().offset(17.5) - $0.leading.equalToSuperview().offset(16) - } - subMessageLabel.snp.makeConstraints { - $0.top.equalTo(messageLabel.snp.bottom).offset(6) - $0.leading.equalTo(messageLabel) - } - imageView.snp.makeConstraints { - $0.verticalEdges.equalToSuperview().inset(8.19) - $0.trailing.equalToSuperview().offset(-16) - $0.width.equalTo(63.63) - } - } -} diff --git a/Projects/Features/MainFeature/Project.swift b/Projects/Features/MainFeature/Project.swift new file mode 100644 index 0000000..d04eb4e --- /dev/null +++ b/Projects/Features/MainFeature/Project.swift @@ -0,0 +1,30 @@ +// +// Project.swift +// ProjectDescriptionHelpers +// +// Created by แ„Žแ…ฌแ„‹แ…กแ†ซแ„‹แ…ญแ†ผ on 2026/02/11. +// + +import ProjectDescription +import ProjectDescriptionHelpers +import DependencyPlugin + +let project = Project.makeModule( + name: "MainFeature", + targets: [ + .makeFrameworkTarget( + name: "MainFeature", + dependencies: [ + .Features.baseFeatureDependency, + .Features.Follow.feature, + .Features.Search.feature, + .Features.Setting.feature, + .Features.TabBar.feature, + .Features.PopularTravel.feature + ], + scripts: [.swiftLint], + isStatic: true, + hasResources: false + ) + ] +) diff --git a/Projects/Features/MainFeature/Sources/MainBuilder.swift b/Projects/Features/MainFeature/Sources/MainBuilder.swift new file mode 100644 index 0000000..fce1da2 --- /dev/null +++ b/Projects/Features/MainFeature/Sources/MainBuilder.swift @@ -0,0 +1,76 @@ +// +// MainBuilder.swift +// MainFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/11/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Domain +import Data +import FollowFeature +import PopularTravelFeature +import SettingFeature +import SearchFeature +import TabBarFeature + +import RIBs + +public protocol MainDependency: Dependency { + var homeUsecase: HomeUsecaseProtocol { get } + var tokenProvider: TokenProviding { get } +} + +final class MainComponent: Component, FollowDetailDependency, PopularTravelDependency,SearchDependency, SettingDependency, TabBarDependency { + var followService: FollowServiceProtocol { + makeFollowService() + } + + var travelService: TravelServiceProtocol { + makeTravelService(tokenProvider: tokenProvider) + } + + var tokenProvider: TokenProviding { + dependency.tokenProvider + } + + var homeUsecase: HomeUsecaseProtocol { + dependency.homeUsecase + } +} + +// MARK: - Builder + +public protocol MainBuildable: Buildable { + func build(withListener listener: MainListener) -> MainRouting +} + +public final class MainBuilder: Builder, MainBuildable { + + override public init(dependency: MainDependency) { + super.init(dependency: dependency) + } + + public func build(withListener listener: MainListener) -> MainRouting { + let component = MainComponent(dependency: dependency) + let viewController = MainViewController() + let interactor = MainInteractor(presenter: viewController) + interactor.listener = listener + + let followBuilder = FollowDetailBuilder(dependency: component) + let popularTravelBuilder = PopularTravelBuilder(dependency: component) + let searchBuilder = SearchBuilder(dependency: component) + let settingBuilder = SettingBuilder(dependency: component) + let tabBarBuilder = TabBarBuilder(dependency: component) + + return MainRouter( + interactor: interactor, + viewController: viewController, + followBuilder: followBuilder, + popularTravelBuilder: popularTravelBuilder, + searchBuilder: searchBuilder, + settingBuilder: settingBuilder, + tabBarBuilder: tabBarBuilder + ) + } +} diff --git a/Projects/Features/MainFeature/Sources/MainInteractor.swift b/Projects/Features/MainFeature/Sources/MainInteractor.swift new file mode 100644 index 0000000..bad8191 --- /dev/null +++ b/Projects/Features/MainFeature/Sources/MainInteractor.swift @@ -0,0 +1,94 @@ +// +// MainInteractor.swift +// MainFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/11/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +import RIBs +import RxSwift + +public protocol MainRouting: ViewableRouting { + func attachFollow(with recommendationId: Int) + func detachFollow() + func attachPopularTravel() + func detachPopularTravel() + func attachSearch() + func detachSearch() + func attachSetting() + func detachSetting() + func attachTabBar() +} + +protocol MainPresentable: Presentable { + var listener: MainPresentableListener? { get set } +} + +public protocol MainListener: AnyObject { } + +final class MainInteractor: PresentableInteractor, MainInteractable, MainPresentableListener { + weak var router: MainRouting? + weak var listener: MainListener? + + override init(presenter: MainPresentable) { + super.init(presenter: presenter) + presenter.listener = self + } + + override func didBecomeActive() { + super.didBecomeActive() + + router?.attachTabBar() + } + + override func willResignActive() { + super.willResignActive() + } + + func followDetailDidTapClose() { + router?.detachFollow() + } + + func followDetailDidAddTrip(title: String, startDate: Date, endDate: Date) { + // ์ด๊ฑด ๋ญ์ž„ + } + + func popularTravelDidTapFollowDetail(with recommendationId: Int) { + router?.attachFollow(with: recommendationId) + } + + func popularTravelDidTapSearch() { + router?.attachSearch() + } + + func routeToPopularTravel() { + router?.attachPopularTravel() + } + + func detachPopularTravel() { + router?.detachPopularTravel() + } + + func detachSearch() { + router?.detachSearch() + } + + func detachSetting() { + router?.detachSetting() + } + + func routeToFollow(with recommendationId: Int) { + router?.attachFollow(with: recommendationId) + } + + func routeToSetting() { + router?.attachSetting() + } + + func routeToSearch() { + router?.attachSearch() + } +} diff --git a/Projects/Features/MainFeature/Sources/MainRouter.swift b/Projects/Features/MainFeature/Sources/MainRouter.swift new file mode 100644 index 0000000..70ec49a --- /dev/null +++ b/Projects/Features/MainFeature/Sources/MainRouter.swift @@ -0,0 +1,151 @@ +// +// MainRouter.swift +// MainFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/11/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import FollowFeature +import PopularTravelFeature +import SearchFeature +import SettingFeature +import TabBarFeature + +import RIBs + +protocol MainInteractable: Interactable, FollowDetailListener, PopularTravelListener, SearchListener, SettingListener, TabBarListener { + var router: MainRouting? { get set } + var listener: MainListener? { get set } +} + +public protocol MainViewControllable: ViewControllable { + func setViewControllers(_ viewControllables: [ViewControllable]) + func pushViewController(_ viewControllable: ViewControllable, animated: Bool) + func popRootViewController(animated: Bool) + func containsInStack(_ viewControllable: ViewControllable) -> Bool +} + +final class MainRouter: ViewableRouter, MainRouting { + private let followBuilder: FollowDetailBuildable + private var followRouter: FollowDetailRouting? + + private let popularTravelBuilder: PopularTravelBuildable + private var popularTravelRouter: PopularTravelRouting? + + private let searchBuilder: SearchBuildable + private var searchRouter: SearchRouting? + + private let settingBuilder: SettingBuildable + private var settingRouter: SettingRouting? + + private let tabBarBuilder: TabBarBuildable + private var tabBarRouter: TabBarRouting? + + init( + interactor: MainInteractable, + viewController: MainViewControllable, + followBuilder: FollowDetailBuildable, + popularTravelBuilder: PopularTravelBuildable, + searchBuilder: SearchBuildable, + settingBuilder: SettingBuildable, + tabBarBuilder: TabBarBuildable + ) { + self.followBuilder = followBuilder + self.popularTravelBuilder = popularTravelBuilder + self.searchBuilder = searchBuilder + self.settingBuilder = settingBuilder + self.tabBarBuilder = tabBarBuilder + + super.init(interactor: interactor, viewController: viewController) + interactor.router = self + } + + func attachFollow(with recommendationId: Int) { + guard followRouter == nil else { return } + let router = followBuilder.build( + withListener: interactor, + recommendationId: recommendationId + ) + self.followRouter = router + attachChild(router) + viewController.pushViewController(router.viewControllable, animated: true) + } + + func detachFollow() { + guard let router = followRouter else { return } + + if viewController.containsInStack(router.viewControllable) { + viewController.popRootViewController(animated: true) + } + + detachChild(router) + self.followRouter = nil + } + + func attachPopularTravel() { + guard popularTravelRouter == nil else { return } + let router = popularTravelBuilder.build(withListener: interactor) + self.popularTravelRouter = router + attachChild(router) + viewController.pushViewController(router.viewControllable, animated: true) + } + + func detachPopularTravel() { + guard let router = popularTravelRouter else { return } + + if viewController.containsInStack(router.viewControllable) { + viewController.popRootViewController(animated: true) + } + + detachChild(router) + self.popularTravelRouter = nil + } + + func attachSearch() { + guard searchRouter == nil else { return } + let router = searchBuilder.build(withListener: interactor) + self.searchRouter = router + attachChild(router) + viewController.pushViewController(router.viewControllable, animated: true) + } + + func detachSearch() { + guard let router = searchRouter else { return } + + if viewController.containsInStack(router.viewControllable) { + viewController.popRootViewController(animated: true) + } + + detachChild(router) + self.searchRouter = nil + } + + func attachSetting() { + guard settingRouter == nil else { return } + let router = settingBuilder.build(withListener: interactor) + self.settingRouter = router + attachChild(router) + viewController.pushViewController(router.viewControllable, animated: true) + } + + func detachSetting() { + guard let router = settingRouter else { return } + + if viewController.containsInStack(router.viewControllable) { + viewController.popRootViewController(animated: true) + } + + detachChild(router) + self.settingRouter = nil + } + + func attachTabBar() { + guard tabBarRouter == nil else { return } + let router = tabBarBuilder.build(withListener: interactor) + self.tabBarRouter = router + attachChild(router) + + viewController.setViewControllers([router.viewControllable]) + } +} diff --git a/Projects/Features/MainFeature/Sources/MainViewController.swift b/Projects/Features/MainFeature/Sources/MainViewController.swift new file mode 100644 index 0000000..4f870d7 --- /dev/null +++ b/Projects/Features/MainFeature/Sources/MainViewController.swift @@ -0,0 +1,67 @@ +// +// MainViewController.swift +// MainFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/11/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +import RIBs + +protocol MainPresentableListener: AnyObject { + +} + +final class MainViewController: UINavigationController, MainPresentable, MainViewControllable { + weak var listener: MainPresentableListener? + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + self.setNavigationBarHidden(true, animated: animated) + } + + override func viewDidLoad() { + super.viewDidLoad() + + setStyle() + setupDelegate() + } + + func setViewControllers(_ viewControllables: [ViewControllable]) { + let viewControllers = viewControllables.map { $0.uiviewController } + self.setViewControllers(viewControllers, animated: false) + } + + func pushViewController(_ viewControllable: ViewControllable, animated: Bool) { + self.pushViewController(viewControllable.uiviewController, animated: animated) + } + + func popRootViewController(animated: Bool) { + self.popViewController(animated: animated) + } + + func containsInStack(_ viewControllable: ViewControllable) -> Bool { + self.viewControllers.contains(viewControllable.uiviewController) + } +} + +private extension MainViewController { + func setStyle() { + self.view.backgroundColor = DSKitAsset.Colors.white.color + } +} + +extension MainViewController: UIGestureRecognizerDelegate { + private func setupDelegate() { + self.interactivePopGestureRecognizer?.delegate = self + } + + public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + viewControllers.count > 1 + } +} diff --git a/Projects/Features/PopularTravelFeature/Project.swift b/Projects/Features/PopularTravelFeature/Project.swift new file mode 100644 index 0000000..d3ffd92 --- /dev/null +++ b/Projects/Features/PopularTravelFeature/Project.swift @@ -0,0 +1,25 @@ +// +// Project.swift +// ProjectDescriptionHelpers +// +// Created by แ„Žแ…ฌแ„‹แ…กแ†ซแ„‹แ…ญแ†ผ on 2026/02/12. +// + +import ProjectDescription +import ProjectDescriptionHelpers +import DependencyPlugin + +let project = Project.makeModule( + name: "PopularTravelFeature", + targets: [ + .makeFrameworkTarget( + name: "PopularTravelFeature", + dependencies: [ + .Features.baseFeatureDependency + ], + scripts: [.swiftLint], + isStatic: true, + hasResources: false + ) + ] +) diff --git a/Projects/Features/PopularTravelFeature/Sources/Models/PopularTravelPresentationModel.swift b/Projects/Features/PopularTravelFeature/Sources/Models/PopularTravelPresentationModel.swift new file mode 100644 index 0000000..efadaec --- /dev/null +++ b/Projects/Features/PopularTravelFeature/Sources/Models/PopularTravelPresentationModel.swift @@ -0,0 +1,56 @@ +// +// PopularTravelPresentationModel.swift +// PopularTravelFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/12/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +import Domain + +struct PopularTravelPresentationModel { + let category: [PopularTravelPresentationModel.Category] + let popularTrip: [PopularTravelPresentationModel.PopularTrip] + + struct Category: Hashable { + let id: Int + let creator: String + let viedoType: VideoType + } + + struct PopularTrip: Hashable { + let id: String + let title: String + let thumbnailUrl: String + let creator: String + let schedule: String + let country: String + let city: String + } +} + +extension TripCategory { + func toPopularTravelModel() -> PopularTravelPresentationModel.Category { + return PopularTravelPresentationModel.Category( + id: self.id, + creator: self.creator, + viedoType: self.viedoType + ) + } +} + +extension TripInfo { + func toPopularTravelModel() -> PopularTravelPresentationModel.PopularTrip { + return PopularTravelPresentationModel.PopularTrip( + id: self.id, + title: self.title, + thumbnailUrl: self.thumbnailUrl, + creator: self.creator, + schedule: "\(self.nights)๋ฐ• \(self.days)์ผ", + country: self.country, + city: self.city + ) + } +} diff --git a/Projects/Features/PopularTravelFeature/Sources/PopularTravelBuilder.swift b/Projects/Features/PopularTravelFeature/Sources/PopularTravelBuilder.swift new file mode 100644 index 0000000..f148d3d --- /dev/null +++ b/Projects/Features/PopularTravelFeature/Sources/PopularTravelBuilder.swift @@ -0,0 +1,45 @@ +// +// PopularTravelBuilder.swift +// PopularTravelFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/12/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Domain + +import RIBs + +public protocol PopularTravelDependency: Dependency { + var homeUsecase: HomeUsecaseProtocol { get } +} + +final class PopularTravelComponent: Component { + var homeUsecase: HomeUsecaseProtocol { + return dependency.homeUsecase + } +} + +// MARK: - Builder + +public protocol PopularTravelBuildable: Buildable { + func build(withListener listener: PopularTravelListener) -> PopularTravelRouting +} + +public final class PopularTravelBuilder: Builder, PopularTravelBuildable { + + override public init(dependency: PopularTravelDependency) { + super.init(dependency: dependency) + } + + public func build(withListener listener: PopularTravelListener) -> PopularTravelRouting { + let component = PopularTravelComponent(dependency: dependency) + let viewController = PopularTravelViewController() + let interactor = PopularTravelInteractor( + presenter: viewController, + usecase: component.homeUsecase + ) + interactor.listener = listener + return PopularTravelRouter(interactor: interactor, viewController: viewController) + } +} diff --git a/Projects/Features/PopularTravelFeature/Sources/PopularTravelInteractor.swift b/Projects/Features/PopularTravelFeature/Sources/PopularTravelInteractor.swift new file mode 100644 index 0000000..3cd1b6a --- /dev/null +++ b/Projects/Features/PopularTravelFeature/Sources/PopularTravelInteractor.swift @@ -0,0 +1,152 @@ +// +// PopularTravelInteractor.swift +// PopularTravelFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/12/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Domain + +import RIBs +import RxCocoa +import RxRelay +import RxSwift + +struct PopularTravelSectionModel { + let section: PopularTravelSectionKind + let items: [PopularTravelItem] +} + +public protocol PopularTravelRouting: ViewableRouting { + +} + +protocol PopularTravelPresentable: Presentable { + var listener: PopularTravelPresentableListener? { get set } + + func update(with sections: [PopularTravelSectionModel]) + func setLoading(_ isLoading: Bool) + func showErrorView(_ isError: Bool) +} + +public protocol PopularTravelListener: AnyObject { + func popularTravelDidTapFollowDetail(with recommendationId: Int) + func popularTravelDidTapSearch() + func detachPopularTravel() +} + +final class PopularTravelInteractor: PresentableInteractor, PopularTravelInteractable { + + weak var router: PopularTravelRouting? + weak var listener: PopularTravelListener? + + private var fetchDataTask: Task? + private let usecase: HomeUsecaseProtocol + private let disposeBag = DisposeBag() + + private let popularTravelDataRelay = BehaviorRelay(value: nil) + private let selectedCategoryRelay = BehaviorRelay(value: nil) + + init(presenter: PopularTravelPresentable, usecase: HomeUsecaseProtocol) { + self.usecase = usecase + super.init(presenter: presenter) + presenter.listener = self + } + + override func didBecomeActive() { + super.didBecomeActive() + + setupStream() + fetchData() + } + + override func willResignActive() { + super.willResignActive() + + fetchDataTask?.cancel() + fetchDataTask = nil + } + + private func setupStream() { + Observable.combineLatest( + popularTravelDataRelay.compactMap { $0 }, + selectedCategoryRelay + ) + .map { model, selectedId -> [PopularTravelSectionModel] in + return [ + .init(section: .category, items: model.category.map { + .category($0, isSelected: $0.id == selectedId) + }), + .init(section: .popularTrip, items: model.popularTrip.map { .popularTrip($0) }) + ] + } + .subscribe(with: self) { owner, sections in + owner.presenter.update(with: sections) + } + .disposed(by: disposeBag) + + popularTravelDataRelay + .map { $0 == nil } + .subscribe(with: self) { owner, isLoading in + owner.presenter.setLoading(isLoading) + } + .disposed(by: disposeBag) + } + + private func fetchData() { + fetchDataTask?.cancel() + + presenter.setLoading(true) + presenter.showErrorView(false) + + fetchDataTask = Task { [weak self] in + guard let self, !Task.isCancelled else { return } + + do { + async let categories = self.usecase.fetchCategoryList().map { $0.toPopularTravelModel() } + async let populars = self.usecase.fetchPopularTripList().map { $0.toPopularTravelModel() } + + let model = try await PopularTravelPresentationModel( + category: categories, + popularTrip: populars + ) + + guard !Task.isCancelled else { return } + + if self.selectedCategoryRelay.value == nil, let firstId = model.category.first?.id { + self.selectedCategoryRelay.accept(firstId) + } + + popularTravelDataRelay.accept(model) + presenter.setLoading(false) + } catch let error { + presenter.setLoading(false) + presenter.showErrorView(true) + } + } + } +} + +extension PopularTravelInteractor: PopularTravelPresentableListener { + func detachPopularTravel() { + listener?.detachPopularTravel() + } + + func searchBtnTapped() { + listener?.popularTravelDidTapSearch() + } + + func itemSelected(item: PopularTravelItem) { + switch item { + case .category(let category, _): + selectedCategoryRelay.accept(category.id) + case .popularTrip(let trip): + listener?.popularTravelDidTapFollowDetail(with: Int(trip.id) ?? 0) + } + } + + func reloadBtnTapped() { + fetchData() + } +} diff --git a/Projects/Features/PopularTravelFeature/Sources/PopularTravelRouter.swift b/Projects/Features/PopularTravelFeature/Sources/PopularTravelRouter.swift new file mode 100644 index 0000000..8229ee8 --- /dev/null +++ b/Projects/Features/PopularTravelFeature/Sources/PopularTravelRouter.swift @@ -0,0 +1,26 @@ +// +// PopularTravelRouter.swift +// PopularTravelFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/12/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import RIBs + +protocol PopularTravelInteractable: Interactable { + var router: PopularTravelRouting? { get set } + var listener: PopularTravelListener? { get set } +} + +protocol PopularTravelViewControllable: ViewControllable { + +} + +final class PopularTravelRouter: ViewableRouter, PopularTravelRouting { + + override init(interactor: PopularTravelInteractable, viewController: PopularTravelViewControllable) { + super.init(interactor: interactor, viewController: viewController) + interactor.router = self + } +} diff --git a/Projects/Features/PopularTravelFeature/Sources/PopularTravelViewController.swift b/Projects/Features/PopularTravelFeature/Sources/PopularTravelViewController.swift new file mode 100644 index 0000000..6f88d70 --- /dev/null +++ b/Projects/Features/PopularTravelFeature/Sources/PopularTravelViewController.swift @@ -0,0 +1,222 @@ +// +// PopularTravelViewController.swift +// PopularTravelFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/12/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +import RIBs +import RxCocoa +import RxSwift + +protocol PopularTravelPresentableListener: AnyObject { + func detachPopularTravel() + func searchBtnTapped() + func itemSelected(item: PopularTravelItem) + func reloadBtnTapped() +} + +final class PopularTravelViewController: UIViewController, PopularTravelViewControllable { + + weak var listener: PopularTravelPresentableListener? + + private let disposeBag = DisposeBag() + + private let navigationBar = NDGLNavigationBar( + style: .white, + title: "์ธ๊ธฐ ์—ฌํ–‰ ๋”ฐ๋ผ๊ฐ€๊ธฐ", + leadingIcon: DSKitAsset.Assets.icChevronLeft3.image, + trailingIcon: DSKitAsset.Assets.icSearch2.image + ) + private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout()) + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + private let networkErrorView = NDGLErrorView() + + private var dataSource: UICollectionViewDiffableDataSource? + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + setStyle() + setUI() + setLayout() + + setCollectionView() + setDataSource() + bindInteractor() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + if isMovingFromParent { + listener?.detachPopularTravel() + } + } +} + +private extension PopularTravelViewController { + func setStyle() { + view.backgroundColor = DSKitAsset.Colors.white.color + + collectionView.do { + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false + $0.backgroundColor = .clear + $0.contentInset = .zero + $0.isScrollEnabled = true + } + + loadingIndicator.do { + $0.color = DSKitAsset.Colors.green300.color + } + + networkErrorView.do { + $0.isHidden = true + } + } + + func setUI() { + view.addSubviews(collectionView, navigationBar, loadingIndicator, networkErrorView) + } + + func setLayout() { + navigationBar.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide) + $0.directionalHorizontalEdges.equalToSuperview() + } + + collectionView.snp.makeConstraints { + $0.top.equalTo(navigationBar.snp.bottom) + $0.bottom.equalToSuperview() + $0.directionalHorizontalEdges.equalToSuperview() + } + + loadingIndicator.snp.makeConstraints { + $0.center.equalToSuperview() + } + + networkErrorView.snp.makeConstraints { + $0.top.equalTo(navigationBar.snp.bottom) + $0.directionalHorizontalEdges.equalToSuperview() + $0.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16.adjustedH) + } + } + + func setCollectionView() { + collectionView.do { + $0.register( + CategoryChipCell.self, + forCellWithReuseIdentifier: CategoryChipCell.cellIdentifier + ) + + $0.register( + PopularInfoCell.self, + forCellWithReuseIdentifier: PopularInfoCell.cellIdentifier + ) + } + } +} + +private extension PopularTravelViewController { + func bindInteractor() { + navigationBar.leadingButtonDidTap + .subscribe(with: self) { owner, _ in + owner.navigationController?.popViewController(animated: true) + } + .disposed(by: disposeBag) + + navigationBar.trailingButtonDidTap + .subscribe(with: self) { owner, _ in + owner.listener?.searchBtnTapped() + } + .disposed(by: disposeBag) + + collectionView.rx.itemSelected + .compactMap { [weak self] indexPath in + self?.dataSource?.itemIdentifier(for: indexPath) + } + .subscribe(with: self) { owner, item in + owner.listener?.itemSelected(item: item) + } + .disposed(by: disposeBag) + + networkErrorView.buttonDidTap + .subscribe(with: self) { owner, _ in + owner.listener?.reloadBtnTapped() + } + .disposed(by: disposeBag) + } + + func applySnapshot(with sections: [PopularTravelSectionModel]) { + var snapshot = NSDiffableDataSourceSnapshot() + sections.forEach { + snapshot.appendSections([$0.section]) + snapshot.appendItems($0.items, toSection: $0.section) + } + dataSource?.apply(snapshot, animatingDifferences: true) + } + + func setDataSource() { + let categoryRegistration = createCategoryCellRegistration() + let popularTripRegistration = createPopularTripCellRegistration() + + dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item in + switch item { + case .category(let category): + return collectionView.dequeueConfiguredReusableCell( + using: categoryRegistration, + for: indexPath, + item: category + ) + case .popularTrip(let tripList): + return collectionView.dequeueConfiguredReusableCell( + using: popularTripRegistration, + for: indexPath, + item: tripList + ) + } + } + } +} + +extension PopularTravelViewController: PopularTravelPresentable { + func update(with sections: [PopularTravelSectionModel]) { + DispatchQueue.main.async { [weak self] in + self?.applySnapshot(with: sections) + } + } + + func setLoading(_ isLoading: Bool) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + if isLoading { + self.loadingIndicator.startAnimating() + self.collectionView.alpha = 0.5 + } else { + self.loadingIndicator.stopAnimating() + self.collectionView.alpha = 1.0 + } + } + } + + func showErrorView(_ isError: Bool) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + self.networkErrorView.isHidden = !isError + + self.collectionView.isHidden = isError + if isError { + self.loadingIndicator.stopAnimating() + } + } + } +} diff --git a/Projects/Features/PopularTravelFeature/Sources/Views/CollectionView/CompositionalLayout/PopularTravelCompositionalLayout.swift b/Projects/Features/PopularTravelFeature/Sources/Views/CollectionView/CompositionalLayout/PopularTravelCompositionalLayout.swift new file mode 100644 index 0000000..265b55d --- /dev/null +++ b/Projects/Features/PopularTravelFeature/Sources/Views/CollectionView/CompositionalLayout/PopularTravelCompositionalLayout.swift @@ -0,0 +1,104 @@ +// +// PopularTravelCompositionalLayout.swift +// PopularTravelFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/12/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +extension PopularTravelViewController { + func createLayout() -> UICollectionViewCompositionalLayout { + return UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in + guard let sectionKind = PopularTravelSectionKind(rawValue: sectionIndex) else { + return self?.emptyLayout() + } + + switch sectionKind { + case .category: + return self?.createCategorySection() + case .popularTrip: + return self?.createPopularTripSection() + } + } + } +} + +private extension PopularTravelViewController { + func createCategorySection() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .estimated(72), + heightDimension: .absolute(CategoryChipCell.defaultHeight) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .estimated(72), + heightDimension: .absolute(CategoryChipCell.defaultHeight) + ) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + + let section = NSCollectionLayoutSection(group: group) + section.orthogonalScrollingBehavior = .continuous + section.interGroupSpacing = 8.adjusted + section.contentInsets = .init( + top: 20.adjustedH, + leading: 24.adjusted, + bottom: 32.adjustedH, + trailing: 24.adjusted + ) + return section + } + + func createPopularTripSection() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(PopularInfoCell.defaultHeight) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(PopularInfoCell.defaultHeight) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, + subitems: [item] + ) + + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = 16.adjustedH + + section.contentInsets = .init( + top: 0, + leading: 24.adjusted, + bottom: 12.adjustedH, + trailing: 24.adjusted + ) + section.orthogonalScrollingBehavior = .none + + return section + } + + + func emptyLayout() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .fractionalHeight(1.0) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .fractionalHeight(1.0) + ) + let group = NSCollectionLayoutGroup(layoutSize: groupSize) + + let section = NSCollectionLayoutSection(group: group) + + return section + } +} diff --git a/Projects/Features/PopularTravelFeature/Sources/Views/CollectionView/Item/PopularTravelItem.swift b/Projects/Features/PopularTravelFeature/Sources/Views/CollectionView/Item/PopularTravelItem.swift new file mode 100644 index 0000000..ba7156d --- /dev/null +++ b/Projects/Features/PopularTravelFeature/Sources/Views/CollectionView/Item/PopularTravelItem.swift @@ -0,0 +1,14 @@ +// +// PopularTravelItem.swift +// PopularTravelFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/12/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +enum PopularTravelItem: Hashable { + case category(PopularTravelPresentationModel.Category, isSelected: Bool) + case popularTrip(PopularTravelPresentationModel.PopularTrip) +} diff --git a/Projects/Features/PopularTravelFeature/Sources/Views/CollectionView/Registration/PopularTravelRegistration.swift b/Projects/Features/PopularTravelFeature/Sources/Views/CollectionView/Registration/PopularTravelRegistration.swift new file mode 100644 index 0000000..28104c8 --- /dev/null +++ b/Projects/Features/PopularTravelFeature/Sources/Views/CollectionView/Registration/PopularTravelRegistration.swift @@ -0,0 +1,41 @@ +// +// PopularTravelRegistration.swift +// PopularTravelFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/12/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +extension PopularTravelViewController { + func createCategoryCellRegistration() -> UICollectionView.CellRegistration { + return UICollectionView.CellRegistration { cell, indexPath, item in + let (chip, isSelected) = item + + let chipType: ChipIconType = { + switch chip.viedoType { + case .tv: ChipIconType.tv + case .youtube: ChipIconType.youtube + case .none: ChipIconType.none + } + }() + + cell.configure(chipType, chip.creator, isSelected) + } + } + + func createPopularTripCellRegistration() -> UICollectionView.CellRegistration { + return UICollectionView.CellRegistration { cell, indexPath, item in + cell.configure( + thumbnailUrl: item.thumbnailUrl, + city: item.city, + title: item.title, + nation: item.country, + schedule: item.schedule + ) + } + } +} diff --git a/Projects/Features/PopularTravelFeature/Sources/Views/CollectionView/SectionKind/PopularTravelSectionKind.swift b/Projects/Features/PopularTravelFeature/Sources/Views/CollectionView/SectionKind/PopularTravelSectionKind.swift new file mode 100644 index 0000000..fc94819 --- /dev/null +++ b/Projects/Features/PopularTravelFeature/Sources/Views/CollectionView/SectionKind/PopularTravelSectionKind.swift @@ -0,0 +1,14 @@ +// +// PopularTravelSectionKind.swift +// PopularTravelFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/12/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +enum PopularTravelSectionKind: Int, CaseIterable { + case category + case popularTrip +} diff --git a/Projects/Features/RootFeature/Project.swift b/Projects/Features/RootFeature/Project.swift index aec99fa..d47107c 100644 --- a/Projects/Features/RootFeature/Project.swift +++ b/Projects/Features/RootFeature/Project.swift @@ -15,7 +15,11 @@ let project = Project.makeModule( .makeFrameworkTarget( name: "RootFeature", dependencies: [ - .Features.TabBar.feature + .Features.Main.feature, + + // TODO: - ์ง€์›Œ์•ผ๋จ + .Modules.networks, + .data ], scripts: [.swiftLint], isStatic: true, diff --git a/Projects/Features/RootFeature/Sources/RootBuilder.swift b/Projects/Features/RootFeature/Sources/RootBuilder.swift index 1bdfba0..4246664 100644 --- a/Projects/Features/RootFeature/Sources/RootBuilder.swift +++ b/Projects/Features/RootFeature/Sources/RootBuilder.swift @@ -7,21 +7,27 @@ // import Domain +import MainFeature + import RIBs -import TabBarFeature // MARK: - RootDependency public protocol RootDependency: Dependency { var tokenProvider: TokenProviding { get } + var homeUsecase: HomeUsecaseProtocol { get } } // MARK: - RootComponent -final class RootComponent: Component, TabBarDependency { +final class RootComponent: Component, MainDependency { var tokenProvider: TokenProviding { dependency.tokenProvider } + + var homeUsecase: HomeUsecaseProtocol { + dependency.homeUsecase + } } // MARK: - RootBuildable @@ -34,7 +40,7 @@ public protocol RootBuildable: Buildable { public final class RootBuilder: Builder, RootBuildable { - public override init(dependency: RootDependency) { + override public init(dependency: RootDependency) { super.init(dependency: dependency) } @@ -43,12 +49,12 @@ public final class RootBuilder: Builder, RootBuildable { let viewController = RootViewController() let interactor = RootInteractor(presenter: viewController) - let tabBarBuilder = TabBarBuilder(dependency: component) + let mainBuilder = MainBuilder(dependency: component) let router = RootRouter( interactor: interactor, viewController: viewController, - tabBarBuilder: tabBarBuilder + mainBuilder: mainBuilder ) return router diff --git a/Projects/Features/RootFeature/Sources/RootInteractor.swift b/Projects/Features/RootFeature/Sources/RootInteractor.swift index 7347e0d..94d061e 100644 --- a/Projects/Features/RootFeature/Sources/RootInteractor.swift +++ b/Projects/Features/RootFeature/Sources/RootInteractor.swift @@ -9,10 +9,11 @@ import RIBs import RxSwift -// MARK: - RootListener +// MARK: - RootRouting -public protocol RootListener: AnyObject { - // Root๋Š” ์ตœ์ƒ์œ„ RIB์ด๋ฏ€๋กœ Listener๊ฐ€ ์—†์Œ +public protocol RootRouting: ViewableRouting { + func attachMain() + func detachMain() } // MARK: - RootPresentable @@ -21,6 +22,12 @@ protocol RootPresentable: Presentable { var listener: RootPresentableListener? { get set } } +// MARK: - RootListener + +public protocol RootListener: AnyObject { + // Root๋Š” ์ตœ์ƒ์œ„ RIB์ด๋ฏ€๋กœ Listener๊ฐ€ ์—†์Œ +} + // MARK: - RootInteractor final class RootInteractor: PresentableInteractor, RootInteractable { diff --git a/Projects/Features/RootFeature/Sources/RootRouter.swift b/Projects/Features/RootFeature/Sources/RootRouter.swift index a6efb28..d1dca91 100644 --- a/Projects/Features/RootFeature/Sources/RootRouter.swift +++ b/Projects/Features/RootFeature/Sources/RootRouter.swift @@ -8,11 +8,11 @@ import RIBs -import TabBarFeature +import MainFeature // MARK: - RootInteractable -protocol RootInteractable: Interactable, TabBarListener { +protocol RootInteractable: Interactable, MainListener { var router: RootRouting? { get set } var listener: RootListener? { get set } } @@ -25,50 +25,42 @@ public protocol RootViewControllable: ViewControllable { func setRootViewController(_ viewController: ViewControllable) } -// MARK: - RootRouting - -public protocol RootRouting: ViewableRouting { - func attachTabBar() - func detachTabBar() -} - // MARK: - RootRouter final class RootRouter: LaunchRouter, RootRouting { - - private let tabBarBuilder: TabBarBuildable - private var tabBarRouter: TabBarRouting? + private let mainBuilder: MainBuildable + private var mainRouter: MainRouting? init( interactor: RootInteractable, viewController: RootViewControllable, - tabBarBuilder: TabBarBuildable + mainBuilder: MainBuildable ) { - self.tabBarBuilder = tabBarBuilder + self.mainBuilder = mainBuilder super.init(interactor: interactor, viewController: viewController) interactor.router = self } override func didLoad() { super.didLoad() - attachTabBar() + attachMain() } // MARK: - RootRouting - func attachTabBar() { - guard tabBarRouter == nil else { return } + func attachMain() { + guard mainRouter == nil else { return } - let router = tabBarBuilder.build(withListener: interactor) - tabBarRouter = router + let router = mainBuilder.build(withListener: interactor) + mainRouter = router attachChild(router) viewController.setRootViewController(router.viewControllable) } - func detachTabBar() { - guard let router = tabBarRouter else { return } + func detachMain() { + guard let router = mainRouter else { return } detachChild(router) - tabBarRouter = nil + mainRouter = nil } } diff --git a/Projects/Features/SearchFeature/Project.swift b/Projects/Features/SearchFeature/Project.swift new file mode 100644 index 0000000..a515219 --- /dev/null +++ b/Projects/Features/SearchFeature/Project.swift @@ -0,0 +1,25 @@ +// +// Project.swift +// ProjectDescriptionHelpers +// +// Created by แ„Žแ…ฌแ„‹แ…กแ†ซแ„‹แ…ญแ†ผ on 2026/02/07. +// + +import ProjectDescription +import ProjectDescriptionHelpers +import DependencyPlugin + +let project = Project.makeModule( + name: "SearchFeature", + targets: [ + .makeFrameworkTarget( + name: "SearchFeature", + dependencies: [ + .Features.baseFeatureDependency + ], + scripts: [.swiftLint], + isStatic: true, + hasResources: false + ) + ] +) diff --git a/Projects/Features/SearchFeature/Sources/SearchBuilder.swift b/Projects/Features/SearchFeature/Sources/SearchBuilder.swift new file mode 100644 index 0000000..b91ca9e --- /dev/null +++ b/Projects/Features/SearchFeature/Sources/SearchBuilder.swift @@ -0,0 +1,37 @@ +// +// SearchBuilder.swift +// SearchFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/7/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import RIBs + +public protocol SearchDependency: Dependency { + +} + +final class SearchComponent: Component { + +} + +public protocol SearchBuildable: Buildable { + func build(withListener listener: SearchListener) -> SearchRouting +} + +public final class SearchBuilder: Builder, SearchBuildable { + + override public init(dependency: SearchDependency) { + super.init(dependency: dependency) + } + + public func build(withListener listener: SearchListener) -> SearchRouting { + let component = SearchComponent(dependency: dependency) + let viewController = SearchViewController() + let interactor = SearchInteractor(presenter: viewController) + interactor.listener = listener + + return SearchRouter(interactor: interactor, viewController: viewController) + } +} diff --git a/Projects/Features/SearchFeature/Sources/SearchInteractor.swift b/Projects/Features/SearchFeature/Sources/SearchInteractor.swift new file mode 100644 index 0000000..2920ae4 --- /dev/null +++ b/Projects/Features/SearchFeature/Sources/SearchInteractor.swift @@ -0,0 +1,50 @@ +// +// SearchInteractor.swift +// SearchFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/7/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import RIBs +import RxSwift + +public protocol SearchRouting: ViewableRouting { + +} + +protocol SearchPresentable: Presentable { + var listener: SearchPresentableListener? { get set } + // TODO: Declare methods the interactor can invoke the presenter to present data. +} + +public protocol SearchListener: AnyObject { + func detachSearch() +} + +final class SearchInteractor: PresentableInteractor, SearchInteractable, SearchPresentableListener { + + weak var router: SearchRouting? + weak var listener: SearchListener? + + // TODO: Add additional dependencies to constructor. Do not perform any logic + // in constructor. + override init(presenter: SearchPresentable) { + super.init(presenter: presenter) + presenter.listener = self + } + + override func didBecomeActive() { + super.didBecomeActive() + // TODO: Implement business logic here. + } + + override func willResignActive() { + super.willResignActive() + // TODO: Pause any business logic. + } + + func detachSearch() { + listener?.detachSearch() + } +} diff --git a/Projects/Features/SearchFeature/Sources/SearchRouter.swift b/Projects/Features/SearchFeature/Sources/SearchRouter.swift new file mode 100644 index 0000000..f6860c5 --- /dev/null +++ b/Projects/Features/SearchFeature/Sources/SearchRouter.swift @@ -0,0 +1,27 @@ +// +// SearchRouter.swift +// SearchFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/7/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import RIBs + +protocol SearchInteractable: Interactable { + var router: SearchRouting? { get set } + var listener: SearchListener? { get set } +} + +protocol SearchViewControllable: ViewControllable { + +} + +final class SearchRouter: ViewableRouter, SearchRouting { + + // TODO: Constructor inject child builder protocols to allow building children. + override init(interactor: SearchInteractable, viewController: SearchViewControllable) { + super.init(interactor: interactor, viewController: viewController) + interactor.router = self + } +} diff --git a/Projects/Features/SearchFeature/Sources/SearchViewController.swift b/Projects/Features/SearchFeature/Sources/SearchViewController.swift new file mode 100644 index 0000000..d57d0b0 --- /dev/null +++ b/Projects/Features/SearchFeature/Sources/SearchViewController.swift @@ -0,0 +1,136 @@ +// +// SearchViewController.swift +// SearchFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/7/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +import RIBs +import RxSwift + +protocol SearchPresentableListener: AnyObject { + func detachSearch() +} + +final class SearchViewController: UIViewController, SearchPresentable, SearchViewControllable{ + weak var listener: SearchPresentableListener? + + private let searchBar = NDGLSearchBar( + placeholder: "๊ฒ€์ƒ‰์–ด๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”", + DSKitAsset.Assets.icChevronLeft3.image, + DSKitAsset.Assets.icSearch2.image + ) + private let emptyImageView = UIImageView() + private let titleLabel = UILabel() + private let containerView = UIView() + + private let disposeBag = DisposeBag() + + override func viewDidLoad() { + super.viewDidLoad() + + setStyle() + setUI() + setLayout() + bindKeyboard() + setupActions() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + if isMovingFromParent { + listener?.detachSearch() + } + } +} + +private extension SearchViewController { + func setStyle() { + emptyImageView.do { + $0.image = DSKitAsset.Assets.icEmptySearch.image + $0.contentMode = .scaleAspectFit + } + + titleLabel.do { + $0.setText( + .bodyLM, + text: "์ข‹์•„ํ•˜๋Š” ์œ ํŠœ๋ฒ„๋‚˜ ๊ฐ€๊ณ  ์‹ถ์€\n์—ฌํ–‰์ง€๋ฅผ ๊ฒ€์ƒ‰ํ•ด๋ด์š”", + color: DSKitAsset.Colors.black400.color, + alignment: .center + ) + $0.numberOfLines = 2 + } + } + + func setUI() { + view.addSubviews(searchBar, containerView) + containerView.addSubviews(emptyImageView, titleLabel) + } + + func setLayout() { + searchBar.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide) + $0.directionalHorizontalEdges.equalToSuperview() + } + + containerView.snp.makeConstraints { + $0.top.equalTo(searchBar.snp.bottom) + $0.directionalHorizontalEdges.equalToSuperview() + $0.bottom.equalToSuperview() + } + + emptyImageView.snp.makeConstraints { + $0.width.equalTo(215.adjusted) + $0.height.equalTo(198.adjustedH) + $0.center.equalToSuperview() + } + + titleLabel.snp.makeConstraints { + $0.top.equalTo(emptyImageView.snp.bottom).offset(16) + $0.centerX.equalToSuperview() + } + } + + func bindKeyboard() { + NotificationCenter.default.rx.notification(UIResponder.keyboardWillShowNotification) + .subscribe(onNext: { [weak self] notification in + guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { return } + let keyboardHeight = keyboardFrame.cgRectValue.height + + self?.containerView.snp.updateConstraints { + $0.bottom.equalToSuperview().inset(keyboardHeight) + } + + UIView.animate(withDuration: 0.3) { + self?.view.layoutIfNeeded() + } + }) + .disposed(by: disposeBag) + + NotificationCenter.default.rx.notification(UIResponder.keyboardWillHideNotification) + .subscribe(onNext: { [weak self] _ in + self?.containerView.snp.updateConstraints { + $0.bottom.equalToSuperview() + } + + UIView.animate(withDuration: 0.3) { + self?.view.layoutIfNeeded() + } + }) + .disposed(by: disposeBag) + } + + func setupActions() { + searchBar.leadingButtonDidTap + .subscribe(with: self) { owner, _ in + owner.navigationController?.popViewController(animated: true) + } + .disposed(by: disposeBag) + } +} diff --git a/Projects/Features/SettingFeature/Project.swift b/Projects/Features/SettingFeature/Project.swift new file mode 100644 index 0000000..88d3e63 --- /dev/null +++ b/Projects/Features/SettingFeature/Project.swift @@ -0,0 +1,25 @@ +// +// Project.swift +// ProjectDescriptionHelpers +// +// Created by แ„Žแ…ฌแ„‹แ…กแ†ซแ„‹แ…ญแ†ผ on 2026/02/09. +// + +import ProjectDescription +import ProjectDescriptionHelpers +import DependencyPlugin + +let project = Project.makeModule( + name: "SettingFeature", + targets: [ + .makeFrameworkTarget( + name: "SettingFeature", + dependencies: [ + .Features.baseFeatureDependency + ], + scripts: [.swiftLint], + isStatic: true, + hasResources: false + ) + ] +) diff --git a/Projects/Features/SettingFeature/Sources/SettingBuilder.swift b/Projects/Features/SettingFeature/Sources/SettingBuilder.swift new file mode 100644 index 0000000..98e4360 --- /dev/null +++ b/Projects/Features/SettingFeature/Sources/SettingBuilder.swift @@ -0,0 +1,36 @@ +// +// SettingBuilder.swift +// SettingFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/9/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import RIBs + +public protocol SettingDependency: Dependency { + +} + +final class SettingComponent: Component { + +} + +public protocol SettingBuildable: Buildable { + func build(withListener listener: SettingListener) -> SettingRouting +} + +public final class SettingBuilder: Builder, SettingBuildable { + + override public init(dependency: SettingDependency) { + super.init(dependency: dependency) + } + + public func build(withListener listener: SettingListener) -> SettingRouting { + let component = SettingComponent(dependency: dependency) + let viewController = SettingViewController() + let interactor = SettingInteractor(presenter: viewController) + interactor.listener = listener + return SettingRouter(interactor: interactor, viewController: viewController) + } +} diff --git a/Projects/Features/SettingFeature/Sources/SettingInteractor.swift b/Projects/Features/SettingFeature/Sources/SettingInteractor.swift new file mode 100644 index 0000000..5a9386a --- /dev/null +++ b/Projects/Features/SettingFeature/Sources/SettingInteractor.swift @@ -0,0 +1,64 @@ +// +// SettingInteractor.swift +// SettingFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/9/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import RIBs +import RxSwift + +public protocol SettingRouting: ViewableRouting { + +} + +public protocol SettingPresentable: Presentable { + var listener: SettingPresentableListener? { get set } +} + +public protocol SettingListener: AnyObject { + func detachSetting() +} + +final class SettingInteractor: PresentableInteractor, SettingInteractable, SettingPresentableListener { + weak var router: SettingRouting? + weak var listener: SettingListener? + + // TODO: Add additional dependencies to constructor. Do not perform any logic + // in constructor. + override init(presenter: SettingPresentable) { + super.init(presenter: presenter) + presenter.listener = self + } + + override func didBecomeActive() { + super.didBecomeActive() + } + + override func willResignActive() { + super.willResignActive() + } + + func detachSetting() { + // ๋ถ€๋ชจ RIB์—๊ฒŒ ์ด ํ™”๋ฉด์„ ๋‹ซ์•„๋‹ฌ๋ผ๊ณ  ์•Œ๋ฆผ + listener?.detachSetting() + } + + func didTapMenu(item: SettingCellItem) { + // ๊ฐ ๋ฉ”๋‰ด ํƒ€์ดํ‹€์— ๋”ฐ๋ฅธ ๋™์ž‘ ์ฒ˜๋ฆฌ + switch item { + case .notification: + print("์•Œ๋ฆผ") + case .faq: + print("FAQ") + case .recommendLink: + print("์ถ”์ฒœ ๋งํฌ") + case .identificationCode: + print("๋‚ด ์‹๋ณ„์ฝ”๋“œ") + case .terms: + print("์„œ๋น„์Šค ์•ฝ๊ด€") + default: break + } + } +} diff --git a/Projects/Features/SettingFeature/Sources/SettingRouter.swift b/Projects/Features/SettingFeature/Sources/SettingRouter.swift new file mode 100644 index 0000000..49a0c7a --- /dev/null +++ b/Projects/Features/SettingFeature/Sources/SettingRouter.swift @@ -0,0 +1,26 @@ +// +// SettingRouter.swift +// SettingFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/9/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import RIBs + +protocol SettingInteractable: Interactable { + var router: SettingRouting? { get set } + var listener: SettingListener? { get set } +} + +protocol SettingViewControllable: ViewControllable { + +} + +final class SettingRouter: ViewableRouter, SettingRouting { + + override init(interactor: SettingInteractable, viewController: SettingViewControllable) { + super.init(interactor: interactor, viewController: viewController) + interactor.router = self + } +} diff --git a/Projects/Features/SettingFeature/Sources/SettingViewController.swift b/Projects/Features/SettingFeature/Sources/SettingViewController.swift new file mode 100644 index 0000000..a644eeb --- /dev/null +++ b/Projects/Features/SettingFeature/Sources/SettingViewController.swift @@ -0,0 +1,140 @@ +// +// SettingViewController.swift +// SettingFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/9/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +import RIBs +import RxSwift + +public protocol SettingPresentableListener: AnyObject { + func detachSetting() + func didTapMenu(item: SettingCellItem) +} + +final class SettingViewController: UIViewController, SettingPresentable, SettingViewControllable { + weak var listener: SettingPresentableListener? + + private let navigationBar = NDGLNavigationBar( + title: "์„ค์ •", + leadingIcon: DSKitAsset.Assets.icChevronLeft3.image + ) + private let tableView = UITableView() + + private let disposeBag = DisposeBag() + + override func viewDidLoad() { + super.viewDidLoad() + + setStyle() + setUI() + setLayout() + setDelegate() + setupActions() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + if isMovingFromParent { + listener?.detachSetting() + } + } +} + +private extension SettingViewController { + func setStyle() { + view.backgroundColor = DSKitAsset.Colors.white.color + + tableView.do { + $0.isScrollEnabled = false + $0.backgroundColor = .clear + $0.separatorStyle = .singleLine + $0.separatorColor = DSKitAsset.Colors.black50.color + } + } + + func setUI() { + view.addSubviews(navigationBar, tableView) + } + + func setLayout() { + navigationBar.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide) + $0.leading.trailing.equalToSuperview() + } + + tableView.snp.makeConstraints { + $0.top.equalTo(navigationBar.snp.bottom).offset(24.adjustedH) + $0.leading.trailing.bottom.equalToSuperview() + } + } + + func setDelegate() { + tableView.delegate = self + tableView.dataSource = self + + tableView.register(SettingMenuCell.self, forCellReuseIdentifier: SettingMenuCell.cellIdentifier) + } + + func setupActions() { + navigationBar.leadingButtonDidTap + .subscribe(with: self) { owner, _ in + owner.navigationController?.popViewController(animated: true) + } + .disposed(by: disposeBag) + } +} + +extension SettingViewController: UITableViewDataSource, UITableViewDelegate { + func numberOfSections(in tableView: UITableView) -> Int { + SettingSection.allCases.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + SettingSection.allCases[section].items.count + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + let menu = SettingSection.allCases[indexPath.section].items[indexPath.row] + return menu.cellType.cellHeight + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: SettingMenuCell.cellIdentifier, + for: indexPath + ) as? SettingMenuCell else { return UITableViewCell() } + + let menu = SettingSection.allCases[indexPath.section].items[indexPath.row] + let type = menu.cellType + + cell.configure(title: menu.title, type: type) + + if menu == .notification { + cell.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: tableView.bounds.width) + } else { + cell.separatorInset = UIEdgeInsets(top: 0, left: 25.adjusted, bottom: 0, right: 24.adjusted) + } + + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + let menu = SettingSection.allCases[indexPath.section].items[indexPath.row] + + switch menu.cellType { + case .toggle, .icon: + listener?.didTapMenu(item: menu) + default: + break + } + } +} diff --git a/Projects/Features/SettingFeature/Sources/UITableView/Cells/SettingMenuCell.swift b/Projects/Features/SettingFeature/Sources/UITableView/Cells/SettingMenuCell.swift new file mode 100644 index 0000000..b573be7 --- /dev/null +++ b/Projects/Features/SettingFeature/Sources/UITableView/Cells/SettingMenuCell.swift @@ -0,0 +1,109 @@ +// +// SettingMenuCell.swift +// SettingFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/10/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +final class SettingMenuCell: UITableViewCell { + private let titleLabel = UILabel() + private let chevronImageView = UIImageView() + private let toggleSwitch = UISwitch() + private let detailLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + setStyle() + setUI() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + contentView.frame = contentView.frame.inset( + by: .init( + top: 0, + left: 25.adjusted, + bottom: 0, + right: 24.adjusted + ) + ) + } + + override func prepareForReuse() { + super.prepareForReuse() + titleLabel.text = nil + detailLabel.text = nil + } + + func configure(title: String, type: SettingCellType) { + titleLabel.setText(.bodyLR, text: title, color: DSKitAsset.Colors.black700.color) + + [chevronImageView, toggleSwitch, detailLabel].forEach { $0.isHidden = true } + + switch type { + case .toggle(let isOn): + toggleSwitch.isHidden = false + toggleSwitch.isOn = isOn + self.selectionStyle = .none + + case .icon: + chevronImageView.isHidden = false + self.selectionStyle = .gray + + case .detailText(let text): + detailLabel.isHidden = false + detailLabel.setText(.bodyLR, text: text, color: DSKitAsset.Colors.black400.color) + self.selectionStyle = .none + } + } +} + +private extension SettingMenuCell { + func setStyle() { + self.backgroundColor = .clear + + chevronImageView.do { + $0.image = DSKitAsset.Assets.icChevronRight2.image + $0.contentMode = .scaleAspectFit + } + + toggleSwitch.do { + $0.onTintColor = DSKitAsset.Colors.green500.color + $0.thumbTintColor = DSKitAsset.Colors.white.color + } + } + + func setUI() { + contentView.addSubviews(titleLabel, chevronImageView, toggleSwitch, detailLabel) + } + + func setLayout() { + titleLabel.snp.makeConstraints { + $0.leading.equalToSuperview().inset(8.adjusted) + $0.centerY.equalToSuperview() + } + + chevronImageView.snp.makeConstraints { + $0.size.equalTo(24.adjustedH) + } + + [chevronImageView, toggleSwitch, detailLabel].forEach { view in + view.snp.makeConstraints { + $0.trailing.equalToSuperview().inset(8.adjusted) + $0.centerY.equalToSuperview() + } + } + } +} diff --git a/Projects/Features/SettingFeature/Sources/UITableView/SettingCellItem.swift b/Projects/Features/SettingFeature/Sources/UITableView/SettingCellItem.swift new file mode 100644 index 0000000..0d83393 --- /dev/null +++ b/Projects/Features/SettingFeature/Sources/UITableView/SettingCellItem.swift @@ -0,0 +1,41 @@ +// +// SettingCellItem.swift +// SettingFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/11/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +public enum SettingCellItem: Int, CaseIterable { + case notification + case faq + case recommendLink + case identificationCode + case terms + case version + + var title: String { + switch self { + case .notification: return "์•Œ๋ฆผ ์„ค์ •" + case .faq: return "FAQ" + case .recommendLink: return "์ฝ˜ํ…์ธ  ์ถ”์ฒœ ๋งํฌ ๋„ฃ๊ธฐ" + case .identificationCode: return "๋‚ด ์‹๋ณ„์ฝ”๋“œ" + case .terms: return "์„œ๋น„์Šค ์•ฝ๊ด€" + case .version: return "๋ฒ„์ „ ์ •๋ณด" + } + } + + var cellType: SettingCellType { + switch self { + case .notification: + return .toggle(isOn: true) + case .version: + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" + return .detailText(text: version) + default: + return .icon + } + } +} diff --git a/Projects/Features/SettingFeature/Sources/UITableView/SettingCellType.swift b/Projects/Features/SettingFeature/Sources/UITableView/SettingCellType.swift new file mode 100644 index 0000000..3e9e0d4 --- /dev/null +++ b/Projects/Features/SettingFeature/Sources/UITableView/SettingCellType.swift @@ -0,0 +1,24 @@ +// +// SettingCellType.swift +// SettingFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/11/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +enum SettingCellType { + case toggle(isOn: Bool) + case icon + case detailText(text: String) + + var cellHeight: CGFloat { + switch self { + case .toggle: + return 63.adjustedH + case .icon, .detailText: + return 56.adjustedH + } + } +} diff --git a/Projects/Features/SettingFeature/Sources/UITableView/SettingSection.swift b/Projects/Features/SettingFeature/Sources/UITableView/SettingSection.swift new file mode 100644 index 0000000..3b360da --- /dev/null +++ b/Projects/Features/SettingFeature/Sources/UITableView/SettingSection.swift @@ -0,0 +1,19 @@ +// +// SettingSection.swift +// SettingFeature +// +// Created by ์ตœ์•ˆ์šฉ on 2/10/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +enum SettingSection: Int, CaseIterable { + case menu + + var items: [SettingCellItem] { + switch self { + case .menu: [.notification, .faq, .recommendLink, .terms, .version] + } + } +} diff --git a/Projects/Features/TabBarFeature/Project.swift b/Projects/Features/TabBarFeature/Project.swift index 7f7d9af..a453a34 100644 --- a/Projects/Features/TabBarFeature/Project.swift +++ b/Projects/Features/TabBarFeature/Project.swift @@ -16,7 +16,11 @@ let project = Project.makeModule( name: "TabBarFeature", dependencies: [ .Features.Home.feature, - .Features.Travel.feature + .Features.Travel.feature, + + // TODO: - ์ง€์›Œ์•ผ๋จ + .data, + .Modules.networks ], scripts: [.swiftLint], isStatic: true, diff --git a/Projects/Features/TabBarFeature/Sources/Components/NDGLTabItem.swift b/Projects/Features/TabBarFeature/Sources/Components/NDGLTabItem.swift index 091cb23..92cb9c9 100644 --- a/Projects/Features/TabBarFeature/Sources/Components/NDGLTabItem.swift +++ b/Projects/Features/TabBarFeature/Sources/Components/NDGLTabItem.swift @@ -8,6 +8,8 @@ import UIKit +import DSKit + final class NDGLTabItem: UIControl { private let containerStackView = UIStackView() private let iconView = UIImageView() @@ -34,7 +36,7 @@ final class NDGLTabItem: UIControl { func setup(title: String, image: UIImage) { iconView.image = image - titleLabel.setText(.bodyLM, text: title, color: UIColor(hexCode: "#2C2C2C")) + titleLabel.setText(.bodyLM, text: title, color: DSKitAsset.Colors.white.color) updateState(animation: false) } } @@ -88,8 +90,8 @@ private extension NDGLTabItem { self.titleLabel.alpha = self.isTabSelected ? 1 : 0 self.iconView.tintColor = self.isTabSelected - ? UIColor(hexCode: "#2C2C2C") - : UIColor(hexCode: "#2C2C2C") + ? DSKitAsset.Colors.white.color + : DSKitAsset.Colors.black600.color self.containerStackView.layoutIfNeeded() diff --git a/Projects/Features/TabBarFeature/Sources/TabBarBuilder.swift b/Projects/Features/TabBarFeature/Sources/TabBarBuilder.swift index 3ddf667..7a3cf85 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarBuilder.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarBuilder.swift @@ -15,6 +15,7 @@ import TravelFeature public protocol TabBarDependency: Dependency { var tokenProvider: TokenProviding { get } + var homeUsecase: HomeUsecaseProtocol { get } } // MARK: - TabBarComponent @@ -23,6 +24,10 @@ final class TabBarComponent: Component, HomeDependency, Travel var tokenProvider: TokenProviding { dependency.tokenProvider } + + var homeUsecase: HomeUsecaseProtocol { + dependency.homeUsecase + } } // MARK: - TabBarBuildable diff --git a/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift b/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift index e226fa0..e5fdc77 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift @@ -11,9 +11,10 @@ import HomeFeature import RIBs import RxSwift -// MARK: - TabBarListener +// MARK: - TabBarRouting -public protocol TabBarListener: AnyObject { +public protocol TabBarRouting: ViewableRouting { + func attachTabs() } // MARK: - TabBarPresentable @@ -24,6 +25,15 @@ protocol TabBarPresentable: Presentable { func switchToTab(at index: Int) } +// MARK: - TabBarListener + +public protocol TabBarListener: AnyObject { + func routeToFollow(with recommendationId: Int) + func routeToSetting() + func routeToSearch() + func routeToPopularTravel() +} + // MARK: - TabBarInteractor final class TabBarInteractor: PresentableInteractor, TabBarInteractable { @@ -55,6 +65,21 @@ extension TabBarInteractor: TabBarPresentableListener { // MARK: - HomeListener extension TabBarInteractor: HomeListener { + func homeDidTapPopularTravel() { + listener?.routeToPopularTravel() + } + + func homeDidTapFollowDetail(with recommendationId: Int) { + listener?.routeToFollow(with: recommendationId) + } + + func homeDidTapSearch() { + listener?.routeToSearch() + } + + func homeDidTapSetting() { + listener?.routeToSetting() + } func homeDidAddTrip(title: String, startDate: Date, endDate: Date) { presenter.switchToTab(at: 2) diff --git a/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift b/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift index 095779e..aec5c6d 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift @@ -24,12 +24,6 @@ public protocol TabBarViewControllable: ViewControllable { func setViewControllers(_ viewControllers: [ViewControllable]) } -// MARK: - TabBarRouting - -public protocol TabBarRouting: ViewableRouting { - func attachTabs() -} - // MARK: - TabBarRouter final class TabBarRouter: ViewableRouter, TabBarRouting { diff --git a/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift b/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift index 954ed28..2b79f2e 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift @@ -13,6 +13,8 @@ import RxSwift import SnapKit import Then +import DSKit + // MARK: - TabBarPresentableListener protocol TabBarPresentableListener: AnyObject { @@ -38,18 +40,15 @@ public final class TabBarViewController: UITabBarController, TabBarPresentable, // MARK: - Lifecycle - public override func viewDidLoad() { + override public func viewDidLoad() { super.viewDidLoad() + + self.tabBar.isHidden = true setupStyle() setupUI() setupConstraints() } - - public override func viewWillLayoutSubviews() { - super.viewWillLayoutSubviews() - tabBar.isHidden = true - } - + // MARK: - TabBarViewControllable public func setViewControllers(_ viewControllers: [ViewControllable]) { @@ -66,8 +65,6 @@ public final class TabBarViewController: UITabBarController, TabBarPresentable, let homeNav = UINavigationController(rootViewController: homeVC) let travelNav = UINavigationController(rootViewController: travelVC) - [infoNav, homeNav, travelNav].forEach { $0.delegate = self } - super.setViewControllers([infoNav, homeNav, travelNav], animated: false) setupTabItems() } @@ -75,31 +72,19 @@ public final class TabBarViewController: UITabBarController, TabBarPresentable, func switchToTab(at index: Int) { guard index < tabItems.count else { return } - viewControllers?.forEach { viewController in - if let navController = viewController as? UINavigationController { - navController.popToRootViewController(animated: false) - } - } - updateSelection(at: index) - - DispatchQueue.main.async { - self.customTabBarContainer.isHidden = false - self.customTabBarContainer.alpha = 1 - } } } // MARK: - Setup private extension TabBarViewController { - func setupStyle() { customTabBarContainer.do { if #available(iOS 26.0, *) { let glass = UIGlassEffect(style: .regular) glass.isInteractive = true - glass.tintColor = .white.withAlphaComponent(0.1) + glass.tintColor = DSKitAsset.Colors.white.color.withAlphaComponent(0.1) $0.effect = glass } else { $0.effect = UIBlurEffect(style: .extraLight) @@ -119,7 +104,7 @@ private extension TabBarViewController { if #available(iOS 26.0, *) { let glass = UIGlassEffect(style: .regular) glass.isInteractive = true - glass.tintColor = UIColor(hexCode: "#2C2C2C") + glass.tintColor = DSKitAsset.Colors.black900.color $0.effect = glass } else { $0.effect = UIBlurEffect(style: .dark) @@ -153,7 +138,7 @@ private extension TabBarViewController { $0.size.equalTo(56.adjusted) } } - + func setupTabItems() { tabItems.forEach { $0.removeFromSuperview() } tabItems.removeAll() @@ -206,38 +191,3 @@ private extension TabBarViewController { } } } - -// MARK: - UINavigationControllerDelegate - -extension TabBarViewController: UINavigationControllerDelegate { - - public func navigationController( - _ navigationController: UINavigationController, - willShow viewController: UIViewController, - animated: Bool - ) { - let shouldHideTabBar = navigationController.viewControllers.count > 1 - - guard animated else { - customTabBarContainer.isHidden = shouldHideTabBar - customTabBarContainer.alpha = shouldHideTabBar ? 0 : 1 - return - } - - if shouldHideTabBar { - UIView.animate(withDuration: 0.3) { - self.customTabBarContainer.alpha = 0 - } completion: { _ in - self.customTabBarContainer.isHidden = true - } - } else { - customTabBarContainer.isHidden = false - customTabBarContainer.alpha = 0 - customTabBarContainer.layoutIfNeeded() - - UIView.animate(withDuration: 0.3) { - self.customTabBarContainer.alpha = 1 - } - } - } -} diff --git a/Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift b/Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift index 272d553..58785d5 100644 --- a/Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift +++ b/Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift @@ -7,7 +7,6 @@ // import DSKit -import Kingfisher import SnapKit import Then import UIKit diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_empty_search.imageset/Contents.json b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_empty_search.imageset/Contents.json new file mode 100644 index 0000000..a3ad2a6 --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_empty_search.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "ic_empty_search.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "ic_empty_search@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_empty_search@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_empty_search.imageset/ic_empty_search.png b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_empty_search.imageset/ic_empty_search.png new file mode 100644 index 0000000..1400bbb Binary files /dev/null and b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_empty_search.imageset/ic_empty_search.png differ diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_empty_search.imageset/ic_empty_search@2x.png b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_empty_search.imageset/ic_empty_search@2x.png new file mode 100644 index 0000000..0164b29 Binary files /dev/null and b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_empty_search.imageset/ic_empty_search@2x.png differ diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_empty_search.imageset/ic_empty_search@3x.png b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_empty_search.imageset/ic_empty_search@3x.png new file mode 100644 index 0000000..81a1e3c Binary files /dev/null and b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_empty_search.imageset/ic_empty_search@3x.png differ diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_empty_trip.imageset/Contents.json b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_empty_trip.imageset/Contents.json new file mode 100644 index 0000000..ea63b28 --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_empty_trip.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "ic_empty_trip.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "ic_empty_trip@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_empty_trip@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_empty_trip.imageset/ic_empty_trip.png b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_empty_trip.imageset/ic_empty_trip.png new file mode 100644 index 0000000..307f1f2 Binary files /dev/null and b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_empty_trip.imageset/ic_empty_trip.png differ diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_empty_trip.imageset/ic_empty_trip@2x.png b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_empty_trip.imageset/ic_empty_trip@2x.png new file mode 100644 index 0000000..b5bd905 Binary files /dev/null and b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_empty_trip.imageset/ic_empty_trip@2x.png differ diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_empty_trip.imageset/ic_empty_trip@3x.png b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_empty_trip.imageset/ic_empty_trip@3x.png new file mode 100644 index 0000000..59bbddb Binary files /dev/null and b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_empty_trip.imageset/ic_empty_trip@3x.png differ diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_server_error.imageset/Contents.json b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_server_error.imageset/Contents.json new file mode 100644 index 0000000..08ca3e4 --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_server_error.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "ic_server_error.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "ic_server_error@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_server_error@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_server_error.imageset/ic_server_error.png b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_server_error.imageset/ic_server_error.png new file mode 100644 index 0000000..1be051a Binary files /dev/null and b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_server_error.imageset/ic_server_error.png differ diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_server_error.imageset/ic_server_error@2x.png b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_server_error.imageset/ic_server_error@2x.png new file mode 100644 index 0000000..a964c85 Binary files /dev/null and b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_server_error.imageset/ic_server_error@2x.png differ diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_server_error.imageset/ic_server_error@3x.png b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_server_error.imageset/ic_server_error@3x.png new file mode 100644 index 0000000..56ca071 Binary files /dev/null and b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_server_error.imageset/ic_server_error@3x.png differ diff --git a/Projects/Modules/DSKit/Sources/Component/BottomPlacedButton.swift b/Projects/Modules/DSKit/Sources/Component/BottomPlacedButton.swift index b10b5f1..85270e5 100644 --- a/Projects/Modules/DSKit/Sources/Component/BottomPlacedButton.swift +++ b/Projects/Modules/DSKit/Sources/Component/BottomPlacedButton.swift @@ -7,7 +7,6 @@ // import Core -import SnapKit import UIKit public final class BottomPlacedButton: UIButton { diff --git a/Projects/Modules/DSKit/Sources/Component/BottomSheetViewController.swift b/Projects/Modules/DSKit/Sources/Component/BottomSheetViewController.swift index 9aa566b..4f45026 100644 --- a/Projects/Modules/DSKit/Sources/Component/BottomSheetViewController.swift +++ b/Projects/Modules/DSKit/Sources/Component/BottomSheetViewController.swift @@ -6,7 +6,6 @@ // Copyright ยฉ 2026 NDGL-iOS. All rights reserved. // -import SnapKit import UIKit // MARK: - BottomSheetConfiguration diff --git a/Projects/Modules/DSKit/Sources/Component/CategoryChipCell.swift b/Projects/Modules/DSKit/Sources/Component/CategoryChipCell.swift new file mode 100644 index 0000000..b135142 --- /dev/null +++ b/Projects/Modules/DSKit/Sources/Component/CategoryChipCell.swift @@ -0,0 +1,128 @@ +// +// CategoryChipCell.swift +// DSKit +// +// Created by ์ตœ์•ˆ์šฉ on 1/30/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +public final class CategoryChipCell: UICollectionViewCell { + public static let defaultHeight = 30.adjustedH + + private let iconView = UIImageView() + private let titleLabel = UILabel() + private let stackView = UIStackView() + + override public init(frame: CGRect) { + super.init(frame: frame) + + setStyle() + setUI() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func prepareForReuse() { + super.prepareForReuse() + + iconView.image = nil + titleLabel.text = nil + } + + public func configure(_ icon: ChipIconType, _ title: String, _ isSelected: Bool = false) { + let contentColor = isSelected ? DSKitAsset.Colors.white.color : DSKitAsset.Colors.black400.color + iconView.do { + $0.image = icon.image + $0.tintColor = contentColor + $0.isHidden = icon == .none + } + + titleLabel.setText( + .bodyMM, + text: title, + color: contentColor, + alignment: .center + ) + + contentView.layer.borderWidth = isSelected ? 0.0 : 1.0 + contentView.backgroundColor = isSelected ? DSKitAsset.Colors.black900.color : DSKitAsset.Colors.white.color + + stackView.snp.updateConstraints { + $0.directionalHorizontalEdges.equalToSuperview().inset(icon.horizontalPadding) + } + } +} + +private extension CategoryChipCell { + func setStyle() { + contentView.do { + $0.layer.cornerRadius = 15.adjustedH + $0.layer.borderWidth = 1.0 + $0.layer.borderColor = DSKitAsset.Colors.black200.color.cgColor + $0.clipsToBounds = true + } + + iconView.do { + $0.contentMode = .scaleAspectFit + } + + titleLabel.do { + $0.numberOfLines = 1 + } + + stackView.do { + $0.axis = .horizontal + $0.spacing = 4 + $0.alignment = .center + $0.isUserInteractionEnabled = false + } + } + + func setUI() { + stackView.addArrangedSubviews(iconView, titleLabel) + contentView.addSubview(stackView) + } + + func setLayout() { + stackView.snp.makeConstraints { + $0.height.equalTo(20.adjustedH) + $0.centerY.equalToSuperview() + $0.directionalHorizontalEdges.equalToSuperview().inset(14.adjusted) + } + + iconView.snp.makeConstraints { + $0.size.equalTo(20.adjustedH) + } + } +} + +public enum ChipIconType { + case none + case youtube + case tv + + fileprivate var image: UIImage? { + switch self { + case .none: + return nil + case .youtube: + return DSKitAsset.Assets.icVideo1.image.withRenderingMode(.alwaysTemplate) + case .tv: + return DSKitAsset.Assets.icTv1.image.withRenderingMode(.alwaysTemplate) + } + } + + fileprivate var horizontalPadding: CGFloat { + switch self { + case .none: + return 23.5 + case .youtube, .tv: + return 14.adjusted + } + } +} diff --git a/Projects/Modules/DSKit/Sources/Component/NDGLErrorView.swift b/Projects/Modules/DSKit/Sources/Component/NDGLErrorView.swift new file mode 100644 index 0000000..9213467 --- /dev/null +++ b/Projects/Modules/DSKit/Sources/Component/NDGLErrorView.swift @@ -0,0 +1,97 @@ +// +// NDGLErrorView.swift +// DSKit +// +// Created by ์ตœ์•ˆ์šฉ on 2/10/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import RxSwift + +public final class NDGLErrorView: UIView { + private let imageView = UIImageView() + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let button = NDGLBtn(title: "๋‹ค์‹œ ์‹œ๋„", style: .primary, size: .large) + private let titleStackView = UIStackView() + private let containerStackView = UIStackView() + + public var buttonDidTap: Observable { + button.rx.tap.asObservable() + } + + override public init(frame: CGRect) { + super.init(frame: frame) + + setStyle() + setUI() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private extension NDGLErrorView { + func setStyle() { + imageView.do { + $0.image = DSKitAsset.Assets.icServerError.image + $0.contentMode = .scaleAspectFit + } + + titleLabel.do { + $0.setText( + .titleMSB, + text: "์ •๋ณด๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์–ด์š”", + color: DSKitAsset.Colors.black700.color, + alignment: .center + ) + } + + subtitleLabel.do { + $0.setText( + .bodyLM, + text: "์ธํ„ฐ๋„ท ์—ฐ๊ฒฐ ํ™•์ธ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”", + color: DSKitAsset.Colors.black500.color, + alignment: .center + ) + } + + titleStackView.do { + $0.axis = .vertical + $0.spacing = 10.adjustedH + $0.alignment = .center + } + + containerStackView.do { + $0.axis = .vertical + $0.spacing = 16.adjustedH + $0.alignment = .center + } + } + + func setUI() { + titleStackView.addArrangedSubviews(titleLabel, subtitleLabel) + containerStackView.addArrangedSubviews(imageView, titleStackView) + addSubviews(containerStackView, button) + } + + func setLayout() { + imageView.snp.makeConstraints { + $0.size.equalTo(140.adjustedH) + } + + containerStackView.snp.makeConstraints { + $0.centerX.equalToSuperview() + $0.centerY.equalTo(button.snp.top).dividedBy(2) + } + + button.snp.makeConstraints { + $0.directionalHorizontalEdges.equalToSuperview().inset(24.adjusted) + $0.bottom.equalToSuperview().inset(16.adjustedH) + } + } +} diff --git a/Projects/Modules/DSKit/Sources/Component/NDGLNavigationBar.swift b/Projects/Modules/DSKit/Sources/Component/NDGLNavigationBar.swift index fea0873..b0a1f72 100644 --- a/Projects/Modules/DSKit/Sources/Component/NDGLNavigationBar.swift +++ b/Projects/Modules/DSKit/Sources/Component/NDGLNavigationBar.swift @@ -109,12 +109,12 @@ private extension NDGLNavigationBar { titleLabel.setText( .bodyLM, text: title, - color: UIColor(hexCode: "#2C2C2C"), + color: DSKitAsset.Colors.black900.color, alignment: .center ) } - let normalColor = UIColor(hexCode: "#383838") + let normalColor = DSKitAsset.Colors.black600.color [(leadingButton, leading), (trailingButton, trailing), (trailing2Button, trailing2)] .forEach { button, image in @@ -132,7 +132,7 @@ private extension NDGLNavigationBar { containerStackView.do { $0.axis = .horizontal - $0.spacing = 4 + $0.spacing = 4.adjusted $0.alignment = .center } } @@ -152,7 +152,7 @@ private extension NDGLNavigationBar { func setLayout() { [leadingButton, trailingButton, trailing2Button].forEach { - $0.snp.makeConstraints { $0.size.equalTo(40.adjustedH) } + $0.snp.makeConstraints { $0.size.equalTo(40.adjustedH).priority(.high) } } leftSpacer.snp.makeConstraints { @@ -168,12 +168,12 @@ private extension NDGLNavigationBar { } containerStackView.snp.makeConstraints { - $0.directionalHorizontalEdges.equalToSuperview().inset(24) - $0.directionalVerticalEdges.equalToSuperview().inset(4) + $0.directionalHorizontalEdges.equalToSuperview().inset(24.adjusted) + $0.directionalVerticalEdges.equalToSuperview().inset(4.adjustedH) } self.snp.makeConstraints { - $0.height.equalTo(48.adjustedH) + $0.height.greaterThanOrEqualTo(48.adjustedH) } } } @@ -188,9 +188,9 @@ public enum NDGLNavigationBarStyle { var backgroundColor: UIColor { switch self { case .white: - return UIColor(hexCode: "#FFFFFF") + return DSKitAsset.Colors.white.color case .gray: - return UIColor(hexCode: "#F5F5F5") + return DSKitAsset.Colors.black50.color } } } diff --git a/Projects/Modules/DSKit/Sources/Component/NDGLSearchBar.swift b/Projects/Modules/DSKit/Sources/Component/NDGLSearchBar.swift index 2cf865f..b0e2187 100644 --- a/Projects/Modules/DSKit/Sources/Component/NDGLSearchBar.swift +++ b/Projects/Modules/DSKit/Sources/Component/NDGLSearchBar.swift @@ -93,22 +93,22 @@ public final class NDGLSearchBar: UIView { private extension NDGLSearchBar { func setStyle(_ placeholder: String, _ leading: UIImage, _ trailing: UIImage) { searchContainerView.do { - $0.backgroundColor = UIColor(hexCode: "#E6E6E6") + $0.backgroundColor = DSKitAsset.Colors.black100.color $0.layer.cornerRadius = 22.adjustedH $0.clipsToBounds = true } textField.do { var placeHolderAttributes = UIFont.NDGL.bodyLR.attributes - placeHolderAttributes[.foregroundColor] = UIColor(hexCode: "#757575") + placeHolderAttributes[.foregroundColor] = DSKitAsset.Colors.black400.color $0.attributedPlaceholder = NSAttributedString( string: placeholder, attributes: placeHolderAttributes ) $0.font = UIFont.NDGL.bodyLR.font - $0.textColor = UIColor(hexCode: "#2C2C2C") - $0.tintColor = UIColor(hexCode: "#757575") + $0.textColor = DSKitAsset.Colors.black700.color + $0.tintColor = DSKitAsset.Colors.black400.color $0.autocapitalizationType = .none $0.autocorrectionType = .no @@ -116,7 +116,7 @@ private extension NDGLSearchBar { $0.returnKeyType = .search } - let normalColor = UIColor(hexCode: "#383838") + let normalColor = DSKitAsset.Colors.black600.color [(leadingButton, leading), (trailingButton, trailing)].forEach { button, image in var config = UIButton.Configuration.plain() config.image = image.resize(targetSize: 28.adjustedH).withRenderingMode(.alwaysTemplate) @@ -126,7 +126,7 @@ private extension NDGLSearchBar { containerStackView.do { $0.axis = .horizontal - $0.spacing = 8 + $0.spacing = 8.adjusted $0.alignment = .center } } @@ -139,12 +139,12 @@ private extension NDGLSearchBar { func setLayout() { self.snp.makeConstraints { - $0.height.equalTo(48.adjustedH) + $0.height.equalTo(48.adjustedH).priority(.high) } containerStackView.snp.makeConstraints { - $0.directionalHorizontalEdges.equalToSuperview().inset(24) - $0.directionalVerticalEdges.equalToSuperview().inset(2) + $0.directionalHorizontalEdges.equalToSuperview().inset(24.adjusted) + $0.directionalVerticalEdges.equalToSuperview().inset(2.adjusted) } leadingButton.snp.makeConstraints { @@ -152,18 +152,18 @@ private extension NDGLSearchBar { } searchContainerView.snp.makeConstraints { - $0.height.equalTo(44.adjustedH) + $0.height.greaterThanOrEqualTo(44.adjustedH) } textField.snp.makeConstraints { - $0.leading.equalToSuperview().inset(18) - $0.trailing.equalTo(trailingButton.snp.leading).offset(-2) + $0.leading.equalToSuperview().inset(18.adjusted) + $0.trailing.equalTo(trailingButton.snp.leading).offset(-2.adjusted) $0.centerY.equalToSuperview() } trailingButton.snp.makeConstraints { $0.size.equalTo(40.adjustedH) - $0.trailing.equalToSuperview().inset(16) + $0.trailing.equalToSuperview().inset(16.adjusted) $0.centerY.equalToSuperview() } } diff --git a/Projects/Modules/DSKit/Sources/Component/PopularInfoCell.swift b/Projects/Modules/DSKit/Sources/Component/PopularInfoCell.swift new file mode 100644 index 0000000..131ba9c --- /dev/null +++ b/Projects/Modules/DSKit/Sources/Component/PopularInfoCell.swift @@ -0,0 +1,138 @@ +// +// PopularInfoCell.swift +// DSKit +// +// Created by ์ตœ์•ˆ์šฉ on 1/30/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import Kingfisher + +public final class PopularInfoCell: UICollectionViewCell { + public static let defaultHeight = 88.adjustedH + + private let thumbnailView = UIImageView() + private let nationalFlagLabel = UILabel() + private let nationLabel = UILabel() + private let nationStackView = UIStackView() + private let titleLabel = UILabel() + private let cityLabel = UILabel() + private let dotLabel = UILabel() + private let scheduleLabel = UILabel() + private let infoStackView = UIStackView() + private let textContainerStackView = UIStackView() + + override public init(frame: CGRect) { + super.init(frame: .zero) + + setStyle() + setUI() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func prepareForReuse() { + super.prepareForReuse() + + thumbnailView.kf.cancelDownloadTask() + thumbnailView.image = nil + nationalFlagLabel.text = nil + cityLabel.text = nil + titleLabel.text = nil + nationLabel.text = nil + scheduleLabel.text = nil + } + + public func configure( + thumbnailUrl: String, + city: String, + title: String, + nation: String, + schedule: String + ) { + if let url = URL(string: thumbnailUrl) { + thumbnailView.kf.setImage(with: url, options: [.transition(.fade(0.3))]) + } + + nationalFlagLabel.text = nation.toFlag() + cityLabel.setText(.bodyMM, text: city, color: DSKitAsset.Colors.black400.color) + titleLabel.setText(.bodyLM, text: title, color: DSKitAsset.Colors.black800.color) + nationLabel.setText(.bodyMM, text: nation.toKoreanCountryName(), color: DSKitAsset.Colors.black400.color) + scheduleLabel.setText(.bodyMM, text: schedule, color: DSKitAsset.Colors.black400.color) + } +} + +private extension PopularInfoCell { + func setStyle() { + thumbnailView.do { + $0.layer.cornerRadius = 6 + $0.clipsToBounds = true + $0.contentMode = .scaleAspectFill + $0.backgroundColor = .systemGray6 + } + + nationalFlagLabel.do { + $0.font = .systemFont(ofSize: 13.5 * max(1.adjusted, 1.adjustedH)) + } + + nationStackView.do { + $0.axis = .horizontal + $0.spacing = 4.adjusted + } + + titleLabel.do { + $0.numberOfLines = 2 + } + + dotLabel.do { + $0.setText(.bodyMM, text: "โ€ข", color: DSKitAsset.Colors.black400.color) + } + + infoStackView.do { + $0.axis = .vertical + $0.spacing = 4 + $0.alignment = .leading + } + + textContainerStackView.do { + $0.axis = .horizontal + $0.spacing = 4 + $0.alignment = .center + } + + + } + + func setUI() { + nationStackView.addArrangedSubviews(nationalFlagLabel, nationLabel) + textContainerStackView.addArrangedSubviews(cityLabel, dotLabel, scheduleLabel) + infoStackView.addArrangedSubviews(titleLabel, textContainerStackView) + contentView.addSubviews(thumbnailView, nationStackView, infoStackView) + } + + func setLayout() { + thumbnailView.snp.makeConstraints { + $0.width.equalTo(140.adjusted) + $0.height.equalTo(thumbnailView.snp.width).multipliedBy(88.0 / 140.0) + $0.leading.top.equalToSuperview() + $0.bottom.lessThanOrEqualToSuperview() + } + + nationStackView.snp.makeConstraints { + $0.leading.equalTo(thumbnailView.snp.trailing).offset(12.adjusted) + $0.top.equalToSuperview() + } + + infoStackView.snp.makeConstraints { + $0.leading.equalTo(thumbnailView.snp.trailing).offset(12.adjusted) + $0.top.equalTo(nationalFlagLabel.snp.bottom).offset(10.adjustedH) + $0.trailing.lessThanOrEqualToSuperview() + $0.bottom.lessThanOrEqualToSuperview() + } + } +} diff --git a/Projects/Modules/DSKit/Sources/Extensions/UIKit+/UILabel+.swift b/Projects/Modules/DSKit/Sources/Extensions/UIKit+/UILabel+.swift index f679bc1..65007a1 100644 --- a/Projects/Modules/DSKit/Sources/Extensions/UIKit+/UILabel+.swift +++ b/Projects/Modules/DSKit/Sources/Extensions/UIKit+/UILabel+.swift @@ -36,7 +36,7 @@ public extension UILabel { if let paragraphStyle = attributes[.paragraphStyle] as? NSMutableParagraphStyle { paragraphStyle.alignment = alignment - paragraphStyle.lineBreakMode = self.lineBreakMode + paragraphStyle.lineBreakMode = .byTruncatingTail attributes[.paragraphStyle] = paragraphStyle } diff --git a/Projects/Modules/Networks/Project.swift b/Projects/Modules/Networks/Project.swift index aab2aec..ec8afd5 100644 --- a/Projects/Modules/Networks/Project.swift +++ b/Projects/Modules/Networks/Project.swift @@ -17,6 +17,8 @@ let project = Project.makeModule( name: "Networks", dependencies: [ .core, + + //TODO: - ์ง€์›Œ์•ผ๋จ .domain ], scripts: [.swiftLint], diff --git a/Projects/Modules/Networks/Sources/Base/BaseResponse.swift b/Projects/Modules/Networks/Sources/Base/BaseResponse.swift index 0f398d6..4495fb5 100644 --- a/Projects/Modules/Networks/Sources/Base/BaseResponse.swift +++ b/Projects/Modules/Networks/Sources/Base/BaseResponse.swift @@ -7,7 +7,7 @@ // import Foundation - + struct BaseResponse: Decodable, Sendable { let code: String let message: String diff --git a/Projects/Modules/Networks/Sources/Base/NetworkError.swift b/Projects/Modules/Networks/Sources/Base/NetworkError.swift index f1c1bb3..7e8b09c 100644 --- a/Projects/Modules/Networks/Sources/Base/NetworkError.swift +++ b/Projects/Modules/Networks/Sources/Base/NetworkError.swift @@ -11,7 +11,9 @@ import Foundation public enum NetworkError: Error, Sendable { case connectionFailed case decodingFailed + case noData case unknown(String) + case serverError(ErrorResponse) public var message: String { switch self { @@ -19,8 +21,12 @@ public enum NetworkError: Error, Sendable { return "๋„คํŠธ์›Œํฌ ์—ฐ๊ฒฐ์„ ํ™•์ธํ•ด์ฃผ์„ธ์š”" case .decodingFailed: return "๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค" + case .noData: + return "์‘๋‹ต ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค." case .unknown(let description): return "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜: \(description)" + case .serverError(let error): + return error.message ?? "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค." } } } diff --git a/Projects/Modules/Networks/Sources/DTO/Home/ProgramResponse.swift b/Projects/Modules/Networks/Sources/DTO/Home/ProgramResponse.swift new file mode 100644 index 0000000..a0d8818 --- /dev/null +++ b/Projects/Modules/Networks/Sources/DTO/Home/ProgramResponse.swift @@ -0,0 +1,15 @@ +// +// ProgramResponse.swift +// Networks +// +// Created by ์ตœ์•ˆ์šฉ on 2/9/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +public struct ProgramResponse: Decodable { + public let id: Int + public let name: String + public let type: String +} diff --git a/Projects/Modules/Networks/Sources/DTO/Home/TripResponse.swift b/Projects/Modules/Networks/Sources/DTO/Home/TripResponse.swift new file mode 100644 index 0000000..e5535df --- /dev/null +++ b/Projects/Modules/Networks/Sources/DTO/Home/TripResponse.swift @@ -0,0 +1,26 @@ +// +// TripResponse.swift +// Networks +// +// Created by ์ตœ์•ˆ์šฉ on 2/9/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +public struct TripResponse: Decodable { + public let content: [TripContentResponse] + public let hasNext: Bool +} + +public struct TripContentResponse: Decodable { + public let travelId: String + public let title: String + public let thumbnail: String? + public let programName: String + public let traveler: String + public let country: String + public let city: String + public let nights: Int + public let days: Int +} diff --git a/Projects/Modules/Networks/Sources/DTO/Home/UpcomingResponse.swift b/Projects/Modules/Networks/Sources/DTO/Home/UpcomingResponse.swift new file mode 100644 index 0000000..0287522 --- /dev/null +++ b/Projects/Modules/Networks/Sources/DTO/Home/UpcomingResponse.swift @@ -0,0 +1,38 @@ +// +// UpcomingResponse.swift +// Networks +// +// Created by ์ตœ์•ˆ์šฉ on 2/13/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +public struct UpcomingResponse: Decodable { + public let userTravelId: Int + public let title: String + public let country: String + public let city: String + public let startDate: String + public let endDate: String + public let nights: Int + public let days: Int + public let upcomingUserTravelPlace: UpcomingPlaceResponse +} + +public struct UpcomingPlaceResponse: Decodable { + public let id: Int + public let estimatedDuration: Int + public let place: UpcomingPlaceDetailResponse +} + +public struct UpcomingPlaceDetailResponse: Decodable { + public let googlePlaceId: String + public let thumbnail: String? + public let latitude: Double + public let longitude: Double + public let name: String + public let regularOpeningHours: String? + public let googleMapsUri: String + public let category: String +} diff --git a/Projects/Modules/Networks/Sources/Extensions/MoyaProvider+Async.swift b/Projects/Modules/Networks/Sources/Extensions/MoyaProvider+Async.swift index 089e459..d977a0c 100644 --- a/Projects/Modules/Networks/Sources/Extensions/MoyaProvider+Async.swift +++ b/Projects/Modules/Networks/Sources/Extensions/MoyaProvider+Async.swift @@ -107,7 +107,42 @@ extension MoyaProvider { } } } - + + func asyncThowsRequest(_ target: Target) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + NetworkLogger.logRequest(target) + + request(target) { result in + switch result { + case .success(let response): + NetworkLogger.logResponse(response) + + guard (200...299).contains(response.statusCode) else { + let error = (try? response.map(ErrorResponse.self)) + .map { NetworkError.serverError($0) } + ?? NetworkError.unknown("Status Code: \(response.statusCode)") + continuation.resume(throwing: error) + return + } + + do { + let baseResponse = try response.map(BaseResponse.self) + if let data = baseResponse.data { + continuation.resume(returning: data) + } else { + continuation.resume(throwing: NetworkError.noData) + } + } catch { + continuation.resume(throwing: NetworkError.decodingFailed) + } + case .failure(let error): + NetworkLogger.logError(error) + continuation.resume(throwing: NetworkError.unknown(error.localizedDescription)) + } + } + } + } + private static func mapMoyaError(_ error: MoyaError) -> NetworkError { switch error { case .underlying(let nsError as NSError, _) diff --git a/Projects/Modules/Networks/Sources/Foundation/NetworkConfiguration.swift b/Projects/Modules/Networks/Sources/Foundation/NetworkConfiguration.swift index 5b7d197..acc88b7 100644 --- a/Projects/Modules/Networks/Sources/Foundation/NetworkConfiguration.swift +++ b/Projects/Modules/Networks/Sources/Foundation/NetworkConfiguration.swift @@ -20,5 +20,3 @@ public enum NetworkConfiguration { return url } } - - diff --git a/Projects/Modules/Networks/Sources/Service/HomeService.swift b/Projects/Modules/Networks/Sources/Service/HomeService.swift new file mode 100644 index 0000000..711be42 --- /dev/null +++ b/Projects/Modules/Networks/Sources/Service/HomeService.swift @@ -0,0 +1,43 @@ +// +// HomeService.swift +// Networks +// +// Created by ์ตœ์•ˆ์šฉ on 2/4/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +import Moya + +// MARK: - API ๋‚˜์˜ค๊ธฐ ์ „ ์ž„์‹œ +public protocol HomeServiceProtocol { + func getUpcoming() async throws -> UpcomingResponse + func getCategoryList() async throws -> [ProgramResponse] + func getPopularTripList(id: Int?, page: Int?, size: Int?) async throws -> TripResponse + func getRecommendTripList(page: Int?, size: Int?) async throws -> TripResponse +} + +public final class HomeService: HomeServiceProtocol { + private let provider: MoyaProvider + + public init(provider: MoyaProvider = MoyaProvider()) { + self.provider = provider + } + + public func getUpcoming() async throws -> UpcomingResponse { + try await provider.asyncThowsRequest(.getUpcoming) + } + + public func getCategoryList() async throws -> [ProgramResponse] { + try await provider.asyncThowsRequest(.getCategoryList) + } + + public func getPopularTripList(id: Int?, page: Int?, size: Int?) async throws -> TripResponse { + try await provider.asyncThowsRequest(.getPopularTripList(id: id, page: page, size: size)) + } + + public func getRecommendTripList(page: Int?, size: Int?) async throws -> TripResponse { + try await provider.asyncThowsRequest(.getRecommendTripList(page: page, size: size)) + } +} diff --git a/Projects/Modules/Networks/Sources/TargetType/HomeAPI.swift b/Projects/Modules/Networks/Sources/TargetType/HomeAPI.swift new file mode 100644 index 0000000..7239c26 --- /dev/null +++ b/Projects/Modules/Networks/Sources/TargetType/HomeAPI.swift @@ -0,0 +1,73 @@ +// +// HomeAPI.swift +// Networks +// +// Created by ์ตœ์•ˆ์šฉ on 2/4/26. +// Copyright ยฉ 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +import Moya + +// MARK: - API ๋‚˜์˜ค๊ธฐ ์ „ ์ž„์‹œ +public enum HomeAPI { + case getUpcoming + case getCategoryList + case getPopularTripList(id: Int?, page: Int?, size: Int?) + case getRecommendTripList(page: Int?, size: Int?) +} + +extension HomeAPI: TargetType { + public var baseURL: URL { + NetworkConfiguration.baseURL + } + + public var path: String { + switch self { + case .getUpcoming: + return "/api/v1/travels/upcoming" + case .getCategoryList: + return "/api/v1/travel-programs" + case .getPopularTripList: + return "/api/v1/travel-templates/popular" + case .getRecommendTripList: + return "/api/v1/travel-templates/recommend" + } + } + + public var method: Moya.Method { + switch self { + case .getUpcoming, .getCategoryList, .getPopularTripList, .getRecommendTripList: + return .get + } + } + + public var task: Moya.Task { + switch self { + case .getUpcoming, .getCategoryList: + return .requestPlain + case .getPopularTripList(let id, let page, let size): + var params: [String: Any] = [:] + + if let id { params["travelProgramId"] = id } + if let page { params["page"] = page } + if let size { params["size"] = size } + + return .requestParameters(parameters: params, encoding: URLEncoding.queryString) + + case .getRecommendTripList(let page, let size): + var params: [String: Any] = [:] + + if let page { params["page"] = page } + if let size { params["size"] = size } + + return .requestParameters(parameters: params, encoding: URLEncoding.queryString) + } + } + + public var headers: [String: String]? { + ["Content-Type": "application/json"] + } +} + diff --git a/Projects/Modules/ThirdPartyLibs/Project.swift b/Projects/Modules/ThirdPartyLibs/Project.swift index fcd9a4f..62b1d2a 100644 --- a/Projects/Modules/ThirdPartyLibs/Project.swift +++ b/Projects/Modules/ThirdPartyLibs/Project.swift @@ -18,7 +18,6 @@ let project = Project.makeModule( .SPM.Kingfisher, .SPM.Moya, .SPM.RIBs, - .SPM.RxSwift, .SPM.RxCocoa, .SPM.SnapKit, .SPM.Then diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 69ab405..44ac7d5 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -8,7 +8,18 @@ import PackageDescription // Customize the product types for specific package product // Default is .staticFramework // productTypes: ["Alamofire": .framework,] - productTypes: [:] + productTypes: [ + "RxSwift": .framework, + "RxCocoa": .framework, + "RxRelay": .framework, + "RxCocoaRuntime": .framework, + "Moya": .framework, + "Alamofire": .framework, + "SnapKit": .framework, + "Then": .framework, + "Kingfisher": .framework, + "RIBs": .framework + ] ) #endif