diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift index 3b943f2..3e96764 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift @@ -17,6 +17,7 @@ public extension TargetDependency { public struct Search {} public struct Setting {} public struct PopularTravel {} + public struct TravelTool {} } struct Modules {} @@ -88,6 +89,12 @@ public extension TargetDependency.Features.Main { public extension TargetDependency.Features.PopularTravel { static let group = "PopularTravel" - + + static let feature = TargetDependency.Features.project(name: "Feature", group: group) +} + +public extension TargetDependency.Features.TravelTool { + static let group = "TravelTool" + static let feature = TargetDependency.Features.project(name: "Feature", group: group) } diff --git a/Plugins/EnvPlugin/ProjectDescriptionHelpers/InfoPlist.swift b/Plugins/EnvPlugin/ProjectDescriptionHelpers/InfoPlist.swift index a1ad675..d8c05ce 100644 --- a/Plugins/EnvPlugin/ProjectDescriptionHelpers/InfoPlist.swift +++ b/Plugins/EnvPlugin/ProjectDescriptionHelpers/InfoPlist.swift @@ -33,7 +33,8 @@ public extension Project { ]), "ITSAppUsesNonExemptEncryption": .boolean(false), "BASE_URL": .string("$(BASE_URL)"), - "X_API_KEY": .string("$(X_API_KEY)") + "X_API_KEY": .string("$(X_API_KEY)"), + "GOOGLE_WEATHER_API_KEY": .string("$(GOOGLE_WEATHER_API_KEY)") ] static let demoInfoPlist: [String: Plist.Value] = [ diff --git a/Projects/App/Resources/PrivacyInfo.xcprivacy b/Projects/App/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..ce82588 --- /dev/null +++ b/Projects/App/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,31 @@ + + + + + + NSPrivacyTracking + + + + NSPrivacyTrackingDomains + + + + NSPrivacyCollectedDataTypes + + + + NSPrivacyAccessedAPITypes + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/Projects/App/Sources/Application/AppComponent.swift b/Projects/App/Sources/Application/AppComponent.swift index e9d3ea1..8bb7018 100644 --- a/Projects/App/Sources/Application/AppComponent.swift +++ b/Projects/App/Sources/Application/AppComponent.swift @@ -41,6 +41,13 @@ final class AppComponent: Component, RootDependency { } } + var weatherRepository: WeatherRepositoryInterface { + shared { + let service = makeWeatherService() + return WeatherRepository(service: service) + } + } + var homeUsecase: HomeUsecaseProtocol { shared { HomeUsecase( diff --git a/Projects/Data/Sources/DI/WeatherServiceFactory.swift b/Projects/Data/Sources/DI/WeatherServiceFactory.swift new file mode 100644 index 0000000..b11d397 --- /dev/null +++ b/Projects/Data/Sources/DI/WeatherServiceFactory.swift @@ -0,0 +1,16 @@ +// +// WeatherServiceFactory.swift +// Data +// +// Created by kimnahun on 2026-02-21. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation +import Networks +import Moya + +public func makeWeatherService() -> WeatherServiceProtocol { + let provider = MoyaProvider() + return WeatherService(provider: provider) +} diff --git a/Projects/Data/Sources/Repository/Weather/WeatherRepository.swift b/Projects/Data/Sources/Repository/Weather/WeatherRepository.swift new file mode 100644 index 0000000..13c6e9f --- /dev/null +++ b/Projects/Data/Sources/Repository/Weather/WeatherRepository.swift @@ -0,0 +1,36 @@ +// +// WeatherRepository.swift +// Data +// +// Created by kimnahun on 2026-02-21. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation +import Domain +import Networks + +public final class WeatherRepository: WeatherRepositoryInterface { + private let service: WeatherServiceProtocol + + public init(service: WeatherServiceProtocol) { + self.service = service + } + + public func fetchForecast( + latitude: Double, + longitude: Double, + days: Int + ) async throws -> [DailyWeatherInfo] { + do { + let response = try await service.getForecast( + latitude: latitude, + longitude: longitude, + days: days + ) + return response.forecastDays.compactMap { $0.toDomain() } + } catch { + throw error.toNDGLError() + } + } +} diff --git a/Projects/Data/Sources/Transform/UserTravelTransform.swift b/Projects/Data/Sources/Transform/UserTravelTransform.swift index c1ba86c..d73f988 100644 --- a/Projects/Data/Sources/Transform/UserTravelTransform.swift +++ b/Projects/Data/Sources/Transform/UserTravelTransform.swift @@ -17,6 +17,8 @@ extension UpcomingResponse { return .init( id: self.userTravelId, title: self.title, + city: self.city, + country: self.country, startDay: self.startDate.toDate() ?? .now, endDay: self.endDate.toDate() ?? .now, tripSchedule: .init( @@ -25,7 +27,9 @@ extension UpcomingResponse { placeName: self.upcomingUserTravelPlace.place.name, thumbnailUrl: self.upcomingUserTravelPlace.place.thumbnail ?? "", transport: self.upcomingUserTravelPlace.place.category, - estimatedDuration: self.upcomingUserTravelPlace.estimatedDuration + estimatedDuration: self.upcomingUserTravelPlace.estimatedDuration, + latitude: self.upcomingUserTravelPlace.place.latitude, + longitude: self.upcomingUserTravelPlace.place.longitude ) ) } diff --git a/Projects/Data/Sources/Transform/WeatherTransform.swift b/Projects/Data/Sources/Transform/WeatherTransform.swift new file mode 100644 index 0000000..3990659 --- /dev/null +++ b/Projects/Data/Sources/Transform/WeatherTransform.swift @@ -0,0 +1,29 @@ +// +// WeatherTransform.swift +// Data +// +// Created by kimnahun on 2026-02-21. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation +import Domain +import Networks + +extension ForecastDayResponse { + func toDomain() -> DailyWeatherInfo? { + var components = DateComponents() + components.year = displayDate.year + components.month = displayDate.month + components.day = displayDate.day + + guard let date = Calendar.current.date(from: components) else { return nil } + + return DailyWeatherInfo( + date: date, + maxTemperature: maxTemperature?.degrees ?? 0, + minTemperature: minTemperature?.degrees ?? 0, + weatherType: daytimeForecast?.weatherCondition.type ?? "CLOUDY" + ) + } +} diff --git a/Projects/Domain/Sources/Interface/Weather/WeatherRepositoryInterface.swift b/Projects/Domain/Sources/Interface/Weather/WeatherRepositoryInterface.swift new file mode 100644 index 0000000..3314d80 --- /dev/null +++ b/Projects/Domain/Sources/Interface/Weather/WeatherRepositoryInterface.swift @@ -0,0 +1,17 @@ +// +// WeatherRepositoryInterface.swift +// Domain +// +// Created by kimnahun on 2026-02-21. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +public protocol WeatherRepositoryInterface { + func fetchForecast( + latitude: Double, + longitude: Double, + days: Int + ) async throws -> [DailyWeatherInfo] +} diff --git a/Projects/Domain/Sources/Model/Home/MyTripSummary.swift b/Projects/Domain/Sources/Model/Home/MyTripSummary.swift index 894937f..f529c73 100644 --- a/Projects/Domain/Sources/Model/Home/MyTripSummary.swift +++ b/Projects/Domain/Sources/Model/Home/MyTripSummary.swift @@ -12,13 +12,17 @@ import Foundation public struct MyTripSummary { public let id: Int public let title: String + public let city: String + public let country: 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) { + + public init(id: Int, title: String, city: String, country: String, startDay: Date, endDay: Date, tripSchedule: Schedule) { self.id = id self.title = title + self.city = city + self.country = country self.startDay = startDay self.endDay = endDay self.tripSchedule = tripSchedule @@ -33,14 +37,18 @@ public struct Schedule { public let thumbnailUrl: String public let transport: String public let estimatedDuration: Int - + public let latitude: Double + public let longitude: Double + public init( id: Int, day: Int, placeName: String, thumbnailUrl: String, transport: String, - estimatedDuration: Int + estimatedDuration: Int, + latitude: Double, + longitude: Double ) { self.id = id self.day = day @@ -48,5 +56,7 @@ public struct Schedule { self.thumbnailUrl = thumbnailUrl self.transport = transport self.estimatedDuration = estimatedDuration + self.latitude = latitude + self.longitude = longitude } } diff --git a/Projects/Domain/Sources/Model/Weather/WeatherInfo.swift b/Projects/Domain/Sources/Model/Weather/WeatherInfo.swift new file mode 100644 index 0000000..1e0bba0 --- /dev/null +++ b/Projects/Domain/Sources/Model/Weather/WeatherInfo.swift @@ -0,0 +1,28 @@ +// +// WeatherInfo.swift +// Domain +// +// Created by kimnahun on 2026-02-21. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +public struct DailyWeatherInfo { + public let date: Date + public let maxTemperature: Double + public let minTemperature: Double + public let weatherType: String + + public init( + date: Date, + maxTemperature: Double, + minTemperature: Double, + weatherType: String + ) { + self.date = date + self.maxTemperature = maxTemperature + self.minTemperature = minTemperature + self.weatherType = weatherType + } +} diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift index df8db57..e1b73cf 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift @@ -15,7 +15,7 @@ import RxSwift public protocol FollowDetailListener: AnyObject { func detachFollowDetail() - func followDetailDidAddTrip(title: String, startDate: Date, endDate: Date) + func followDetailDidViewTrip() } // MARK: - FollowDetailPresentable @@ -28,6 +28,7 @@ protocol FollowDetailPresentable: Presentable { func updateTravelDetail(_ detail: TravelDetail) func updatePlaces(_ places: [TravelPlace]) func showPlaceDetail(_ place: TravelPlace) + func showTripCreatedModal(onLater: @escaping () -> Void, onViewTrip: @escaping () -> Void) } // MARK: - FollowDetailPresentableListener @@ -192,16 +193,24 @@ extension FollowDetailInteractor: TripCalendarListener { await MainActor.run { presenter.showLoading() } - + do { let response = try await followDetailUsecase.createUserTravel(request: request) - - await MainActor.run { + + await MainActor.run { [weak self] in + guard let self else { return } presenter.hideLoading() - router?.detachTripCalendar() - - let tripTitle = "\(travelDetail?.city ?? "새로운") 여행" - listener?.followDetailDidAddTrip(title: tripTitle, startDate: startDate, endDate: endDate) + + presenter.showTripCreatedModal( + onLater: { [weak self] in + self?.router?.detachTripCalendar() + self?.listener?.detachFollowDetail() + }, + onViewTrip: { [weak self] in + self?.router?.detachTripCalendar() + self?.listener?.followDetailDidViewTrip() + } + ) print("여행 생성 성공 - userTravelId: \(response.userTravelId)") } } catch { diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift index 6a68d2a..730b0e6 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift @@ -31,8 +31,7 @@ final class FollowDetailViewController: UIViewController, FollowDetailPresentabl // MARK: - UI Components (Scroll) private let navigationBar = NDGLNavigationBar( style: .gray, - leadingIcon: DSKitAsset.Assets.icChevronLeft3.image, - trailingIcon: DSKitAsset.Assets.icShare1.image + leadingIcon: DSKitAsset.Assets.icChevronLeft3.image ) private let scrollView = UIScrollView().then { @@ -255,6 +254,18 @@ extension FollowDetailViewController { } } + func showTripCreatedModal(onLater: @escaping () -> Void, onViewTrip: @escaping () -> Void) { + let modal = NDGLModalViewController( + title: "여행이 준비됐어요", + subtitle: "이제 선택한 여행을\n그대로 따라갈 수 있어요.", + cancelButtonTitle: "나중에 보러가기", + actionButtonTitle: "여행 보러가기" + ) + modal.onCancelTapped = onLater + modal.onActionTapped = onViewTrip + present(modal, animated: true) + } + func showPlaceDetail(_ place: TravelPlace) { let contentView = PlaceDetailBottomSheetView() contentView.configure(with: place) diff --git a/Projects/Features/FollowFeature/Sources/PlaceDetail/PlaceDetailViewController.swift b/Projects/Features/FollowFeature/Sources/PlaceDetail/PlaceDetailViewController.swift index 7d0b3d8..c363075 100644 --- a/Projects/Features/FollowFeature/Sources/PlaceDetail/PlaceDetailViewController.swift +++ b/Projects/Features/FollowFeature/Sources/PlaceDetail/PlaceDetailViewController.swift @@ -10,6 +10,7 @@ import Domain import DSKit import Kingfisher import RIBs +import RxSwift import SnapKit import UIKit @@ -20,8 +21,15 @@ final class PlaceDetailViewController: UIViewController, PlaceDetailPresentable, // MARK: - Properties weak var listener: PlaceDetailPresentableListener? + private let disposeBag = DisposeBag() private var segmentOriginY: CGFloat = .greatestFiniteMagnitude + // MARK: - UI Components (Navigation) + + private let navigationBar = NDGLNavigationBar( + leadingIcon: DSKitAsset.Assets.icChevronLeft3.image + ) + // MARK: - UI Components (Fixed Header) private let fixedHeaderView: UIView = { @@ -183,7 +191,7 @@ final class PlaceDetailViewController: UIViewController, PlaceDetailPresentable, override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - navigationController?.setNavigationBarHidden(false, animated: animated) + navigationController?.setNavigationBarHidden(true, animated: animated) } override func viewDidDisappear(_ animated: Bool) { @@ -203,6 +211,7 @@ final class PlaceDetailViewController: UIViewController, PlaceDetailPresentable, private func setupUI() { view.backgroundColor = .white + view.addSubview(navigationBar) view.addSubview(fixedHeaderView) [nameLabel, ratingLabel, reviewCountLabel].forEach { fixedHeaderView.addSubview($0) } @@ -224,8 +233,13 @@ final class PlaceDetailViewController: UIViewController, PlaceDetailPresentable, } private func setupConstraints() { - fixedHeaderView.snp.makeConstraints { + navigationBar.snp.makeConstraints { $0.top.equalTo(view.safeAreaLayoutGuide) + $0.horizontalEdges.equalToSuperview() + } + + fixedHeaderView.snp.makeConstraints { + $0.top.equalTo(navigationBar.snp.bottom) $0.leading.trailing.equalToSuperview() } @@ -358,6 +372,12 @@ final class PlaceDetailViewController: UIViewController, PlaceDetailPresentable, private func setupActions() { segmentedControl.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged) stickySegmentedControl.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged) + + navigationBar.leadingButtonDidTap + .subscribe(with: self) { owner, _ in + owner.navigationController?.popViewController(animated: true) + } + .disposed(by: disposeBag) } // MARK: - Actions diff --git a/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift b/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift index 56a8e04..d9d2cf1 100644 --- a/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift +++ b/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift @@ -9,6 +9,7 @@ import Core import DSKit import RIBs +import RxSwift import SnapKit import Then import UIKit @@ -21,11 +22,17 @@ final class TripCalendarViewController: UIViewController, TripCalendarPresentabl weak var listener: TripCalendarPresentableListener? + private let disposeBag = DisposeBag() private var selectedStartDate: Date? private var selectedEndDate: Date? // MARK: - UI Components + private let navigationBar = NDGLNavigationBar( + title: "여행 따라가기", + leadingIcon: DSKitAsset.Assets.icChevronLeft3.image + ) + private let calendarView = CalendarView() private let completeButton = BottomPlacedButton(title: "완료") @@ -50,18 +57,27 @@ final class TripCalendarViewController: UIViewController, TripCalendarPresentabl // MARK: - Setup + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(true, animated: animated) + } + private func setupUI() { - title = "여행 따라가기" view.backgroundColor = UIColor(hexCode: "#FFFFFF") - [calendarView, completeButton].forEach { + [navigationBar, calendarView, completeButton].forEach { view.addSubview($0) } } private func setupConstraints() { + navigationBar.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide) + $0.horizontalEdges.equalToSuperview() + } + calendarView.snp.makeConstraints { - $0.top.equalTo(view.safeAreaLayoutGuide).offset(16) + $0.top.equalTo(navigationBar.snp.bottom).offset(16) $0.leading.trailing.equalToSuperview() } @@ -78,6 +94,12 @@ final class TripCalendarViewController: UIViewController, TripCalendarPresentabl private func setupActions() { completeButton.addTarget(self, action: #selector(completeButtonTapped), for: .touchUpInside) + + navigationBar.leadingButtonDidTap + .subscribe(with: self) { owner, _ in + owner.navigationController?.popViewController(animated: true) + } + .disposed(by: disposeBag) } // MARK: - Actions diff --git a/Projects/Features/HomeFeature/Sources/HomeInteractor.swift b/Projects/Features/HomeFeature/Sources/HomeInteractor.swift index a684446..d124111 100644 --- a/Projects/Features/HomeFeature/Sources/HomeInteractor.swift +++ b/Projects/Features/HomeFeature/Sources/HomeInteractor.swift @@ -95,51 +95,54 @@ final class HomeInteractor: PresentableInteractor, HomeInteract owner.presenter.update(with: sections) } .disposed(by: disposeBag) - - homeDataRelay - .map { $0 == nil } - .subscribe(with: self) { owner, isLoading in - owner.presenter.setLoading(isLoading) - } - .disposed(by: disposeBag) } + private static let allCategoryId = -1 + 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() } + let usecase = self.usecase + var myTripBanner: HomePresentationModel.Banner + do { + let tripInfo = try await usecase.fetchMyTripInfo() + myTripBanner = tripInfo.toPresention() + } catch { + myTripBanner = .empty + } + + let allCategory = HomePresentationModel.Category( + id: HomeInteractor.allCategoryId, + creator: "전체", + viedoType: .none + ) + + async let apiCategories = self.usecase.fetchCategoryList().map { $0.toHomeModel() } + async let populars = self.usecase.fetchPopularTripList(id: nil).map { $0.toPopularHomeModel() } async let recommended = self.usecase.fetchRecommendTripList().map { $0.toRecommendHomeModel() } - + + let categories = try await [allCategory] + apiCategories + 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) + + if self.selectedCategoryRelay.value == nil { + self.selectedCategoryRelay.accept(HomeInteractor.allCategoryId) } - + homeDataRelay.accept(model) presenter.setLoading(false) } catch let error { @@ -149,10 +152,37 @@ final class HomeInteractor: PresentableInteractor, HomeInteract } } } + + private func fetchPopularTrips(categoryId: Int) { + Task { [weak self] in + guard let self else { return } + + do { + let apiId: Int? = categoryId == HomeInteractor.allCategoryId ? nil : categoryId + let populars = try await self.usecase.fetchPopularTripList(id: apiId).map { $0.toPopularHomeModel() } + + guard !Task.isCancelled, let model = self.homeDataRelay.value else { return } + + let updated = HomePresentationModel( + banner: model.banner, + category: model.category, + popularTrip: populars, + recommendedTrip: model.recommendedTrip + ) + self.homeDataRelay.accept(updated) + } catch { + print(error) + } + } + } } // MARK: - HomePresentableListener extension HomeInteractor: HomePresentableListener { + func viewWillAppear() { + fetchHomeData() + } + func reloadBtnTapped() { fetchHomeData() } @@ -168,7 +198,10 @@ extension HomeInteractor: HomePresentableListener { func itemSelected(item: HomeItem) { switch item { case .category(let category, _): - selectedCategoryRelay.accept(category.id) + let categoryId = category.id + guard categoryId != selectedCategoryRelay.value else { return } + selectedCategoryRelay.accept(categoryId) + fetchPopularTrips(categoryId: categoryId) case .popularTrip(let trip): listener?.homeDidTapFollowDetail(with: trip.id) case .recommendedTrip(let trip): diff --git a/Projects/Features/HomeFeature/Sources/HomeViewController.swift b/Projects/Features/HomeFeature/Sources/HomeViewController.swift index a662c1d..6161f6e 100644 --- a/Projects/Features/HomeFeature/Sources/HomeViewController.swift +++ b/Projects/Features/HomeFeature/Sources/HomeViewController.swift @@ -23,6 +23,7 @@ protocol HomePresentableListener: AnyObject { func itemSelected(item: HomeItem) func moreBtnTapped() func reloadBtnTapped() + func viewWillAppear() } // MARK: - HomeViewController @@ -62,6 +63,7 @@ final class HomeViewController: UIViewController, HomeViewControllable { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController?.setNavigationBarHidden(true, animated: animated) + listener?.viewWillAppear() } } diff --git a/Projects/Features/HomeFeature/Sources/Views/Cells/HomeBannerCell.swift b/Projects/Features/HomeFeature/Sources/Views/Cells/HomeBannerCell.swift index 2ff02ec..a539516 100644 --- a/Projects/Features/HomeFeature/Sources/Views/Cells/HomeBannerCell.swift +++ b/Projects/Features/HomeFeature/Sources/Views/Cells/HomeBannerCell.swift @@ -16,8 +16,8 @@ final class HomeBannerCell: UICollectionViewCell { private var type: HomeBannerType = .empty private let emptyView = HomeBannerEmptyView() - private let upCommingView = HomeBannerUpCommingView() - private let onGoingView = HomeBannerOnGoingView() + private let upCommingView = NDGLUpComingView() + private let onGoingView = NDGLOnGoingView() private let stackView = UIStackView() override init(frame: CGRect) { diff --git a/Projects/Features/MainFeature/Sources/MainBuilder.swift b/Projects/Features/MainFeature/Sources/MainBuilder.swift index e985c49..38b783c 100644 --- a/Projects/Features/MainFeature/Sources/MainBuilder.swift +++ b/Projects/Features/MainFeature/Sources/MainBuilder.swift @@ -19,20 +19,25 @@ public protocol MainDependency: Dependency { var homeUsecase: HomeUsecaseProtocol { get } var followDetailUsecase: FollowDetailUsecaseProtocol { get } var templateSearchUsecase: TemplatesSearchUsecaseProtocol { get } + var weatherRepository: WeatherRepositoryInterface { get } } final class MainComponent: Component, FollowDetailDependency, PopularTravelDependency,SearchDependency, SettingDependency, TabBarDependency { var searchUsecase: TemplatesSearchUsecaseProtocol { dependency.templateSearchUsecase } - + var followDetailUsecase: FollowDetailUsecaseProtocol { dependency.followDetailUsecase } - + var homeUsecase: HomeUsecaseProtocol { dependency.homeUsecase } + + var weatherRepository: WeatherRepositoryInterface { + dependency.weatherRepository + } } // MARK: - Builder diff --git a/Projects/Features/MainFeature/Sources/MainInteractor.swift b/Projects/Features/MainFeature/Sources/MainInteractor.swift index dfa86dd..e61f19f 100644 --- a/Projects/Features/MainFeature/Sources/MainInteractor.swift +++ b/Projects/Features/MainFeature/Sources/MainInteractor.swift @@ -21,6 +21,7 @@ public protocol MainRouting: ViewableRouting { func attachSetting() func detachSetting() func attachTabBar() + func switchToTab(at index: Int) } protocol MainPresentable: Presentable { @@ -52,8 +53,9 @@ final class MainInteractor: PresentableInteractor, MainInteract router?.detachFollow() } - func followDetailDidAddTrip(title: String, startDate: Date, endDate: Date) { - // 이건 뭐임 + func followDetailDidViewTrip() { + router?.detachFollow() + router?.switchToTab(at: 1) } func popularTravelDidTapFollowDetail(with recommendationId: Int) { diff --git a/Projects/Features/MainFeature/Sources/MainRouter.swift b/Projects/Features/MainFeature/Sources/MainRouter.swift index f55d55b..6ed769c 100644 --- a/Projects/Features/MainFeature/Sources/MainRouter.swift +++ b/Projects/Features/MainFeature/Sources/MainRouter.swift @@ -140,6 +140,10 @@ final class MainRouter: ViewableRouter, self.settingRouter = nil } + func switchToTab(at index: Int) { + tabBarRouter?.switchToTab(at: index) + } + func attachTabBar() { guard tabBarRouter == nil else { return } let router = tabBarBuilder.build(withListener: interactor) diff --git a/Projects/Features/RootFeature/Sources/RootBuilder.swift b/Projects/Features/RootFeature/Sources/RootBuilder.swift index 64ffd5e..2b9f59f 100644 --- a/Projects/Features/RootFeature/Sources/RootBuilder.swift +++ b/Projects/Features/RootFeature/Sources/RootBuilder.swift @@ -19,6 +19,7 @@ public protocol RootDependency: Dependency { var authRepository: AuthRepositoryInterface { get } var tokenRepository: TokenRepositoryProtocol { get } var templateSearchUsecase: TemplatesSearchUsecaseProtocol { get } + var weatherRepository: WeatherRepositoryInterface { get } } // MARK: - RootComponent @@ -27,14 +28,18 @@ final class RootComponent: Component, MainDependency { var templateSearchUsecase: TemplatesSearchUsecaseProtocol { dependency.templateSearchUsecase } - + var followDetailUsecase: FollowDetailUsecaseProtocol { dependency.followDetailUsecase } - + var homeUsecase: HomeUsecaseProtocol { dependency.homeUsecase } + + var weatherRepository: WeatherRepositoryInterface { + dependency.weatherRepository + } } // MARK: - RootBuildable diff --git a/Projects/Features/SearchFeature/Sources/SearchBuilder.swift b/Projects/Features/SearchFeature/Sources/SearchBuilder.swift index f216023..dec196d 100644 --- a/Projects/Features/SearchFeature/Sources/SearchBuilder.swift +++ b/Projects/Features/SearchFeature/Sources/SearchBuilder.swift @@ -14,7 +14,7 @@ public protocol SearchDependency: Dependency { var searchUsecase: TemplatesSearchUsecaseProtocol { get } } -final class SearchComponent: Component, SearchResultDependency { +final class SearchComponent: Component { var searchUsecase: TemplatesSearchUsecaseProtocol { dependency.searchUsecase } @@ -35,13 +35,10 @@ public final class SearchBuilder: Builder, SearchBuildable { let viewController = SearchViewController() let interactor = SearchInteractor(presenter: viewController, usecase: component.searchUsecase) interactor.listener = listener - - let searchResultBuilder = SearchResultBuilder(dependency: component) - + return SearchRouter( interactor: interactor, - viewController: viewController, - searchResultBuilder: searchResultBuilder + viewController: viewController ) } } diff --git a/Projects/Features/SearchFeature/Sources/SearchInteractor.swift b/Projects/Features/SearchFeature/Sources/SearchInteractor.swift index 9746542..5a0320c 100644 --- a/Projects/Features/SearchFeature/Sources/SearchInteractor.swift +++ b/Projects/Features/SearchFeature/Sources/SearchInteractor.swift @@ -12,12 +12,14 @@ import RIBs import RxSwift public protocol SearchRouting: ViewableRouting { - func attachSearchResult(keyword: String) - func detachSearchResult() } protocol SearchPresentable: Presentable { var listener: SearchPresentableListener? { get set } + + func update(with model: SearchResultPresentationModel) + func setLoading(_ isLoading: Bool) + func showErrorView(_ isError: Bool) } public protocol SearchListener: AnyObject { @@ -30,7 +32,9 @@ final class SearchInteractor: PresentableInteractor, SearchIn weak var listener: SearchListener? private let usecase: TemplatesSearchUsecaseProtocol - + private var fetchDataTask: Task? + private var lastKeyword: String? + init(presenter: SearchPresentable, usecase: TemplatesSearchUsecaseProtocol) { self.usecase = usecase super.init(presenter: presenter) @@ -39,27 +43,56 @@ final class SearchInteractor: PresentableInteractor, SearchIn override func didBecomeActive() { super.didBecomeActive() - // TODO: Implement business logic here. } override func willResignActive() { super.willResignActive() - // TODO: Pause any business logic. + + fetchDataTask?.cancel() + fetchDataTask = nil } - + func search(keyword: String) { - router?.attachSearchResult(keyword: keyword) + lastKeyword = keyword + fetchData(keyword: keyword) } - - func detachSearchResult() { - router?.detachSearchResult() + + func itemSelected(item: SearchResultItem) { + switch item { + case .resultTrip(let trip): + listener?.attachFollowDetail(with: trip.id) + } } - - func popularTravelDidTapFollowDetail(with recommendationId: Int) { - listener?.attachFollowDetail(with: recommendationId) + + func reloadBtnTapped() { + guard let keyword = lastKeyword else { return } + fetchData(keyword: keyword) } - + func detachSearch() { listener?.detachSearch() } + + private func fetchData(keyword: String) { + fetchDataTask?.cancel() + + presenter.setLoading(true) + presenter.showErrorView(false) + + fetchDataTask = Task { [weak self] in + guard let self, !Task.isCancelled else { return } + + do { + let result = try await self.usecase.searchTemplate(keyword: keyword) + + let model = SearchResultPresentationModel(resultTrip: result.map { $0.toSearchResultModel() }) + + self.presenter.update(with: model) + self.presenter.setLoading(false) + } catch { + self.presenter.setLoading(false) + self.presenter.showErrorView(true) + } + } + } } diff --git a/Projects/Features/SearchFeature/Sources/SearchRouter.swift b/Projects/Features/SearchFeature/Sources/SearchRouter.swift index b1b0999..576e885 100644 --- a/Projects/Features/SearchFeature/Sources/SearchRouter.swift +++ b/Projects/Features/SearchFeature/Sources/SearchRouter.swift @@ -8,45 +8,21 @@ import RIBs -protocol SearchInteractable: Interactable, SearchResultListener { +protocol SearchInteractable: Interactable { var router: SearchRouting? { get set } var listener: SearchListener? { get set } } protocol SearchViewControllable: ViewControllable { - func pushChild(_ viewControllable: ViewControllable) - func popChild(_ animated: Bool) } final class SearchRouter: ViewableRouter, SearchRouting { - private let searchResultBuilder: SearchResultBuildable - private var searchResultRouter: SearchResultRouting? - - init( + + override init( interactor: SearchInteractable, - viewController: SearchViewControllable, - searchResultBuilder: SearchResultBuildable + viewController: SearchViewControllable ) { - self.searchResultBuilder = searchResultBuilder super.init(interactor: interactor, viewController: viewController) interactor.router = self } - - func attachSearchResult(keyword: String) { - guard searchResultRouter == nil else { return } - - let router = searchResultBuilder.build( - withListener: interactor, - searchKeyword: keyword - ) - self.searchResultRouter = router - attachChild(router) - viewController.pushChild(router.viewControllable) - } - - func detachSearchResult() { - guard let routing = searchResultRouter else { return } - detachChild(routing) - self.searchResultRouter = nil - } } diff --git a/Projects/Features/SearchFeature/Sources/SearchViewController.swift b/Projects/Features/SearchFeature/Sources/SearchViewController.swift index 3aedc6c..4656cfc 100644 --- a/Projects/Features/SearchFeature/Sources/SearchViewController.swift +++ b/Projects/Features/SearchFeature/Sources/SearchViewController.swift @@ -15,108 +15,195 @@ import RxSwift protocol SearchPresentableListener: AnyObject { func search(keyword: String) - func detachSearchResult() func detachSearch() + func itemSelected(item: SearchResultItem) + func reloadBtnTapped() } 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 contentNavigationController = UINavigationController() - private let emptyView = EmptyView() + + private let startEmptyView = EmptyView() + private let noResultEmptyView = EmptyView() + private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout()) + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + private let networkErrorView = NDGLErrorView() + private var dataSource: UICollectionViewDiffableDataSource! + + private let contentContainerView = UIView() private let disposeBag = DisposeBag() - + override func viewDidLoad() { super.viewDidLoad() - + hideKeyboard() setStyle() setUI() - setContentNavigation() setLayout() + setCollectionView() + setDataSource() searchBar.focus() bindKeyboard() setupActions() + bindInteractor() } - + override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - + if isMovingFromParent { - contentNavigationController.interactivePopGestureRecognizer?.delegate = nil listener?.detachSearch() } } - - func pushChild(_ viewControllable: ViewControllable) { - contentNavigationController.pushViewController( - viewControllable.uiviewController, - animated: true - ) +} + +// MARK: - SearchPresentable + +extension SearchViewController { + func update(with model: SearchResultPresentationModel) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + self.applySnapShot(model.resultTrip) + + let isEmpty = model.resultTrip.isEmpty + + self.startEmptyView.isHidden = true + self.noResultEmptyView.isHidden = !isEmpty + self.collectionView.isHidden = isEmpty + } } - - func popChild(_ animated: Bool) { - contentNavigationController.popViewController(animated: animated) + + func setLoading(_ isLoading: Bool) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + if isLoading { + self.startEmptyView.isHidden = true + 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 + } } } private extension SearchViewController { func setStyle() { view.backgroundColor = DSKitAsset.Colors.white.color - - contentNavigationController.do { - $0.isNavigationBarHidden = true - $0.view.backgroundColor = .clear + + contentContainerView.do { + $0.backgroundColor = .clear + } + + collectionView.do { + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false + $0.backgroundColor = .clear + $0.isScrollEnabled = true + $0.contentInset = .init(top: 18.adjustedH, left: 0, bottom: 0, right: 0) + $0.isHidden = true + } + + loadingIndicator.do { + $0.color = DSKitAsset.Colors.green300.color + } + + networkErrorView.do { + $0.isHidden = true + } + + noResultEmptyView.do { + $0.isHidden = true + $0.changeType(.noResults) + } + + startEmptyView.do { + $0.changeType(.start) } } - + func setUI() { - view.addSubviews(searchBar) + view.addSubviews(searchBar, contentContainerView) + contentContainerView.addSubviews(startEmptyView, collectionView, loadingIndicator, networkErrorView, noResultEmptyView) } - + func setLayout() { searchBar.snp.makeConstraints { $0.top.equalTo(view.safeAreaLayoutGuide) $0.directionalHorizontalEdges.equalToSuperview() } - - contentNavigationController.view.snp.makeConstraints { + + contentContainerView.snp.makeConstraints { $0.top.equalTo(searchBar.snp.bottom) $0.directionalHorizontalEdges.bottom.equalToSuperview() } + + startEmptyView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + collectionView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + loadingIndicator.snp.makeConstraints { + $0.center.equalToSuperview() + } + + networkErrorView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.directionalHorizontalEdges.equalToSuperview() + $0.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16.adjustedH) + } + + noResultEmptyView.snp.makeConstraints { + $0.edges.equalToSuperview() + } } - - func setContentNavigation() { - let rootVC = UIViewController() - rootVC.view.backgroundColor = DSKitAsset.Colors.white.color - rootVC.view.addSubview(emptyView) - emptyView.snp.makeConstraints { $0.edges.equalToSuperview() } - - contentNavigationController.setViewControllers([rootVC], animated: false) - addChild(contentNavigationController) - view.addSubview(contentNavigationController.view) - contentNavigationController.didMove(toParent: self) + func setCollectionView() { + collectionView.do { + $0.register( + PopularInfoCell.self, + forCellWithReuseIdentifier: PopularInfoCell.cellIdentifier + ) - contentNavigationController.interactivePopGestureRecognizer?.delegate = self + $0.register( + SearchResultHeaderView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: SearchResultHeaderView.reusableViewIdentifier + ) + } } - + 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?.contentNavigationController.view.snp.updateConstraints { + + self?.contentContainerView.snp.updateConstraints { $0.bottom.equalToSuperview().inset(keyboardHeight) } - + UIView.animate(withDuration: 0.3) { self?.view.layoutIfNeeded() } @@ -125,28 +212,24 @@ private extension SearchViewController { NotificationCenter.default.rx.notification(UIResponder.keyboardWillHideNotification) .subscribe(onNext: { [weak self] _ in - self?.contentNavigationController.view.snp.updateConstraints { + self?.contentContainerView.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 - if owner.contentNavigationController.viewControllers.count > 1 { - owner.contentNavigationController.popViewController(animated: true) - } else { - owner.listener?.detachSearch() - } + owner.listener?.detachSearch() } .disposed(by: disposeBag) - + searchBar.searchButtonClicked .withLatestFrom(searchBar.searchText) .compactMap { $0 } @@ -155,7 +238,7 @@ private extension SearchViewController { owner.listener?.search(keyword: text) } .disposed(by: disposeBag) - + searchBar.trailingButtonDidTap .withLatestFrom(searchBar.searchText) .compactMap { $0 } @@ -164,26 +247,177 @@ private extension SearchViewController { owner.listener?.search(keyword: text) } .disposed(by: disposeBag) - - searchBar.editingDidBegin + } + + func applySnapShot(_ items: [SearchResultPresentationModel.ResultTrip]) { + var snapshot = NSDiffableDataSourceSnapshot() + + snapshot.appendSections([.resultTrip]) + let resultItems = items.map { SearchResultItem.resultTrip($0) } + snapshot.appendItems(resultItems, toSection: .resultTrip) + dataSource?.apply(snapshot, animatingDifferences: true) + } + + func setDataSource() { + let resultTripRegistration = createResultTripCellRegistration() + + dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item in + switch item { + case .resultTrip(let tripList): + return collectionView.dequeueConfiguredReusableCell( + using: resultTripRegistration, + for: indexPath, + item: tripList + ) + } + } + + configureSupplementaryView() + } + + func configureSupplementaryView() { + let headerRegistration = createHeaderRegistration(dataSource: dataSource) + + dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in + guard SearchResultSectionKind(rawValue: indexPath.section) != nil else { + return UICollectionReusableView() + } + + if kind == UICollectionView.elementKindSectionHeader { + return collectionView.dequeueConfiguredReusableSupplementary( + using: headerRegistration, + for: indexPath + ) + } + + return nil + } + } + + func bindInteractor() { + 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 - if owner.contentNavigationController.viewControllers.count > 1 { - owner.contentNavigationController.popViewController(animated: false) - } + owner.listener?.reloadBtnTapped() } .disposed(by: disposeBag) } } -extension SearchViewController: UIGestureRecognizerDelegate { - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool { - return gestureRecognizer == contentNavigationController.interactivePopGestureRecognizer +// MARK: - CompositionalLayout + +extension SearchViewController { + func createLayout() -> UICollectionViewCompositionalLayout { + return UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in + guard let sectionKind = SearchResultSectionKind(rawValue: sectionIndex) else { + return self?.emptyLayout() + } + + switch sectionKind { + case .resultTrip: + return self?.createPopularTripSection() + } + } } - func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - if gestureRecognizer == contentNavigationController.interactivePopGestureRecognizer { - return contentNavigationController.viewControllers.count > 1 + private 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: 16.adjustedH, + leading: 24.adjusted, + bottom: 12.adjustedH, + trailing: 24.adjusted + ) + section.orthogonalScrollingBehavior = .none + + let headerSize = NSCollectionLayoutSize( + widthDimension: .estimated(43.adjusted), + heightDimension: .absolute(30.adjustedH) + ) + + let header = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: headerSize, + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .topLeading + ) + + section.boundarySupplementaryItems = [header] + + return section + } + + private 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 + } +} + +// MARK: - Registration + +extension SearchViewController { + func createResultTripCellRegistration() + -> 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 createHeaderRegistration( + dataSource: UICollectionViewDiffableDataSource + ) -> UICollectionView.SupplementaryRegistration { + return UICollectionView.SupplementaryRegistration( + elementKind: UICollectionView.elementKindSectionHeader + ) { [weak dataSource] headerView, elementKind, indexPath in + guard let dataSource else { return } + + let snapshot = dataSource.snapshot() + let itemCount = snapshot.numberOfItems(inSection: .resultTrip) + + headerView.configure(count: itemCount) } - return true } } diff --git a/Projects/Features/TabBarFeature/Project.swift b/Projects/Features/TabBarFeature/Project.swift index 7f7d9af..7fbb8f0 100644 --- a/Projects/Features/TabBarFeature/Project.swift +++ b/Projects/Features/TabBarFeature/Project.swift @@ -16,7 +16,8 @@ let project = Project.makeModule( name: "TabBarFeature", dependencies: [ .Features.Home.feature, - .Features.Travel.feature + .Features.Travel.feature, + .Features.TravelTool.feature ], scripts: [.swiftLint], isStatic: true, diff --git a/Projects/Features/TabBarFeature/Sources/TabBarBuilder.swift b/Projects/Features/TabBarFeature/Sources/TabBarBuilder.swift index 9191e3d..1174f6b 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarBuilder.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarBuilder.swift @@ -10,19 +10,25 @@ import Domain import HomeFeature import RIBs import TravelFeature +import TravelToolFeature // MARK: - TabBarDependency public protocol TabBarDependency: Dependency { var homeUsecase: HomeUsecaseProtocol { get } + var weatherRepository: WeatherRepositoryInterface { get } } // MARK: - TabBarComponent -final class TabBarComponent: Component, HomeDependency, TravelDependency { +final class TabBarComponent: Component, HomeDependency, TravelDependency, TravelToolDependency { var homeUsecase: HomeUsecaseProtocol { dependency.homeUsecase } + + var weatherRepository: WeatherRepositoryInterface { + dependency.weatherRepository + } } // MARK: - TabBarBuildable @@ -47,12 +53,14 @@ public final class TabBarBuilder: Builder, TabBarBuildable { let homeBuilder = HomeBuilder(dependency: component) let travelBuilder = TravelBuilder(dependency: component) + let travelToolBuilder = TravelToolBuilder(dependency: component) let router = TabBarRouter( interactor: interactor, viewController: viewController, homeBuilder: homeBuilder, - travelBuilder: travelBuilder + travelBuilder: travelBuilder, + travelToolBuilder: travelToolBuilder ) return router diff --git a/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift b/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift index e5fdc77..f2c055d 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift @@ -10,11 +10,13 @@ import Foundation import HomeFeature import RIBs import RxSwift +import TravelToolFeature // MARK: - TabBarRouting public protocol TabBarRouting: ViewableRouting { func attachTabs() + func switchToTab(at index: Int) } // MARK: - TabBarPresentable @@ -85,3 +87,8 @@ extension TabBarInteractor: HomeListener { presenter.switchToTab(at: 2) } } + +// MARK: - TravelToolListener + +extension TabBarInteractor: TravelToolListener { +} diff --git a/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift b/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift index aec5c6d..7f4ba55 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift @@ -10,10 +10,11 @@ import RIBs import HomeFeature import TravelFeature +import TravelToolFeature // MARK: - TabBarInteractable -protocol TabBarInteractable: Interactable, HomeListener, TravelListener { +protocol TabBarInteractable: Interactable, HomeListener, TravelListener, TravelToolListener { var router: TabBarRouting? { get set } var listener: TabBarListener? { get set } } @@ -22,6 +23,7 @@ protocol TabBarInteractable: Interactable, HomeListener, TravelListener { public protocol TabBarViewControllable: ViewControllable { func setViewControllers(_ viewControllers: [ViewControllable]) + func switchToTab(at index: Int) } // MARK: - TabBarRouter @@ -30,17 +32,21 @@ final class TabBarRouter: ViewableRouter= 2 else { + guard viewControllers.count >= 3 else { return } - let homeVC = viewControllers[0].uiviewController - let travelVC = viewControllers[1].uiviewController + let travelToolVC = viewControllers[0].uiviewController + let homeVC = viewControllers[1].uiviewController + let travelVC = viewControllers[2].uiviewController - let infoDummy = UIViewController().then { $0.view.backgroundColor = .white } - - let infoNav = UINavigationController(rootViewController: infoDummy) + let travelToolNav = UINavigationController(rootViewController: travelToolVC) let homeNav = UINavigationController(rootViewController: homeVC) let travelNav = UINavigationController(rootViewController: travelVC) - super.setViewControllers([infoNav, homeNav, travelNav], animated: false) + super.setViewControllers([travelToolNav, homeNav, travelNav], animated: false) setupTabItems() } - func switchToTab(at index: Int) { + public func switchToTab(at index: Int) { guard index < tabItems.count else { return } updateSelection(at: index) diff --git a/Projects/Features/TravelFeature/Sources/TravelInteractor.swift b/Projects/Features/TravelFeature/Sources/TravelInteractor.swift index 42b410e..e4da809 100644 --- a/Projects/Features/TravelFeature/Sources/TravelInteractor.swift +++ b/Projects/Features/TravelFeature/Sources/TravelInteractor.swift @@ -29,7 +29,7 @@ protocol TravelPresentable: Presentable { protocol TravelPresentableListener: AnyObject { func didTapTrip(_ trip: UpcomingTrip) - func didTapMenuButton() + func viewWillAppear() } // MARK: - TravelInteractor @@ -113,9 +113,10 @@ final class TravelInteractor: PresentableInteractor, TravelIn extension TravelInteractor: TravelPresentableListener { - func didTapTrip(_ trip: UpcomingTrip) { + func viewWillAppear() { + loadTrips() } - func didTapMenuButton() { + func didTapTrip(_ trip: UpcomingTrip) { } } diff --git a/Projects/Features/TravelFeature/Sources/TravelViewController.swift b/Projects/Features/TravelFeature/Sources/TravelViewController.swift index 3136ab5..ad57434 100644 --- a/Projects/Features/TravelFeature/Sources/TravelViewController.swift +++ b/Projects/Features/TravelFeature/Sources/TravelViewController.swift @@ -27,14 +27,9 @@ final class TravelViewController: UIViewController, TravelPresentable, TravelVie // MARK: - UI Components - private let titleLabel = UILabel().then { - $0.setText(.subTitleLSB, text: "다가오는 여행", color: UIColor(hexCode: "#111111")) - } - - private let menuButton = UIButton(type: .system).then { - $0.setImage(UIImage(systemName: "line.3.horizontal"), for: .normal) - $0.tintColor = UIColor(hexCode: "#111111") - } + private let navigationBar = NDGLNavigationBar( + title: "다가오는 여행" + ) private let collectionView: UICollectionView = { let layout = UICollectionViewFlowLayout() @@ -70,6 +65,7 @@ final class TravelViewController: UIViewController, TravelPresentable, TravelVie override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController?.setNavigationBarHidden(true, animated: animated) + listener?.viewWillAppear() } // MARK: - Setup @@ -77,25 +73,19 @@ final class TravelViewController: UIViewController, TravelPresentable, TravelVie private func setupUI() { view.backgroundColor = UIColor(hexCode: "#FFFFFF") - [titleLabel, menuButton, collectionView, emptyStateLabel, loadingIndicator].forEach { + [navigationBar, collectionView, emptyStateLabel, loadingIndicator].forEach { view.addSubview($0) } } private func setupConstraints() { - titleLabel.snp.makeConstraints { - $0.top.equalTo(view.safeAreaLayoutGuide).offset(16) - $0.leading.equalToSuperview().offset(24) - } - - menuButton.snp.makeConstraints { - $0.centerY.equalTo(titleLabel) - $0.trailing.equalToSuperview().offset(-24) - $0.size.equalTo(24) + navigationBar.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide) + $0.horizontalEdges.equalToSuperview() } collectionView.snp.makeConstraints { - $0.top.equalTo(titleLabel.snp.bottom).offset(24) + $0.top.equalTo(navigationBar.snp.bottom).offset(24) $0.leading.equalToSuperview().offset(24) $0.trailing.equalToSuperview().offset(-24) $0.bottom.equalToSuperview() @@ -117,13 +107,6 @@ final class TravelViewController: UIViewController, TravelPresentable, TravelVie } private func setupActions() { - menuButton.addTarget(self, action: #selector(menuButtonTapped), for: .touchUpInside) - } - - // MARK: - Actions - - @objc private func menuButtonTapped() { - listener?.didTapMenuButton() } } diff --git a/Projects/Features/TravelToolFeature/Project.swift b/Projects/Features/TravelToolFeature/Project.swift new file mode 100644 index 0000000..3f6ef9e --- /dev/null +++ b/Projects/Features/TravelToolFeature/Project.swift @@ -0,0 +1,25 @@ +// +// Project.swift +// TravelToolFeature +// +// Created by kimnahun on 2026-02-21. +// + +import ProjectDescription +import ProjectDescriptionHelpers +import DependencyPlugin + +let project = Project.makeModule( + name: "TravelToolFeature", + targets: [ + .makeFrameworkTarget( + name: "TravelToolFeature", + dependencies: [ + .Features.baseFeatureDependency + ], + scripts: [.swiftLint], + isStatic: true, + hasResources: false + ) + ] +) diff --git a/Projects/Features/TravelToolFeature/Sources/TravelToolBuilder.swift b/Projects/Features/TravelToolFeature/Sources/TravelToolBuilder.swift new file mode 100644 index 0000000..6d01a6c --- /dev/null +++ b/Projects/Features/TravelToolFeature/Sources/TravelToolBuilder.swift @@ -0,0 +1,62 @@ +// +// TravelToolBuilder.swift +// TravelToolFeature +// +// Created by kimnahun on 2026-02-21. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Domain +import RIBs + +// MARK: - TravelToolDependency + +public protocol TravelToolDependency: Dependency { + var homeUsecase: HomeUsecaseProtocol { get } + var weatherRepository: WeatherRepositoryInterface { get } +} + +// MARK: - TravelToolComponent + +final class TravelToolComponent: Component { + var homeUsecase: HomeUsecaseProtocol { + dependency.homeUsecase + } + + var weatherRepository: WeatherRepositoryInterface { + dependency.weatherRepository + } +} + +// MARK: - TravelToolBuildable + +public protocol TravelToolBuildable: Buildable { + func build(withListener listener: TravelToolListener) -> TravelToolRouting +} + +// MARK: - TravelToolBuilder + +public final class TravelToolBuilder: Builder, TravelToolBuildable { + + public override init(dependency: TravelToolDependency) { + super.init(dependency: dependency) + } + + public func build(withListener listener: TravelToolListener) -> TravelToolRouting { + let component = TravelToolComponent(dependency: dependency) + let viewController = TravelToolViewController() + let interactor = TravelToolInteractor( + presenter: viewController, + usecase: component.homeUsecase, + weatherRepository: component.weatherRepository + ) + interactor.listener = listener + + let router = TravelToolRouter( + interactor: interactor, + viewController: viewController + ) + + return router + } +} diff --git a/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift b/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift new file mode 100644 index 0000000..58a0183 --- /dev/null +++ b/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift @@ -0,0 +1,214 @@ +// +// TravelToolInteractor.swift +// TravelToolFeature +// +// Created by kimnahun on 2026-02-21. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +import Domain +import DSKit +import RIBs +import RxSwift + +// MARK: - TravelToolListener + +public protocol TravelToolListener: AnyObject { +} + +// MARK: - TravelToolPresentable + +protocol TravelToolPresentable: Presentable { + var listener: TravelToolPresentableListener? { get set } + + func updateTripCard(_ state: TravelToolTripState) + func updateWeather(_ state: TravelToolWeatherState) +} + +// MARK: - TravelToolPresentableListener + +protocol TravelToolPresentableListener: AnyObject { + func viewWillAppear() +} + +// MARK: - TravelToolInteractor + +final class TravelToolInteractor: PresentableInteractor, TravelToolInteractable { + + weak var router: TravelToolRouting? + weak var listener: TravelToolListener? + + private let usecase: HomeUsecaseProtocol + private let weatherRepository: WeatherRepositoryInterface + private var fetchTask: Task? + private let disposeBag = DisposeBag() + + init( + presenter: TravelToolPresentable, + usecase: HomeUsecaseProtocol, + weatherRepository: WeatherRepositoryInterface + ) { + self.usecase = usecase + self.weatherRepository = weatherRepository + super.init(presenter: presenter) + presenter.listener = self + } + + override func didBecomeActive() { + super.didBecomeActive() + + fetchTripInfo() + } + + override func willResignActive() { + super.willResignActive() + + fetchTask?.cancel() + fetchTask = nil + } + + private func fetchTripInfo() { + fetchTask?.cancel() + + fetchTask = Task { [weak self] in + guard let self else { return } + + // 1. 여행 정보 조회 + var summary: MyTripSummary? + do { + summary = try await self.usecase.fetchMyTripInfo() + } catch { + summary = nil + } + + guard !Task.isCancelled else { return } + + // 2. 트립 카드 업데이트 + let tripState: TravelToolTripState + if let summary { + tripState = self.convertToState(summary) + } else { + tripState = .empty + } + + await MainActor.run { [tripState] in + self.presenter.updateTripCard(tripState) + } + + // 3. 여행이 없으면 noTrip 상태 + guard let summary else { + await MainActor.run { + self.presenter.updateWeather(.noTrip) + } + return + } + + guard !Task.isCancelled else { return } + + // 4. 여행 기간 일수 계산 + let calendar = Calendar.current + let startOfToday = calendar.startOfDay(for: Date()) + let startOfEnd = calendar.startOfDay(for: summary.endDay) + let daysFromToday = (calendar.dateComponents([.day], from: startOfToday, to: startOfEnd).day ?? 0) + 1 + + guard daysFromToday > 0 else { + await MainActor.run { + self.presenter.updateWeather(.preparing) + } + return + } + + // 5. 날씨 예보 조회 + let forecastDays = min(daysFromToday, 10) + var forecasts: [DailyWeatherInfo]? + + do { + let all = try await self.weatherRepository.fetchForecast( + latitude: summary.tripSchedule.latitude, + longitude: summary.tripSchedule.longitude, + days: forecastDays + ) + + let startOfTravel = calendar.startOfDay(for: summary.startDay) + let endOfTravel = calendar.startOfDay(for: summary.endDay) + + let filtered = all.filter { info in + let day = calendar.startOfDay(for: info.date) + return day >= startOfTravel && day <= endOfTravel + } + + forecasts = filtered.isEmpty ? nil : filtered + } catch { + forecasts = nil + } + + guard !Task.isCancelled else { return } + + // 6. 날씨 뷰 업데이트 + let city = summary.city + await MainActor.run { [forecasts] in + if let forecasts { + self.presenter.updateWeather( + .hasWeather(title: city, forecasts: forecasts) + ) + } else { + self.presenter.updateWeather(.preparing) + } + } + } + } + + private func convertToState(_ summary: MyTripSummary) -> TravelToolTripState { + let calendar = Calendar.current + let now = Date() + let startOfToday = calendar.startOfDay(for: now) + let startOfTravel = calendar.startOfDay(for: summary.startDay) + let startOfEnd = calendar.startOfDay(for: summary.endDay) + + let duration = "\(summary.startDay.toTravelToolKoreanMMdd())~\(summary.endDay.toTravelToolKoreanMMdd())" + + if startOfToday >= startOfTravel && startOfToday <= startOfEnd { + let schedule = summary.tripSchedule + return .onGoing( + title: "\(summary.title) \(schedule.day)일차 입니다!", + date: duration, + transportIcon: DSKitAsset.Assets.icBus2.image, + transport: "대중교통", + duration: "\(schedule.estimatedDuration)분", + place: schedule.placeName, + imageUrl: schedule.thumbnailUrl + ) + } else if startOfToday < startOfTravel { + let dDayValue = calendar.dateComponents([.day], from: startOfToday, to: startOfTravel).day ?? 0 + return .upComing( + title: summary.title, + date: duration, + dDay: dDayValue, + imageUrl: summary.tripSchedule.thumbnailUrl + ) + } else { + return .empty + } + } +} + +// MARK: - TravelToolPresentableListener + +extension TravelToolInteractor: TravelToolPresentableListener { + func viewWillAppear() { + fetchTripInfo() + } +} + +// MARK: - Date Extension + +extension Date { + func toTravelToolKoreanMMdd() -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "M월 d일" + return formatter.string(from: self) + } +} diff --git a/Projects/Features/TravelToolFeature/Sources/TravelToolRouter.swift b/Projects/Features/TravelToolFeature/Sources/TravelToolRouter.swift new file mode 100644 index 0000000..b374110 --- /dev/null +++ b/Projects/Features/TravelToolFeature/Sources/TravelToolRouter.swift @@ -0,0 +1,39 @@ +// +// TravelToolRouter.swift +// TravelToolFeature +// +// Created by kimnahun on 2026-02-21. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import RIBs + +// MARK: - TravelToolInteractable + +protocol TravelToolInteractable: Interactable { + var router: TravelToolRouting? { get set } + var listener: TravelToolListener? { get set } +} + +// MARK: - TravelToolViewControllable + +public protocol TravelToolViewControllable: ViewControllable { +} + +// MARK: - TravelToolRouting + +public protocol TravelToolRouting: ViewableRouting { +} + +// MARK: - TravelToolRouter + +final class TravelToolRouter: ViewableRouter, TravelToolRouting { + + override init( + interactor: TravelToolInteractable, + viewController: TravelToolViewControllable + ) { + super.init(interactor: interactor, viewController: viewController) + interactor.router = self + } +} diff --git a/Projects/Features/TravelToolFeature/Sources/TravelToolViewController.swift b/Projects/Features/TravelToolFeature/Sources/TravelToolViewController.swift new file mode 100644 index 0000000..702c3fb --- /dev/null +++ b/Projects/Features/TravelToolFeature/Sources/TravelToolViewController.swift @@ -0,0 +1,76 @@ +// +// TravelToolViewController.swift +// TravelToolFeature +// +// Created by kimnahun on 2026-02-21. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import RIBs +import UIKit + +import Domain +import DSKit + +// MARK: - TravelToolViewController + +final class TravelToolViewController: UIViewController, TravelToolPresentable, TravelToolViewControllable { + + // MARK: - Properties + + weak var listener: TravelToolPresentableListener? + + // MARK: - UI + + private let tripCardView = TravelToolTripCardView() + private let weatherView = TravelToolWeatherView() + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + setStyle() + setUI() + setLayout() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + listener?.viewWillAppear() + } + + // MARK: - TravelToolPresentable + + func updateTripCard(_ state: TravelToolTripState) { + tripCardView.configure(state) + } + + func updateWeather(_ state: TravelToolWeatherState) { + weatherView.configure(state) + } +} + +// MARK: - Private + +private extension TravelToolViewController { + func setStyle() { + view.backgroundColor = .white + } + + func setUI() { + view.addSubviews(tripCardView, weatherView) + } + + func setLayout() { + tripCardView.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide) + $0.directionalHorizontalEdges.equalToSuperview().inset(24.adjusted) + } + + weatherView.snp.makeConstraints { + $0.top.equalTo(tripCardView.snp.bottom).offset(16.adjustedH) + $0.directionalHorizontalEdges.equalToSuperview().inset(24.adjusted) + } + } +} diff --git a/Projects/Features/TravelToolFeature/Sources/Views/TravelToolEmptyView.swift b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolEmptyView.swift new file mode 100644 index 0000000..4f99ef1 --- /dev/null +++ b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolEmptyView.swift @@ -0,0 +1,81 @@ +// +// TravelToolEmptyView.swift +// TravelToolFeature +// +// Created by kimnahun on 2026-02-21. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +final class TravelToolEmptyView: UIView { + private let titleLabel = UILabel() + private let subTitleLabel = UILabel() + 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 TravelToolEmptyView { + 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 + ) + } + + imageView.do { + $0.image = DSKitAsset.Assets.icEmptyTrip.image + $0.contentMode = .scaleAspectFit + } + } + + func setUI() { + addSubviews(titleLabel, subTitleLabel, imageView) + } + + func setLayout() { + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(17.5.adjustedH) + $0.leading.equalToSuperview().inset(16.adjusted) + $0.trailing.lessThanOrEqualTo(imageView.snp.leading) + } + + subTitleLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(6.adjustedH) + $0.leading.equalToSuperview().inset(16.adjusted) + $0.trailing.lessThanOrEqualTo(imageView.snp.leading) + $0.bottom.equalToSuperview().inset(17.5.adjustedH) + } + + imageView.snp.makeConstraints { + $0.trailing.equalToSuperview().inset(16.adjusted) + $0.centerY.equalToSuperview() + $0.size.equalTo(76.adjustedH) + } + } +} diff --git a/Projects/Features/TravelToolFeature/Sources/Views/TravelToolTripCardView.swift b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolTripCardView.swift new file mode 100644 index 0000000..92a9152 --- /dev/null +++ b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolTripCardView.swift @@ -0,0 +1,92 @@ +// +// TravelToolTripCardView.swift +// TravelToolFeature +// +// Created by kimnahun on 2026-02-21. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +enum TravelToolTripState { + case empty + case upComing(title: String, date: String, dDay: Int, imageUrl: String) + case onGoing( + title: String, + date: String, + transportIcon: UIImage?, + transport: String, + duration: String, + place: String, + imageUrl: String + ) +} + +final class TravelToolTripCardView: UIView { + private let emptyView = TravelToolEmptyView() + private let upComingView = NDGLUpComingView() + private let onGoingView = NDGLOnGoingView() + 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(_ state: TravelToolTripState) { + [emptyView, upComingView, onGoingView].forEach { $0.isHidden = true } + + switch state { + case .empty: + emptyView.isHidden = false + case .upComing(let title, let date, let dDay, let imageUrl): + upComingView.isHidden = false + upComingView.configure(title: title, date: date, dDay: dDay, imageUrl: imageUrl) + case .onGoing(let title, let date, let transportIcon, let transport, let duration, let place, let imageUrl): + onGoingView.isHidden = false + onGoingView.configure( + title: title, + date: date, + transportIcon: transportIcon, + transport: transport, + duration: duration, + place: place, + imageUrl: imageUrl + ) + } + } +} + +private extension TravelToolTripCardView { + func setStyle() { + backgroundColor = DSKitAsset.Colors.black50.color + layer.cornerRadius = 8.adjustedH + clipsToBounds = true + + stackView.do { + $0.axis = .vertical + $0.alignment = .fill + $0.distribution = .fill + } + } + + func setUI() { + addSubview(stackView) + stackView.addArrangedSubviews(emptyView, upComingView, onGoingView) + } + + func setLayout() { + stackView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } +} diff --git a/Projects/Features/TravelToolFeature/Sources/Views/TravelToolWeatherView.swift b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolWeatherView.swift new file mode 100644 index 0000000..2c97e27 --- /dev/null +++ b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolWeatherView.swift @@ -0,0 +1,341 @@ +// +// TravelToolWeatherView.swift +// TravelToolFeature +// +// Created by kimnahun on 2026-02-21. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import Domain +import DSKit + +// MARK: - Weather State + +enum TravelToolWeatherState { + case noTrip + case preparing + case hasWeather(title: String, forecasts: [DailyWeatherInfo]) +} + +// MARK: - TravelToolWeatherView + +final class TravelToolWeatherView: UIView { + private let titleLabel = UILabel() + private let contentStackView = UIStackView() + private let noTripView = WeatherEmptyContentView( + message: "아직 예정된 여행이 없어요.", + subMessage: "따라가기 영상을 담아두면 여행 준비가 쉬워져요." + ) + private let preparingView = WeatherEmptyContentView( + message: "아직 날씨 정보를 준비하고 있어요.", + subMessage: nil + ) + private let collectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.minimumInteritemSpacing = 0 + layout.minimumLineSpacing = 0 + let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) + cv.showsHorizontalScrollIndicator = false + cv.backgroundColor = .clear + cv.register(WeatherDayCell.self, forCellWithReuseIdentifier: WeatherDayCell.identifier) + return cv + }() + + private var forecasts: [DailyWeatherInfo] = [] + + override init(frame: CGRect) { + super.init(frame: frame) + + setStyle() + setUI() + setLayout() + collectionView.dataSource = self + collectionView.delegate = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(_ state: TravelToolWeatherState) { + noTripView.isHidden = true + preparingView.isHidden = true + collectionView.isHidden = true + + switch state { + case .noTrip: + titleLabel.setText(.subTitleMSB, text: "여행 중 날씨", color: DSKitAsset.Colors.black700.color) + noTripView.isHidden = false + + case .preparing: + titleLabel.setText(.subTitleMSB, text: "여행 중 날씨", color: DSKitAsset.Colors.black700.color) + preparingView.isHidden = false + + case .hasWeather(let title, let forecasts): + titleLabel.setText(.subTitleMSB, text: "\(title) 여행 중 날씨", color: DSKitAsset.Colors.black700.color) + self.forecasts = forecasts + collectionView.isHidden = false + collectionView.reloadData() + } + } +} + +// MARK: - Private + +private extension TravelToolWeatherView { + func setStyle() { + backgroundColor = .clear + + titleLabel.do { + $0.numberOfLines = 1 + $0.lineBreakMode = .byTruncatingTail + } + + contentStackView.do { + $0.axis = .vertical + $0.alignment = .fill + $0.distribution = .fill + } + } + + func setUI() { + addSubviews(titleLabel, contentStackView) + contentStackView.addArrangedSubviews(noTripView, preparingView, collectionView) + } + + func setLayout() { + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(20.adjustedH) + $0.leading.equalToSuperview().inset(20.adjusted) + $0.trailing.lessThanOrEqualToSuperview().inset(20.adjusted) + } + + contentStackView.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(16.adjustedH) + $0.directionalHorizontalEdges.equalToSuperview() + $0.bottom.equalToSuperview().inset(20.adjustedH) + } + + collectionView.snp.makeConstraints { + $0.height.equalTo(140.adjustedH) + } + } +} + +// MARK: - UICollectionViewDataSource + +extension TravelToolWeatherView: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + forecasts.count + } + + func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: WeatherDayCell.identifier, + for: indexPath + ) as? WeatherDayCell else { + return UICollectionViewCell() + } + cell.configure(with: forecasts[indexPath.item]) + return cell + } +} + +// MARK: - UICollectionViewDelegateFlowLayout + +extension TravelToolWeatherView: UICollectionViewDelegateFlowLayout { + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + CGSize(width: 100.adjusted, height: 140.adjustedH) + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + insetForSectionAt section: Int + ) -> UIEdgeInsets { + UIEdgeInsets(top: 0, left: 20.adjusted, bottom: 0, right: 20.adjusted) + } +} + +// MARK: - WeatherDayCell + +final class WeatherDayCell: UICollectionViewCell { + static let identifier = "WeatherDayCell" + + private let dateLabel = UILabel() + private let dayOfWeekLabel = UILabel() + private let iconImageView = UIImageView() + private let tempLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + setStyle() + setUI() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(with info: DailyWeatherInfo) { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "ko_KR") + + dateFormatter.dateFormat = "MM.dd" + dateLabel.setText(.bodyLSB, text: dateFormatter.string(from: info.date), color: DSKitAsset.Colors.black700.color) + + dateFormatter.dateFormat = "EEEE" + dayOfWeekLabel.setText(.bodySR, text: dateFormatter.string(from: info.date), color: DSKitAsset.Colors.black400.color) + + tempLabel.setText( + .bodyMR, + text: "\(Int(info.maxTemperature))° / \(Int(info.minTemperature))°", + color: DSKitAsset.Colors.black600.color + ) + + iconImageView.image = WeatherIconMapper.icon(for: info.weatherType) + } + + private func setStyle() { + iconImageView.do { + $0.contentMode = .scaleAspectFit + } + + dateLabel.textAlignment = .center + dayOfWeekLabel.textAlignment = .center + tempLabel.textAlignment = .center + } + + private func setUI() { + contentView.addSubviews(dateLabel, dayOfWeekLabel, iconImageView, tempLabel) + } + + private func setLayout() { + dateLabel.snp.makeConstraints { + $0.top.equalToSuperview() + $0.centerX.equalToSuperview() + } + + dayOfWeekLabel.snp.makeConstraints { + $0.top.equalTo(dateLabel.snp.bottom).offset(2.adjustedH) + $0.centerX.equalToSuperview() + } + + iconImageView.snp.makeConstraints { + $0.top.equalTo(dayOfWeekLabel.snp.bottom).offset(8.adjustedH) + $0.centerX.equalToSuperview() + $0.size.equalTo(48.adjustedH) + } + + tempLabel.snp.makeConstraints { + $0.top.equalTo(iconImageView.snp.bottom).offset(8.adjustedH) + $0.centerX.equalToSuperview() + } + } +} + +// MARK: - WeatherEmptyContentView + +final class WeatherEmptyContentView: UIView { + private let imageView = UIImageView() + private let messageLabel = UILabel() + private let subMessageLabel = UILabel() + private let stackView = UIStackView() + + init(message: String, subMessage: String?) { + super.init(frame: .zero) + + setStyle(message: message, subMessage: subMessage) + setUI(hasSubMessage: subMessage != nil) + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setStyle(message: String, subMessage: String?) { + imageView.do { + $0.image = DSKitAsset.Assets.icEmptyTrip.image + $0.contentMode = .scaleAspectFit + } + + messageLabel.do { + $0.setText(.bodyLSB, text: message, color: DSKitAsset.Colors.black700.color) + $0.textAlignment = .center + } + + stackView.do { + $0.axis = .vertical + $0.alignment = .center + $0.spacing = 4.adjustedH + } + + if let subMessage { + subMessageLabel.do { + $0.setText(.bodyMR, text: subMessage, color: DSKitAsset.Colors.black400.color) + $0.textAlignment = .center + } + } + } + + private func setUI(hasSubMessage: Bool) { + addSubview(stackView) + stackView.addArrangedSubviews(imageView, messageLabel) + if hasSubMessage { + stackView.addArrangedSubview(subMessageLabel) + } + } + + private func setLayout() { + imageView.snp.makeConstraints { + $0.size.equalTo(120.adjustedH) + } + + stackView.snp.makeConstraints { + $0.top.equalToSuperview().inset(8.adjustedH) + $0.centerX.equalToSuperview() + $0.bottom.equalToSuperview().inset(8.adjustedH) + } + } +} + +// MARK: - WeatherIconMapper + +enum WeatherIconMapper { + static func icon(for type: String) -> UIImage { + switch type { + case "CLEAR": + return DSKitAsset.Assets.icWeatherSunny.image + case "MOSTLY_CLEAR": + return DSKitAsset.Assets.icWeatherSunClouds01.image + case "PARTLY_CLOUDY": + return DSKitAsset.Assets.icWeatherSunClouds01.image + case "MOSTLY_CLOUDY": + return DSKitAsset.Assets.icWeatherSunClouds02.image + case "CLOUDY", "FOGGY": + return DSKitAsset.Assets.icWeatherCloud.image + case "LIGHT_RAIN", "SCATTERED_SHOWERS": + return DSKitAsset.Assets.icWeatherSunRain.image + case "RAIN", "HEAVY_RAIN", "SHOWERS": + return DSKitAsset.Assets.icWeatherRain.image + case "LIGHT_SNOW", "SNOW", "HEAVY_SNOW", "BLIZZARD", "FLURRIES": + return DSKitAsset.Assets.icWeatherCloud.image + case "THUNDERSTORM", "THUNDERSTORMS": + return DSKitAsset.Assets.icWeatherThunder.image + default: + return DSKitAsset.Assets.icWeatherCloud.image + } + } +} diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_cloud.imageset/Contents.json b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_cloud.imageset/Contents.json new file mode 100644 index 0000000..d94c488 --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_cloud.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "ic_weather_cloud.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_cloud.imageset/ic_weather_cloud.svg b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_cloud.imageset/ic_weather_cloud.svg new file mode 100644 index 0000000..fba0b2a --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_cloud.imageset/ic_weather_cloud.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon.imageset/Contents.json b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon.imageset/Contents.json new file mode 100644 index 0000000..d4f9226 --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "ic_weather_moon.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon.imageset/ic_weather_moon.svg b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon.imageset/ic_weather_moon.svg new file mode 100644 index 0000000..7c6bb7c --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon.imageset/ic_weather_moon.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_clouds.imageset/Contents.json b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_clouds.imageset/Contents.json new file mode 100644 index 0000000..b2a7ee3 --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_clouds.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "ic_weather_moon_clouds.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_clouds.imageset/ic_weather_moon_clouds.svg b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_clouds.imageset/ic_weather_moon_clouds.svg new file mode 100644 index 0000000..eaeb2e0 --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_clouds.imageset/ic_weather_moon_clouds.svg @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_rain.imageset/Contents.json b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_rain.imageset/Contents.json new file mode 100644 index 0000000..3d59864 --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_rain.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "ic_weather_moon_rain.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_rain.imageset/ic_weather_moon_rain.svg b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_rain.imageset/ic_weather_moon_rain.svg new file mode 100644 index 0000000..3c9099f --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_rain.imageset/ic_weather_moon_rain.svg @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_rain.imageset/Contents.json b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_rain.imageset/Contents.json new file mode 100644 index 0000000..2eddb6e --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_rain.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "ic_weather_rain.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_rain.imageset/ic_weather_rain.svg b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_rain.imageset/ic_weather_rain.svg new file mode 100644 index 0000000..6fd0c92 --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_rain.imageset/ic_weather_rain.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds01.imageset/Contents.json b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds01.imageset/Contents.json new file mode 100644 index 0000000..c9b19c1 --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds01.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "ic_weather_sun_clouds01.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds01.imageset/ic_weather_sun_clouds01.svg b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds01.imageset/ic_weather_sun_clouds01.svg new file mode 100644 index 0000000..f15ed70 --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds01.imageset/ic_weather_sun_clouds01.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds02.imageset/Contents.json b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds02.imageset/Contents.json new file mode 100644 index 0000000..5e51ea4 --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds02.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "ic_weather_sun_clouds02.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds02.imageset/ic_weather_sun_clouds02.svg b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds02.imageset/ic_weather_sun_clouds02.svg new file mode 100644 index 0000000..0b6747c --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds02.imageset/ic_weather_sun_clouds02.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_rain.imageset/Contents.json b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_rain.imageset/Contents.json new file mode 100644 index 0000000..0909d46 --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_rain.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "ic_weather_sun_rain.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_rain.imageset/ic_weather_sun_rain.svg b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_rain.imageset/ic_weather_sun_rain.svg new file mode 100644 index 0000000..1f15c77 --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_rain.imageset/ic_weather_sun_rain.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sunny.imageset/Contents.json b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sunny.imageset/Contents.json new file mode 100644 index 0000000..2a8713f --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sunny.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "ic_weather_sunny.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sunny.imageset/ic_weather_sunny.svg b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sunny.imageset/ic_weather_sunny.svg new file mode 100644 index 0000000..f333a70 --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sunny.imageset/ic_weather_sunny.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_thunder.imageset/Contents.json b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_thunder.imageset/Contents.json new file mode 100644 index 0000000..07faeba --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_thunder.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "ic_weather_thunder.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_thunder.imageset/ic_weather_thunder.svg b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_thunder.imageset/ic_weather_thunder.svg new file mode 100644 index 0000000..7b46233 --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_thunder.imageset/ic_weather_thunder.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Modules/DSKit/Sources/Component/NDGLNavigationBar.swift b/Projects/Modules/DSKit/Sources/Component/NDGLNavigationBar.swift index b0a1f72..6c2d27f 100644 --- a/Projects/Modules/DSKit/Sources/Component/NDGLNavigationBar.swift +++ b/Projects/Modules/DSKit/Sources/Component/NDGLNavigationBar.swift @@ -168,7 +168,7 @@ private extension NDGLNavigationBar { } containerStackView.snp.makeConstraints { - $0.directionalHorizontalEdges.equalToSuperview().inset(24.adjusted) + $0.directionalHorizontalEdges.equalToSuperview().inset(14.adjusted) $0.directionalVerticalEdges.equalToSuperview().inset(4.adjustedH) } diff --git a/Projects/Features/HomeFeature/Sources/Views/Component/HomeBannerOnGoingView.swift b/Projects/Modules/DSKit/Sources/Component/NDGLOnGoingView.swift similarity index 91% rename from Projects/Features/HomeFeature/Sources/Views/Component/HomeBannerOnGoingView.swift rename to Projects/Modules/DSKit/Sources/Component/NDGLOnGoingView.swift index 7c75970..2c47b69 100644 --- a/Projects/Features/HomeFeature/Sources/Views/Component/HomeBannerOnGoingView.swift +++ b/Projects/Modules/DSKit/Sources/Component/NDGLOnGoingView.swift @@ -1,15 +1,18 @@ // -// HomeBannerOnGoingView.swift -// HomeFeature +// NDGLOnGoingView.swift +// DSKit // -// Created by 최안용 on 2/3/26. +// Created by kimnahun on 2026-02-22. // Copyright © 2026 NDGL-iOS. All rights reserved. // import UIKit -import DSKit -final class HomeBannerOnGoingView: UIView { +import Kingfisher +import SnapKit +import Then + +public final class NDGLOnGoingView: UIView { private let titleLabel = UILabel() private let dateLabel = UILabel() private let iconImageView = UIImageView() @@ -24,20 +27,20 @@ final class HomeBannerOnGoingView: UIView { private let routeStackView = UIStackView() private let routeCardView = UIView() private let containerStackView = UIStackView() - - override init(frame: CGRect) { + + override public init(frame: CGRect) { super.init(frame: frame) - + setStyle() setUI() setLayout() } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - func configure( + + public func configure( title: String, date: String, transportIcon: UIImage?, @@ -52,15 +55,15 @@ final class HomeBannerOnGoingView: UIView { 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() { + + public func prepareForReuse() { imageView.kf.cancelDownloadTask() titleLabel.text = nil dateLabel.text = nil @@ -72,61 +75,61 @@ final class HomeBannerOnGoingView: UIView { } } -private extension HomeBannerOnGoingView { +private extension NDGLOnGoingView { 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) @@ -136,30 +139,30 @@ private extension HomeBannerOnGoingView { 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) diff --git a/Projects/Features/HomeFeature/Sources/Views/Component/HomeBannerUpCommingView.swift b/Projects/Modules/DSKit/Sources/Component/NDGLUpComingView.swift similarity index 87% rename from Projects/Features/HomeFeature/Sources/Views/Component/HomeBannerUpCommingView.swift rename to Projects/Modules/DSKit/Sources/Component/NDGLUpComingView.swift index b1756bb..d01c1bd 100644 --- a/Projects/Features/HomeFeature/Sources/Views/Component/HomeBannerUpCommingView.swift +++ b/Projects/Modules/DSKit/Sources/Component/NDGLUpComingView.swift @@ -1,15 +1,18 @@ // -// HomeBannerUpCommingView.swift -// HomeFeature +// NDGLUpComingView.swift +// DSKit // -// Created by 최안용 on 2/3/26. +// Created by kimnahun on 2026-02-22. // Copyright © 2026 NDGL-iOS. All rights reserved. // import UIKit -import DSKit -final class HomeBannerUpCommingView: UIView { +import Kingfisher +import SnapKit +import Then + +public final class NDGLUpComingView: UIView { private let imageView = UIImageView() private let badge = UIView() private let dDayLabel = UILabel() @@ -18,32 +21,32 @@ final class HomeBannerUpCommingView: UIView { private let titleStackView = UIStackView() private let infoStackView = UIStackView() private let stackView = UIStackView() - - override init(frame: CGRect) { + + override public 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) { + + public 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() { + + public func prepareForReuse() { imageView.kf.cancelDownloadTask() titleLabel.text = nil dateLabel.text = nil @@ -52,16 +55,16 @@ final class HomeBannerUpCommingView: UIView { } } -private extension HomeBannerUpCommingView { +private extension NDGLUpComingView { 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 @@ -69,31 +72,31 @@ private extension HomeBannerUpCommingView { $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) @@ -101,17 +104,17 @@ private extension HomeBannerUpCommingView { 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/Modules/Networks/Sources/DTO/Weather/WeatherResponse.swift b/Projects/Modules/Networks/Sources/DTO/Weather/WeatherResponse.swift new file mode 100644 index 0000000..b6ea517 --- /dev/null +++ b/Projects/Modules/Networks/Sources/DTO/Weather/WeatherResponse.swift @@ -0,0 +1,48 @@ +// +// WeatherResponse.swift +// Networks +// +// Created by kimnahun on 2026-02-21. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +// MARK: - Forecast Response + +public struct ForecastResponse: Decodable { + public let forecastDays: [ForecastDayResponse] +} + +public struct ForecastDayResponse: Decodable { + public let displayDate: DateResponse + public let daytimeForecast: ForecastPeriodResponse? + public let maxTemperature: TemperatureResponse? + public let minTemperature: TemperatureResponse? +} + +public struct DateResponse: Decodable { + public let year: Int + public let month: Int + public let day: Int +} + +public struct ForecastPeriodResponse: Decodable { + public let weatherCondition: WeatherConditionResponse +} + +public struct WeatherConditionResponse: Decodable { + public let type: String? + public let iconBaseUri: String? + public let icon: String? + public let description: WeatherDescriptionResponse? +} + +public struct WeatherDescriptionResponse: Decodable { + public let text: String +} + +public struct TemperatureResponse: Decodable { + public let degrees: Double + public let unit: String? +} diff --git a/Projects/Modules/Networks/Sources/Extensions/MoyaProvider+Async.swift b/Projects/Modules/Networks/Sources/Extensions/MoyaProvider+Async.swift index d977a0c..88f32ce 100644 --- a/Projects/Modules/Networks/Sources/Extensions/MoyaProvider+Async.swift +++ b/Projects/Modules/Networks/Sources/Extensions/MoyaProvider+Async.swift @@ -143,6 +143,37 @@ extension MoyaProvider { } } + func asyncThrowsRequestRaw(_ 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 { + continuation.resume( + throwing: NetworkError.unknown("Status Code: \(response.statusCode)") + ) + return + } + + do { + let decoded = try JSONDecoder().decode(T.self, from: response.data) + continuation.resume(returning: decoded) + } catch { + NetworkLogger.logDecodingError(error, data: response.data) + 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 e6ed600..93703f9 100644 --- a/Projects/Modules/Networks/Sources/Foundation/NetworkConfiguration.swift +++ b/Projects/Modules/Networks/Sources/Foundation/NetworkConfiguration.swift @@ -27,4 +27,12 @@ public enum NetworkConfiguration { } return apiHeader } + + public static var weatherApiKey: String { + let bundle = Bundle.main + guard let key = bundle.infoDictionary?["GOOGLE_WEATHER_API_KEY"] as? String else { + fatalError("GOOGLE_WEATHER_API_KEY not found in Info.plist") + } + return key + } } diff --git a/Projects/Modules/Networks/Sources/Service/WeatherService.swift b/Projects/Modules/Networks/Sources/Service/WeatherService.swift new file mode 100644 index 0000000..d311974 --- /dev/null +++ b/Projects/Modules/Networks/Sources/Service/WeatherService.swift @@ -0,0 +1,28 @@ +// +// WeatherService.swift +// Networks +// +// Created by kimnahun on 2026-02-21. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation +import Moya + +public protocol WeatherServiceProtocol { + func getForecast(latitude: Double, longitude: Double, days: Int) async throws -> ForecastResponse +} + +public final class WeatherService: WeatherServiceProtocol { + private let provider: MoyaProvider + + public init(provider: MoyaProvider = MoyaProvider()) { + self.provider = provider + } + + public func getForecast(latitude: Double, longitude: Double, days: Int) async throws -> ForecastResponse { + try await provider.asyncThrowsRequestRaw( + .getForecast(latitude: latitude, longitude: longitude, days: days) + ) + } +} diff --git a/Projects/Modules/Networks/Sources/TargetType/WeatherAPI.swift b/Projects/Modules/Networks/Sources/TargetType/WeatherAPI.swift new file mode 100644 index 0000000..88b587b --- /dev/null +++ b/Projects/Modules/Networks/Sources/TargetType/WeatherAPI.swift @@ -0,0 +1,53 @@ +// +// WeatherAPI.swift +// Networks +// +// Created by kimnahun on 2026-02-21. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation +import Moya + +public enum WeatherAPI { + case getForecast(latitude: Double, longitude: Double, days: Int) +} + +extension WeatherAPI: TargetType { + public var baseURL: URL { + URL(string: "https://weather.googleapis.com")! + } + + public var path: String { + switch self { + case .getForecast: + return "/v1/forecast/days:lookup" + } + } + + public var method: Moya.Method { + switch self { + case .getForecast: + return .get + } + } + + public var task: Moya.Task { + switch self { + case .getForecast(let latitude, let longitude, let days): + return .requestParameters( + parameters: [ + "key": NetworkConfiguration.weatherApiKey, + "location.latitude": latitude, + "location.longitude": longitude, + "days": days + ], + encoding: URLEncoding.queryString + ) + } + } + + public var headers: [String: String]? { + ["Content-Type": "application/json"] + } +}