diff --git a/WeatherForecast/WeatherForecast.xcodeproj/project.pbxproj b/WeatherForecast/WeatherForecast.xcodeproj/project.pbxproj index 9b2f170..ebdf95f 100644 --- a/WeatherForecast/WeatherForecast.xcodeproj/project.pbxproj +++ b/WeatherForecast/WeatherForecast.xcodeproj/project.pbxproj @@ -7,11 +7,23 @@ objects = { /* Begin PBXBuildFile section */ + 2468C7752B6BFF1800840F12 /* TempUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2468C7742B6BFF1800840F12 /* TempUnit.swift */; }; + 2468C7782B6BFFA600840F12 /* WeatherJsonService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2468C7772B6BFFA600840F12 /* WeatherJsonService.swift */; }; + 2468C77B2B6C013D00840F12 /* JsonService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2468C77A2B6C013D00840F12 /* JsonService.swift */; }; + 2468C77E2B6C016B00840F12 /* JsonError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2468C77D2B6C016B00840F12 /* JsonError.swift */; }; + 2468C7802B6C03E700840F12 /* NetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2468C77F2B6C03E700840F12 /* NetworkService.swift */; }; + 2468C7822B6C046200840F12 /* WeatherIconImageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2468C7812B6C046200840F12 /* WeatherIconImageService.swift */; }; + 2468C78A2B6C063B00840F12 /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2468C7892B6C063B00840F12 /* NetworkError.swift */; }; + 2468C78D2B6C0F7900840F12 /* MainWeatherListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2468C78C2B6C0F7900840F12 /* MainWeatherListView.swift */; }; + 2468C7A72B6FE76800840F12 /* WeatherDetailInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2468C7A62B6FE76800840F12 /* WeatherDetailInfo.swift */; }; + 2468C7AA2B70376300840F12 /* WeatherDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2468C7A92B70376300840F12 /* WeatherDetailView.swift */; }; + 2468C7AE2B703AF500840F12 /* UILabel+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2468C7AD2B703AF500840F12 /* UILabel+Extension.swift */; }; + 2468C7B02B727B4500840F12 /* TempUnitManagerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2468C7AF2B727B4500840F12 /* TempUnitManagerService.swift */; }; + 2468C7B22B727B5D00840F12 /* TempUnitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2468C7B12B727B5D00840F12 /* TempUnitManager.swift */; }; C741F6702B58F00500A4DDC0 /* Weather.swift in Sources */ = {isa = PBXBuildFile; fileRef = C741F66F2B58F00500A4DDC0 /* Weather.swift */; }; C7743D8D2B21C38100DF0D09 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7743D8C2B21C38100DF0D09 /* AppDelegate.swift */; }; C7743D8F2B21C38100DF0D09 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7743D8E2B21C38100DF0D09 /* SceneDelegate.swift */; }; - C7743D912B21C38100DF0D09 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7743D902B21C38100DF0D09 /* ViewController.swift */; }; - C7743D942B21C38100DF0D09 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C7743D922B21C38100DF0D09 /* Main.storyboard */; }; + C7743D912B21C38100DF0D09 /* MainWeatherListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7743D902B21C38100DF0D09 /* MainWeatherListViewController.swift */; }; C7743D962B21C38200DF0D09 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C7743D952B21C38200DF0D09 /* Assets.xcassets */; }; C7743D992B21C38200DF0D09 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C7743D972B21C38200DF0D09 /* LaunchScreen.storyboard */; }; C7743DA12B21C3B400DF0D09 /* WeatherTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7743DA02B21C3B400DF0D09 /* WeatherTableViewCell.swift */; }; @@ -19,12 +31,24 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 2468C7742B6BFF1800840F12 /* TempUnit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempUnit.swift; sourceTree = ""; }; + 2468C7772B6BFFA600840F12 /* WeatherJsonService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherJsonService.swift; sourceTree = ""; }; + 2468C77A2B6C013D00840F12 /* JsonService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonService.swift; sourceTree = ""; }; + 2468C77D2B6C016B00840F12 /* JsonError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonError.swift; sourceTree = ""; }; + 2468C77F2B6C03E700840F12 /* NetworkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkService.swift; sourceTree = ""; }; + 2468C7812B6C046200840F12 /* WeatherIconImageService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherIconImageService.swift; sourceTree = ""; }; + 2468C7892B6C063B00840F12 /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = ""; }; + 2468C78C2B6C0F7900840F12 /* MainWeatherListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWeatherListView.swift; sourceTree = ""; }; + 2468C7A62B6FE76800840F12 /* WeatherDetailInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherDetailInfo.swift; sourceTree = ""; }; + 2468C7A92B70376300840F12 /* WeatherDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherDetailView.swift; sourceTree = ""; }; + 2468C7AD2B703AF500840F12 /* UILabel+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+Extension.swift"; sourceTree = ""; }; + 2468C7AF2B727B4500840F12 /* TempUnitManagerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempUnitManagerService.swift; sourceTree = ""; }; + 2468C7B12B727B5D00840F12 /* TempUnitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempUnitManager.swift; sourceTree = ""; }; C741F66F2B58F00500A4DDC0 /* Weather.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weather.swift; sourceTree = ""; }; C7743D892B21C38100DF0D09 /* WeatherForecast.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WeatherForecast.app; sourceTree = BUILT_PRODUCTS_DIR; }; C7743D8C2B21C38100DF0D09 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; C7743D8E2B21C38100DF0D09 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - C7743D902B21C38100DF0D09 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; - C7743D932B21C38100DF0D09 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + C7743D902B21C38100DF0D09 /* MainWeatherListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWeatherListViewController.swift; sourceTree = ""; }; C7743D952B21C38200DF0D09 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; C7743D982B21C38200DF0D09 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; C7743D9A2B21C38200DF0D09 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -43,6 +67,147 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 2468C7712B6BFE0200840F12 /* Network */ = { + isa = PBXGroup; + children = ( + 2468C7722B6BFE0900840F12 /* DTO */, + 2468C77C2B6C016500840F12 /* Error */, + 2468C7792B6BFFBC00840F12 /* Interface */, + 2468C7762B6BFF3600840F12 /* Service */, + ); + path = Network; + sourceTree = ""; + }; + 2468C7722B6BFE0900840F12 /* DTO */ = { + isa = PBXGroup; + children = ( + C741F66F2B58F00500A4DDC0 /* Weather.swift */, + ); + path = DTO; + sourceTree = ""; + }; + 2468C7732B6BFF0F00840F12 /* Model */ = { + isa = PBXGroup; + children = ( + 2468C7742B6BFF1800840F12 /* TempUnit.swift */, + 2468C7A62B6FE76800840F12 /* WeatherDetailInfo.swift */, + ); + path = Model; + sourceTree = ""; + }; + 2468C7762B6BFF3600840F12 /* Service */ = { + isa = PBXGroup; + children = ( + 2468C7772B6BFFA600840F12 /* WeatherJsonService.swift */, + 2468C7812B6C046200840F12 /* WeatherIconImageService.swift */, + 2468C7B12B727B5D00840F12 /* TempUnitManager.swift */, + ); + path = Service; + sourceTree = ""; + }; + 2468C7792B6BFFBC00840F12 /* Interface */ = { + isa = PBXGroup; + children = ( + 2468C77A2B6C013D00840F12 /* JsonService.swift */, + 2468C77F2B6C03E700840F12 /* NetworkService.swift */, + 2468C7AF2B727B4500840F12 /* TempUnitManagerService.swift */, + ); + path = Interface; + sourceTree = ""; + }; + 2468C77C2B6C016500840F12 /* Error */ = { + isa = PBXGroup; + children = ( + 2468C77D2B6C016B00840F12 /* JsonError.swift */, + 2468C7892B6C063B00840F12 /* NetworkError.swift */, + ); + path = Error; + sourceTree = ""; + }; + 2468C7832B6C049100840F12 /* Presentation */ = { + isa = PBXGroup; + children = ( + 2468C7842B6C04D000840F12 /* MainWeatherList */, + 2468C7852B6C04D800840F12 /* WeatherDetail */, + ); + path = Presentation; + sourceTree = ""; + }; + 2468C7842B6C04D000840F12 /* MainWeatherList */ = { + isa = PBXGroup; + children = ( + 2468C7882B6C04F800840F12 /* Cell */, + 2468C78B2B6C0F6300840F12 /* View */, + 2468C7872B6C04E900840F12 /* ViewController */, + ); + path = MainWeatherList; + sourceTree = ""; + }; + 2468C7852B6C04D800840F12 /* WeatherDetail */ = { + isa = PBXGroup; + children = ( + 2468C7A82B70375000840F12 /* View */, + 2468C7862B6C04E400840F12 /* ViewController */, + ); + path = WeatherDetail; + sourceTree = ""; + }; + 2468C7862B6C04E400840F12 /* ViewController */ = { + isa = PBXGroup; + children = ( + C7743DA22B21CA8500DF0D09 /* WeatherDetailViewController.swift */, + ); + path = ViewController; + sourceTree = ""; + }; + 2468C7872B6C04E900840F12 /* ViewController */ = { + isa = PBXGroup; + children = ( + C7743D902B21C38100DF0D09 /* MainWeatherListViewController.swift */, + ); + path = ViewController; + sourceTree = ""; + }; + 2468C7882B6C04F800840F12 /* Cell */ = { + isa = PBXGroup; + children = ( + C7743DA02B21C3B400DF0D09 /* WeatherTableViewCell.swift */, + ); + path = Cell; + sourceTree = ""; + }; + 2468C78B2B6C0F6300840F12 /* View */ = { + isa = PBXGroup; + children = ( + 2468C78C2B6C0F7900840F12 /* MainWeatherListView.swift */, + ); + path = View; + sourceTree = ""; + }; + 2468C7A82B70375000840F12 /* View */ = { + isa = PBXGroup; + children = ( + 2468C7A92B70376300840F12 /* WeatherDetailView.swift */, + ); + path = View; + sourceTree = ""; + }; + 2468C7AB2B703AE700840F12 /* Util */ = { + isa = PBXGroup; + children = ( + 2468C7AC2B703AEB00840F12 /* Extension */, + ); + path = Util; + sourceTree = ""; + }; + 2468C7AC2B703AEB00840F12 /* Extension */ = { + isa = PBXGroup; + children = ( + 2468C7AD2B703AF500840F12 /* UILabel+Extension.swift */, + ); + path = Extension; + sourceTree = ""; + }; C7743D802B21C38100DF0D09 = { isa = PBXGroup; children = ( @@ -62,13 +227,12 @@ C7743D8B2B21C38100DF0D09 /* WeatherForecast */ = { isa = PBXGroup; children = ( + 2468C7AB2B703AE700840F12 /* Util */, + 2468C7832B6C049100840F12 /* Presentation */, + 2468C7732B6BFF0F00840F12 /* Model */, + 2468C7712B6BFE0200840F12 /* Network */, C7743D8C2B21C38100DF0D09 /* AppDelegate.swift */, C7743D8E2B21C38100DF0D09 /* SceneDelegate.swift */, - C7743DA02B21C3B400DF0D09 /* WeatherTableViewCell.swift */, - C7743D902B21C38100DF0D09 /* ViewController.swift */, - C741F66F2B58F00500A4DDC0 /* Weather.swift */, - C7743DA22B21CA8500DF0D09 /* WeatherDetailViewController.swift */, - C7743D922B21C38100DF0D09 /* Main.storyboard */, C7743D952B21C38200DF0D09 /* Assets.xcassets */, C7743D972B21C38200DF0D09 /* LaunchScreen.storyboard */, C7743D9A2B21C38200DF0D09 /* Info.plist */, @@ -136,7 +300,6 @@ files = ( C7743D992B21C38200DF0D09 /* LaunchScreen.storyboard in Resources */, C7743D962B21C38200DF0D09 /* Assets.xcassets in Resources */, - C7743D942B21C38100DF0D09 /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -148,25 +311,30 @@ buildActionMask = 2147483647; files = ( C7743DA12B21C3B400DF0D09 /* WeatherTableViewCell.swift in Sources */, - C7743D912B21C38100DF0D09 /* ViewController.swift in Sources */, + 2468C7B02B727B4500840F12 /* TempUnitManagerService.swift in Sources */, + 2468C7B22B727B5D00840F12 /* TempUnitManager.swift in Sources */, + 2468C7822B6C046200840F12 /* WeatherIconImageService.swift in Sources */, + C7743D912B21C38100DF0D09 /* MainWeatherListViewController.swift in Sources */, + 2468C7AE2B703AF500840F12 /* UILabel+Extension.swift in Sources */, + 2468C7AA2B70376300840F12 /* WeatherDetailView.swift in Sources */, + 2468C7782B6BFFA600840F12 /* WeatherJsonService.swift in Sources */, C7743D8D2B21C38100DF0D09 /* AppDelegate.swift in Sources */, + 2468C77B2B6C013D00840F12 /* JsonService.swift in Sources */, C7743DA32B21CA8600DF0D09 /* WeatherDetailViewController.swift in Sources */, + 2468C7752B6BFF1800840F12 /* TempUnit.swift in Sources */, C741F6702B58F00500A4DDC0 /* Weather.swift in Sources */, + 2468C7A72B6FE76800840F12 /* WeatherDetailInfo.swift in Sources */, + 2468C7802B6C03E700840F12 /* NetworkService.swift in Sources */, C7743D8F2B21C38100DF0D09 /* SceneDelegate.swift in Sources */, + 2468C77E2B6C016B00840F12 /* JsonError.swift in Sources */, + 2468C78D2B6C0F7900840F12 /* MainWeatherListView.swift in Sources */, + 2468C78A2B6C063B00840F12 /* NetworkError.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXVariantGroup section */ - C7743D922B21C38100DF0D09 /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - C7743D932B21C38100DF0D09 /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; C7743D972B21C38200DF0D09 /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( @@ -308,7 +476,6 @@ INFOPLIST_FILE = WeatherForecast/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; - INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; LD_RUNPATH_SEARCH_PATHS = ( @@ -335,7 +502,6 @@ INFOPLIST_FILE = WeatherForecast/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; - INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/WeatherForecast/WeatherForecast/Base.lproj/Main.storyboard b/WeatherForecast/WeatherForecast/Base.lproj/Main.storyboard deleted file mode 100644 index 4798dc7..0000000 --- a/WeatherForecast/WeatherForecast/Base.lproj/Main.storyboard +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WeatherForecast/WeatherForecast/Info.plist b/WeatherForecast/WeatherForecast/Info.plist index dd3c9af..0eb786d 100644 --- a/WeatherForecast/WeatherForecast/Info.plist +++ b/WeatherForecast/WeatherForecast/Info.plist @@ -15,8 +15,6 @@ Default Configuration UISceneDelegateClassName $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main diff --git a/WeatherForecast/WeatherForecast/Model/TempUnit.swift b/WeatherForecast/WeatherForecast/Model/TempUnit.swift new file mode 100644 index 0000000..d8dafcb --- /dev/null +++ b/WeatherForecast/WeatherForecast/Model/TempUnit.swift @@ -0,0 +1,30 @@ +// +// TempUnit.swift +// WeatherForecast +// +// Created by MIN SEONG KIM on 2024/02/02. +// + +import Foundation + +// MARK: - Temperature Unit +enum TempUnit: String { + case metric + case imperial + + var expression: String { + switch self { + case .metric: return "℃" + case .imperial: return "℉" + } + } + + var strOpposite: String { + switch self { + case .metric: + return "화씨" + case .imperial: + return "섭씨" + } + } +} diff --git a/WeatherForecast/WeatherForecast/Model/WeatherDetailInfo.swift b/WeatherForecast/WeatherForecast/Model/WeatherDetailInfo.swift new file mode 100644 index 0000000..1694aea --- /dev/null +++ b/WeatherForecast/WeatherForecast/Model/WeatherDetailInfo.swift @@ -0,0 +1,40 @@ +// +// WeatherInfo.swift +// WeatherForecast +// +// Created by MIN SEONG KIM on 2024/02/05. +// + +import Foundation + +struct WeatherDetailInfo { + private let weatherForecastInfo: WeatherForecastInfo + private let cityInfo: City + private let tempUnit: TempUnit + + var date: String { weatherForecastInfo.date } + + var sunrise: String { cityInfo.sunriseString } + var sunset: String { cityInfo.sunsetString } + + var weatherMain: String { weatherForecastInfo.weatherMain } + var description: String { weatherForecastInfo.description } + + var temp: String { "\(weatherForecastInfo.temp)\(tempUnit.expression)"} + var tempMax: String { "\(weatherForecastInfo.tempMax)\(tempUnit.expression)" } + var tempMin: String { "\(weatherForecastInfo.tempMin)\(tempUnit.expression)" } + + var feelsLike: String { "\(weatherForecastInfo.feelsLike)\(tempUnit.expression)" } + var pop: String { weatherForecastInfo.pop } + + var humidity: String { weatherForecastInfo.humidity } + + var iconName: String { weatherForecastInfo.iconName } + + //MARK: - Init + init(weatherForecastInfo: WeatherForecastInfo, cityInfo: City, tempUnit: TempUnit) { + self.weatherForecastInfo = weatherForecastInfo + self.cityInfo = cityInfo + self.tempUnit = tempUnit + } +} diff --git a/WeatherForecast/WeatherForecast/Network/DTO/Weather.swift b/WeatherForecast/WeatherForecast/Network/DTO/Weather.swift new file mode 100644 index 0000000..35ad936 --- /dev/null +++ b/WeatherForecast/WeatherForecast/Network/DTO/Weather.swift @@ -0,0 +1,95 @@ +// +// WeatherForecast - Weather.swift +// Created by yagom. +// Copyright © yagom. All rights reserved. +// + +import Foundation + +// MARK: - Weather JSON Format +struct WeatherJSON: Decodable { + let weatherForecast: [WeatherForecastInfo] + let city: City +} + +// MARK: - List +struct WeatherForecastInfo: Decodable { + let dt: TimeInterval + let main: MainInfo + let weather: Weather + let dtTxt: String + + var date: String { + let formatter: DateFormatter = DateFormatter() + formatter.locale = .init(identifier: "ko_KR") + formatter.dateFormat = "yyyy-MM-dd(EEEEE) a HH:mm" + + return formatter.string(from: Date(timeIntervalSince1970: dt)) + } + + var weatherMain: String { weather.main } + var description: String { weather.description } + + var temp: Double { main.temp } + var tempMax: Double { main.tempMax } + var tempMin: Double { main.tempMin } + var feelsLike: Double { main.feelsLike } + + var pop: String { "\(main.pop * 100)%" } + var humidity: String { "\(main.humidity)%" } + + var iconName: String { weather.icon } +} + +// MARK: - MainClass +struct MainInfo: Decodable { + let temp: Double + let feelsLike: Double + let tempMin: Double + let tempMax: Double + let pressure: Double + let seaLevel: Double + let grndLevel: Double + let humidity: Double + let pop: Double +} + +// MARK: - Weather +struct Weather: Decodable { + let id: Int + let main: String + let description: String + let icon: String +} + +// MARK: - City +struct City: Decodable { + let id: Int + let name: String + let coord: Coord + let country: String + let population, timezone: Int + let sunrise, sunset: TimeInterval + + var sunriseString: String { + let formatter: DateFormatter = DateFormatter() + formatter.dateFormat = .none + formatter.timeStyle = .short + formatter.locale = .init(identifier: "ko_KR") + return formatter.string(from: Date(timeIntervalSince1970: sunrise)) + } + + var sunsetString: String { + let formatter: DateFormatter = DateFormatter() + formatter.dateFormat = .none + formatter.timeStyle = .short + formatter.locale = .init(identifier: "ko_KR") + return formatter.string(from: Date(timeIntervalSince1970: sunset)) + } +} + +// MARK: - Coord +struct Coord: Decodable { + let lat: Double + let lon: Double +} diff --git a/WeatherForecast/WeatherForecast/Network/Error/JsonError.swift b/WeatherForecast/WeatherForecast/Network/Error/JsonError.swift new file mode 100644 index 0000000..451c77f --- /dev/null +++ b/WeatherForecast/WeatherForecast/Network/Error/JsonError.swift @@ -0,0 +1,13 @@ +// +// NetworkError.swift +// WeatherForecast +// +// Created by MIN SEONG KIM on 2024/02/02. +// + +import Foundation + +enum JsonError: Error { + case emptyData // 데이터 미존재 + case failDecode // 디코드 실패 +} diff --git a/WeatherForecast/WeatherForecast/Network/Error/NetworkError.swift b/WeatherForecast/WeatherForecast/Network/Error/NetworkError.swift new file mode 100644 index 0000000..684b418 --- /dev/null +++ b/WeatherForecast/WeatherForecast/Network/Error/NetworkError.swift @@ -0,0 +1,14 @@ +// +// ImageServiceError.swift +// WeatherForecast +// +// Created by MIN SEONG KIM on 2024/02/02. +// + +import Foundation + +enum NetworkError: Error { + case invalidUrl // url 에러 + case networkFail // 응답상태코드 에러 + case invalidData // 유효하지 않은 데이터 +} diff --git a/WeatherForecast/WeatherForecast/Network/Interface/JsonService.swift b/WeatherForecast/WeatherForecast/Network/Interface/JsonService.swift new file mode 100644 index 0000000..4ae684e --- /dev/null +++ b/WeatherForecast/WeatherForecast/Network/Interface/JsonService.swift @@ -0,0 +1,12 @@ +// +// NetworkService.swift +// WeatherForecast +// +// Created by MIN SEONG KIM on 2024/02/02. +// + +import Foundation + +protocol JsonService { + func fetchWeather() async -> Result +} diff --git a/WeatherForecast/WeatherForecast/Network/Interface/NetworkService.swift b/WeatherForecast/WeatherForecast/Network/Interface/NetworkService.swift new file mode 100644 index 0000000..8a288d4 --- /dev/null +++ b/WeatherForecast/WeatherForecast/Network/Interface/NetworkService.swift @@ -0,0 +1,13 @@ +// +// ImageService.swift +// WeatherForecast +// +// Created by MIN SEONG KIM on 2024/02/02. +// + +import UIKit + +protocol NetworkService { + func fetchImage(iconName: String, + urlSession: URLSession) async throws -> UIImage +} diff --git a/WeatherForecast/WeatherForecast/Network/Interface/TempUnitManagerService.swift b/WeatherForecast/WeatherForecast/Network/Interface/TempUnitManagerService.swift new file mode 100644 index 0000000..cd5c9ae --- /dev/null +++ b/WeatherForecast/WeatherForecast/Network/Interface/TempUnitManagerService.swift @@ -0,0 +1,14 @@ +// +// TempUnitManagerService.swift +// WeatherForecast +// +// Created by MIN SEONG KIM on 2024/02/06. +// + +import Foundation + +protocol TempUnitManagerService { + var tempUnit: TempUnit { get } + + func update() +} diff --git a/WeatherForecast/WeatherForecast/Network/Service/TempUnitManager.swift b/WeatherForecast/WeatherForecast/Network/Service/TempUnitManager.swift new file mode 100644 index 0000000..cab3848 --- /dev/null +++ b/WeatherForecast/WeatherForecast/Network/Service/TempUnitManager.swift @@ -0,0 +1,23 @@ +// +// TempUnitManager.swift +// WeatherForecast +// +// Created by MIN SEONG KIM on 2024/02/06. +// + +import Foundation + +final class TempUnitManager: TempUnitManagerService { + var tempUnit: TempUnit = .metric + + func update() { + switch tempUnit { + case .metric: + tempUnit = .imperial + case .imperial: + tempUnit = .metric + } + } + + +} diff --git a/WeatherForecast/WeatherForecast/Network/Service/WeatherIconImageService.swift b/WeatherForecast/WeatherForecast/Network/Service/WeatherIconImageService.swift new file mode 100644 index 0000000..734dd5b --- /dev/null +++ b/WeatherForecast/WeatherForecast/Network/Service/WeatherIconImageService.swift @@ -0,0 +1,34 @@ +// +// WeatherIconImageService.swift +// WeatherForecast +// +// Created by MIN SEONG KIM on 2024/02/02. +// + +import UIKit + +final class WeatherIconImageService: NetworkService { + private let imageChache: NSCache = NSCache() + + func fetchImage(iconName: String, + urlSession: URLSession) async throws -> UIImage { + let urlString: String = "https://openweathermap.org/img/wn/\(iconName)@2x.png" + + guard let url: URL = URL(string: urlString) else { + throw NetworkError.invalidUrl + } + + let (data, response) = try await urlSession.data(from: url) + guard let response = response as? HTTPURLResponse, response.statusCode == 200 else { + throw NetworkError.networkFail + } + + guard let image: UIImage = UIImage(data: data) else { + throw NetworkError.invalidData + } + + imageChache.setObject(image, forKey: urlString as NSString) + + return image + } +} diff --git a/WeatherForecast/WeatherForecast/Network/Service/WeatherJsonService.swift b/WeatherForecast/WeatherForecast/Network/Service/WeatherJsonService.swift new file mode 100644 index 0000000..f746065 --- /dev/null +++ b/WeatherForecast/WeatherForecast/Network/Service/WeatherJsonService.swift @@ -0,0 +1,27 @@ +// +// NetworkService.swift +// WeatherForecast +// +// Created by MIN SEONG KIM on 2024/02/02. +// + +import UIKit + +final class WeatherJsonService: JsonService { + func fetchWeather() async -> Result { + let jsonDecoder: JSONDecoder = .init() + jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase + + guard let data = NSDataAsset(name: "weather")?.data else { + return .failure(.emptyData) + } + + let info: WeatherJSON + do { + info = try jsonDecoder.decode(WeatherJSON.self, from: data) + return .success(info) + } catch { + return .failure(.failDecode) + } + } +} diff --git a/WeatherForecast/WeatherForecast/WeatherTableViewCell.swift b/WeatherForecast/WeatherForecast/Presentation/MainWeatherList/Cell/WeatherTableViewCell.swift similarity index 70% rename from WeatherForecast/WeatherForecast/WeatherTableViewCell.swift rename to WeatherForecast/WeatherForecast/Presentation/MainWeatherList/Cell/WeatherTableViewCell.swift index 42cb519..7817d58 100644 --- a/WeatherForecast/WeatherForecast/WeatherTableViewCell.swift +++ b/WeatherForecast/WeatherForecast/Presentation/MainWeatherList/Cell/WeatherTableViewCell.swift @@ -1,19 +1,28 @@ // // WeatherForecast - WeatherTableViewCell.swift -// Created by yagom. +// Created by yagom. // Copyright © yagom. All rights reserved. -// +// import UIKit -class WeatherTableViewCell: UITableViewCell { - var weatherIcon: UIImageView! - var dateLabel: UILabel! - var temperatureLabel: UILabel! - var weatherLabel: UILabel! - var descriptionLabel: UILabel! +final class WeatherTableViewCell: UITableViewCell { + // MARK: - Properties + static let identifier = String(describing: WeatherTableViewCell.self) + + // MARK: - UI + private var weatherIcon: UIImageView! + private var dateLabel: UILabel! + private var temperatureLabel: UILabel! + private var weatherLabel: UILabel! + private var descriptionLabel: UILabel! + private var imageLoadingTask: Task<(), Never>? - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + // MARK: - Init + override init( + style: UITableViewCell.CellStyle, + reuseIdentifier: String? + ) { super.init(style: style, reuseIdentifier: reuseIdentifier) layViews() reset() @@ -28,6 +37,7 @@ class WeatherTableViewCell: UITableViewCell { reset() } + // MARK: - Layout private func layViews() { weatherIcon = UIImageView() dateLabel = UILabel() @@ -39,9 +49,7 @@ class WeatherTableViewCell: UITableViewCell { let labels: [UILabel] = [dateLabel, temperatureLabel, weatherLabel, dashLabel, descriptionLabel] labels.forEach { label in - label.textColor = .black - label.font = .preferredFont(forTextStyle: .body) - label.numberOfLines = 1 + label.makeLabel() } let weatherStackView: UIStackView = UIStackView(arrangedSubviews: [ @@ -92,12 +100,29 @@ class WeatherTableViewCell: UITableViewCell { weatherIcon.widthAnchor.constraint(equalToConstant: 100) ]) } - + private func reset() { + imageLoadingTask?.cancel() weatherIcon.image = UIImage(systemName: "arrow.down.circle.dotted") dateLabel.text = "0000-00-00 00:00:00" temperatureLabel.text = "00℃" weatherLabel.text = "~~~" descriptionLabel.text = "~~~~~" } + + func configureCell( + weatherInfo: WeatherForecastInfo, + tempUnitManager: TempUnitManagerService, + imageService: NetworkService + ) { + imageLoadingTask = Task { + let image = try? await imageService.fetchImage(iconName: weatherInfo.weather.icon, urlSession: URLSession.shared) + weatherIcon.image = image + } + + dateLabel.text = weatherInfo.dtTxt + temperatureLabel.text = "\(weatherInfo.main.temp)\(tempUnitManager.tempUnit.expression)" + weatherLabel.text = weatherInfo.weather.main + descriptionLabel.text = weatherInfo.weather.description + } } diff --git a/WeatherForecast/WeatherForecast/Presentation/MainWeatherList/View/MainWeatherListView.swift b/WeatherForecast/WeatherForecast/Presentation/MainWeatherList/View/MainWeatherListView.swift new file mode 100644 index 0000000..a8c89b2 --- /dev/null +++ b/WeatherForecast/WeatherForecast/Presentation/MainWeatherList/View/MainWeatherListView.swift @@ -0,0 +1,180 @@ +// +// MainWeatherListView.swift +// WeatherForecast +// +// Created by MIN SEONG KIM on 2024/02/02. +// + +import UIKit + +protocol MainWeatherListViewDelete: AnyObject { + func showWeatherDetailInfo(detailVC: WeatherDetailViewController) + func changeNavigationTitle(title: String?) +} + +final class MainWeatherListView: UIView { + // MARK: - Properties + struct Dependency { + let weatherDetailViewControllerFactory: (WeatherDetailViewController.Dependency) -> WeatherDetailViewController + let weatherDetailViewFactory: (WeatherDetailView.Dependency) -> WeatherDetailView + let weatherJsonService: JsonService + let imageService: NetworkService + let tempUnitManager: TempUnitManagerService + } + + private let dependency: Dependency + private var weatherJSON: WeatherJSON? + + var delegate: MainWeatherListViewDelete? + + // MARK: - UI + private var tableView: UITableView! + private let refreshControl: UIRefreshControl = UIRefreshControl() + + // MARK: - Init + init( + dependency: Dependency + ) { + self.dependency = dependency + super.init(frame: .zero) + initialSetUp() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Layout + private func initialSetUp() { + backgroundColor = .white + + setTableView() + } + + private func setTableView() { + layoutView() + + tableView.refreshControl = refreshControl + tableView.register(WeatherTableViewCell.self, forCellReuseIdentifier: WeatherTableViewCell.identifier) + tableView.dataSource = self + tableView.delegate = self + + refreshControl.addTarget(self, + action: #selector(refresh), + for: .valueChanged) + } + + private func layoutView() { + + tableView = .init(frame: .zero, style: .plain) + + addSubview(tableView) + tableView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), + tableView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor), + tableView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor), + tableView.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor) + ]) + } + + @objc func refresh() { + Task { + let fetchResult = await self.fetchWeather() + switch fetchResult { + case .success(let featchData): + self.weatherJSON = featchData + tableView.reloadData() + delegate?.changeNavigationTitle(title: featchData.city.name) + case .failure(let error): + print(error) + } + } + + refreshControl.endRefreshing() + } + + private func fetchWeather() async -> Result { + return await dependency.weatherJsonService.fetchWeather() + } +} + +extension MainWeatherListView: UITableViewDelegate, UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return weatherJSON?.weatherForecast.count ?? 0 + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell: UITableViewCell = tableView.dequeueReusableCell( + withIdentifier: WeatherTableViewCell.identifier, + for: indexPath + ) + + guard let cell: WeatherTableViewCell = cell as? WeatherTableViewCell, + let weatherForcast = weatherJSON?.weatherForecast[indexPath.row] else { + return cell + } + + cell.configureCell(weatherInfo: weatherForcast, + tempUnitManager: dependency.tempUnitManager, + imageService: dependency.imageService + ) + + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + let weatherDetailVC = createWeatherDetailViewController(indexPath: indexPath) + + delegate?.showWeatherDetailInfo(detailVC: weatherDetailVC as! WeatherDetailViewController) + } +} + +extension MainWeatherListView { + private func createWeatherDetailViewController(indexPath: IndexPath) -> UIViewController { + guard let weatherJSON = weatherJSON else { return UIViewController() } + + let weatherDetailInfo = createWeatherDetailInfo( + weatherJSON: weatherJSON, + indexPath: indexPath + ) + + let weatherDetailViewControllerDependency = createWeatherDetailViewControllerDependency(weatherDetaiInfo: weatherDetailInfo) + + let weatherDetailViewController = dependency.weatherDetailViewControllerFactory(weatherDetailViewControllerDependency) + + return weatherDetailViewController + } + + private func createWeatherDetailInfo( + weatherJSON: WeatherJSON, + indexPath: IndexPath + ) -> WeatherDetailInfo { + let weatherDetailInfo: WeatherDetailInfo = .init( + weatherForecastInfo: weatherJSON.weatherForecast[indexPath.item], + cityInfo: weatherJSON.city, + tempUnit: dependency.tempUnitManager.tempUnit + ) + + return weatherDetailInfo + } + + private func createWeatherDetailViewControllerDependency( + weatherDetaiInfo: WeatherDetailInfo + ) -> WeatherDetailViewController.Dependency { + let weatherDetailViewControllerDependency: WeatherDetailViewController.Dependency = .init( + weatherDetailViewFactory: dependency.weatherDetailViewFactory, + weatherDetailInfo: weatherDetaiInfo, + imageService: dependency.imageService + ) + + return weatherDetailViewControllerDependency + } +} diff --git a/WeatherForecast/WeatherForecast/Presentation/MainWeatherList/ViewController/MainWeatherListViewController.swift b/WeatherForecast/WeatherForecast/Presentation/MainWeatherList/ViewController/MainWeatherListViewController.swift new file mode 100644 index 0000000..3e47999 --- /dev/null +++ b/WeatherForecast/WeatherForecast/Presentation/MainWeatherList/ViewController/MainWeatherListViewController.swift @@ -0,0 +1,113 @@ +// +// WeatherForecast - ViewController.swift +// Created by yagom. +// Copyright © yagom. All rights reserved. +// + +import UIKit + +final class MainWeatherListViewController: UIViewController { + // MARK: - Properties + struct Dependency { + let mainWeatherListViewFactory: (MainWeatherListView.Dependency) -> MainWeatherListView + let weatherJsonService: JsonService + let imageService: NetworkService + let tempUnitManager: TempUnitManagerService + } + + private let dependency: Dependency + + // MARK: - UI + private var mainWeatherListView: MainWeatherListView! + + // MARK: - Init + init(dependency: Dependency) { + self.dependency = dependency + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + override func viewDidLoad() { + super.viewDidLoad() + initialSetUp() + layoutView() + } + + // MARK: - Layout + private func layoutView() { + view.addSubview(mainWeatherListView) + mainWeatherListView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + mainWeatherListView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + mainWeatherListView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + mainWeatherListView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + mainWeatherListView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor) + ]) + } +} + +extension MainWeatherListViewController: MainWeatherListViewDelete { + func showWeatherDetailInfo(detailVC: WeatherDetailViewController) { + navigationController?.show(detailVC, sender: self) + } + + func changeNavigationTitle(title: String?) { + navigationItem.title = title + } + + @objc private func changeTempUnit() { + dependency.tempUnitManager.update() + navigationItem.rightBarButtonItem?.title = dependency.tempUnitManager.tempUnit + .strOpposite + + mainWeatherListView.refresh() + } + + private func initialSetUp() { + mainWeatherListView = (createMainWeatherListView() as! MainWeatherListView) + + view.backgroundColor = .systemBackground + mainWeatherListView.delegate = self + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: dependency.tempUnitManager.tempUnit.strOpposite, + image: nil, + target: self, + action: #selector(changeTempUnit) + ) + } +} + +extension MainWeatherListViewController { + private func createMainWeatherListView() -> UIView { + let mainWeatherListViewDependency = createMainWeatherListViewDependency() + + let mainWeatherListView = dependency.mainWeatherListViewFactory(mainWeatherListViewDependency) + + return mainWeatherListView + } + + private func createMainWeatherListViewDependency() -> MainWeatherListView.Dependency { + let weatherDetailViewControllerFactory = { dependency in + WeatherDetailViewController(dependency: dependency) + } + + let weatherDetailViewFactory = { dependency in + WeatherDetailView(dependency: dependency) + } + + let mainWeatherListViewDependency: MainWeatherListView.Dependency = .init( + weatherDetailViewControllerFactory: weatherDetailViewControllerFactory, + weatherDetailViewFactory: weatherDetailViewFactory, + weatherJsonService: dependency.weatherJsonService, + imageService: dependency.imageService, + tempUnitManager: dependency.tempUnitManager + ) + + return mainWeatherListViewDependency + } +} diff --git a/WeatherForecast/WeatherForecast/Presentation/WeatherDetail/View/WeatherDetailView.swift b/WeatherForecast/WeatherForecast/Presentation/WeatherDetail/View/WeatherDetailView.swift new file mode 100644 index 0000000..e7c2e64 --- /dev/null +++ b/WeatherForecast/WeatherForecast/Presentation/WeatherDetail/View/WeatherDetailView.swift @@ -0,0 +1,122 @@ +// +// WeatherDetailView.swift +// WeatherForecast +// +// Created by MIN SEONG KIM on 2024/02/05. +// + +import UIKit + +final class WeatherDetailView: UIView { + // MARK: - Properties + struct Dependency { + let imageService: NetworkService + let weatherDetailInfo: WeatherDetailInfo + } + + private let dependency: Dependency + + // MARK: - UI + private let iconImageView: UIImageView = UIImageView() + private let weatherGroupLabel: UILabel = UILabel() + private let weatherDescriptionLabel: UILabel = UILabel() + private let temperatureLabel: UILabel = UILabel() + private let feelsLikeLabel: UILabel = UILabel() + private let maximumTemperatureLabel: UILabel = UILabel() + private let minimumTemperatureLabel: UILabel = UILabel() + private let popLabel: UILabel = UILabel() + private let humidityLabel: UILabel = UILabel() + private let sunriseTimeLabel: UILabel = UILabel() + private let sunsetTimeLabel: UILabel = UILabel() + private let spacingView: UIView = UIView() + + // MARK: - Init + init( + dependency: Dependency + ) { + self.dependency = dependency + super.init(frame: .zero) + layoutView() + updateLabel() + updateIcon() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Layout + private func layoutView() { + self.backgroundColor = .white + + makeLabel() + + let mainStackView: UIStackView = .init(arrangedSubviews: [ + iconImageView, + weatherGroupLabel, + weatherDescriptionLabel, + temperatureLabel, + feelsLikeLabel, + maximumTemperatureLabel, + minimumTemperatureLabel, + popLabel, + humidityLabel, + sunriseTimeLabel, + sunsetTimeLabel, + spacingView + ]) + + mainStackView.axis = .vertical + mainStackView.alignment = .center + mainStackView.spacing = 8 + mainStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(mainStackView) + + let safeArea: UILayoutGuide = safeAreaLayoutGuide + NSLayoutConstraint.activate([ + mainStackView.topAnchor.constraint(equalTo: safeArea.topAnchor), + mainStackView.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor), + mainStackView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, + constant: 16), + mainStackView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, + constant: -16), + iconImageView.widthAnchor.constraint(equalTo: iconImageView.heightAnchor), + iconImageView.widthAnchor.constraint(equalTo: safeArea.widthAnchor, + multiplier: 0.3) + ]) + } + + private func makeLabel() { + let largeTitleLabels: [UILabel] = [weatherGroupLabel, weatherDescriptionLabel] + + largeTitleLabels.forEach { label in + label.makeLabel(font: .preferredFont(forTextStyle: .largeTitle)) + } + + let bodyLabels: [UILabel] = [temperatureLabel, feelsLikeLabel, maximumTemperatureLabel, minimumTemperatureLabel, popLabel, humidityLabel, sunriseTimeLabel, sunsetTimeLabel] + + bodyLabels.forEach { label in + label.makeLabel() + } + } + + private func updateLabel() { + weatherGroupLabel.text = dependency.weatherDetailInfo.weatherMain + weatherDescriptionLabel.text = dependency.weatherDetailInfo.description + temperatureLabel.text = "현재 기온 : \(String(describing: dependency.weatherDetailInfo.temp))" + feelsLikeLabel.text = "체감 기온 : \(String(describing: dependency.weatherDetailInfo.feelsLike))" + maximumTemperatureLabel.text = "최고 기온 : \(String(describing: dependency.weatherDetailInfo.tempMax))" + minimumTemperatureLabel.text = "최저 기온 : \(String(describing: dependency.weatherDetailInfo.tempMin))" + popLabel.text = "강수 확률 : \(String(describing: dependency.weatherDetailInfo.pop))" + humidityLabel.text = "습도 : \(String(describing: dependency.weatherDetailInfo.humidity))" + sunriseTimeLabel.text = "일출 : \(String(describing: dependency.weatherDetailInfo.sunrise))" + sunsetTimeLabel.text = "일몰 : \(String(describing: dependency.weatherDetailInfo.sunset))" + } + + private func updateIcon() { + Task { + iconImageView.image = try? await dependency.imageService.fetchImage(iconName: dependency.weatherDetailInfo.iconName, urlSession: URLSession.shared) + } + + } +} diff --git a/WeatherForecast/WeatherForecast/Presentation/WeatherDetail/ViewController/WeatherDetailViewController.swift b/WeatherForecast/WeatherForecast/Presentation/WeatherDetail/ViewController/WeatherDetailViewController.swift new file mode 100644 index 0000000..0f8765d --- /dev/null +++ b/WeatherForecast/WeatherForecast/Presentation/WeatherDetail/ViewController/WeatherDetailViewController.swift @@ -0,0 +1,63 @@ +// +// WeatherForecast - WeatherDetailViewController.swift +// Created by yagom. +// Copyright © yagom. All rights reserved. +// + +import UIKit + +final class WeatherDetailViewController: UIViewController { + // MARK: - Properties + struct Dependency { + let weatherDetailViewFactory: (WeatherDetailView.Dependency) -> WeatherDetailView + let weatherDetailInfo: WeatherDetailInfo + let imageService: NetworkService + } + + private let dependency: Dependency + + // MARK: - Init + init( + dependency: Dependency + ) { + self.dependency = dependency + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + override func loadView() { + view = createWeatherDetailView() + } + + override func viewDidLoad() { + super.viewDidLoad() + initialSetUp() + } + + private func initialSetUp() { + navigationItem.title = dependency.weatherDetailInfo.date + } +} + +extension WeatherDetailViewController { + private func createWeatherDetailView() -> UIView { + let weatherDetailViewDependency = createWeatherDetailViewDependency() + + let weatherDetailView = dependency.weatherDetailViewFactory(weatherDetailViewDependency) + + return weatherDetailView + } + + private func createWeatherDetailViewDependency() -> WeatherDetailView.Dependency { + let weatherDetailViewDependency: WeatherDetailView.Dependency = .init( + imageService: dependency.imageService, + weatherDetailInfo: dependency.weatherDetailInfo + ) + + return weatherDetailViewDependency + } +} diff --git a/WeatherForecast/WeatherForecast/SceneDelegate.swift b/WeatherForecast/WeatherForecast/SceneDelegate.swift index 264a1ab..056abac 100644 --- a/WeatherForecast/WeatherForecast/SceneDelegate.swift +++ b/WeatherForecast/WeatherForecast/SceneDelegate.swift @@ -1,8 +1,8 @@ // // WeatherForecast - SceneDelegate.swift -// Created by yagom. +// Created by yagom. // Copyright © yagom. All rights reserved. -// +// import UIKit @@ -15,7 +15,30 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). - guard let _ = (scene as? UIWindowScene) else { return } + guard let windowScene = (scene as? UIWindowScene) else { return } + + window = UIWindow(windowScene: windowScene) + + let imageService: NetworkService = WeatherIconImageService() + let weatherJsonService: JsonService = WeatherJsonService() + let tempUnitManager: TempUnitManagerService = TempUnitManager() + + let mainWeatherListViewFactory = { dependency in + MainWeatherListView(dependency: dependency) + } + + let vc = MainWeatherListViewController( + dependency: .init( + mainWeatherListViewFactory: mainWeatherListViewFactory, + weatherJsonService: weatherJsonService, + imageService: imageService, + tempUnitManager: tempUnitManager + ) + ) + + let nav = UINavigationController(rootViewController: vc) + window?.rootViewController = nav + window?.makeKeyAndVisible() } func sceneDidDisconnect(_ scene: UIScene) { diff --git a/WeatherForecast/WeatherForecast/Util/Extension/UILabel+Extension.swift b/WeatherForecast/WeatherForecast/Util/Extension/UILabel+Extension.swift new file mode 100644 index 0000000..510aa73 --- /dev/null +++ b/WeatherForecast/WeatherForecast/Util/Extension/UILabel+Extension.swift @@ -0,0 +1,20 @@ +// +// UILabel+Extension.swift +// WeatherForecast +// +// Created by MIN SEONG KIM on 2024/02/05. +// + +import UIKit + +extension UILabel { + func makeLabel(textColor: UIColor = .black, + numberOfLines: Int = 1, + textAlignment: NSTextAlignment = .center, + font: UIFont = .preferredFont(forTextStyle: .body)) { + self.textColor = textColor + self.numberOfLines = numberOfLines + self.textAlignment = textAlignment + self.font = font + } +} diff --git a/WeatherForecast/WeatherForecast/ViewController.swift b/WeatherForecast/WeatherForecast/ViewController.swift deleted file mode 100644 index 50b66fb..0000000 --- a/WeatherForecast/WeatherForecast/ViewController.swift +++ /dev/null @@ -1,166 +0,0 @@ -// -// WeatherForecast - ViewController.swift -// Created by yagom. -// Copyright © yagom. All rights reserved. -// - -import UIKit - -class ViewController: UIViewController { - var tableView: UITableView! - let refreshControl: UIRefreshControl = UIRefreshControl() - var weatherJSON: WeatherJSON? - var icons: [UIImage]? - let imageChache: NSCache = NSCache() - let dateFormatter: DateFormatter = { - let formatter: DateFormatter = DateFormatter() - formatter.locale = .init(identifier: "ko_KR") - formatter.dateFormat = "yyyy-MM-dd(EEEEE) a HH:mm" - return formatter - }() - - var tempUnit: TempUnit = .metric - - override func viewDidLoad() { - super.viewDidLoad() - initialSetUp() - } -} - -extension ViewController { - @objc private func changeTempUnit() { - switch tempUnit { - case .imperial: - tempUnit = .metric - navigationItem.rightBarButtonItem?.title = "섭씨" - case .metric: - tempUnit = .imperial - navigationItem.rightBarButtonItem?.title = "화씨" - } - refresh() - } - - @objc private func refresh() { - fetchWeatherJSON() - tableView.reloadData() - refreshControl.endRefreshing() - } - - private func initialSetUp() { - navigationItem.rightBarButtonItem = UIBarButtonItem(title: "화씨", image: nil, target: self, action: #selector(changeTempUnit)) - - layTable() - - refreshControl.addTarget(self, - action: #selector(refresh), - for: .valueChanged) - - tableView.refreshControl = refreshControl - tableView.register(WeatherTableViewCell.self, forCellReuseIdentifier: "WeatherCell") - tableView.dataSource = self - tableView.delegate = self - } - - private func layTable() { - tableView = .init(frame: .zero, style: .plain) - view.addSubview(tableView) - tableView.translatesAutoresizingMaskIntoConstraints = false - - let safeArea: UILayoutGuide = view.safeAreaLayoutGuide - - NSLayoutConstraint.activate([ - tableView.topAnchor.constraint(equalTo: safeArea.topAnchor), - tableView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor), - tableView.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor), - tableView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor) - ]) - } -} - -extension ViewController { - private func fetchWeatherJSON() { - - let jsonDecoder: JSONDecoder = .init() - jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase - - guard let data = NSDataAsset(name: "weather")?.data else { - return - } - - let info: WeatherJSON - do { - info = try jsonDecoder.decode(WeatherJSON.self, from: data) - } catch { - print(error.localizedDescription) - return - } - - weatherJSON = info - navigationItem.title = weatherJSON?.city.name - } -} - -extension ViewController: UITableViewDataSource { - - func numberOfSections(in tableView: UITableView) -> Int { - 1 - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - weatherJSON?.weatherForecast.count ?? 0 - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell: UITableViewCell = tableView.dequeueReusableCell(withIdentifier: "WeatherCell", for: indexPath) - - guard let cell: WeatherTableViewCell = cell as? WeatherTableViewCell, - let weatherForecastInfo = weatherJSON?.weatherForecast[indexPath.row] else { - return cell - } - - cell.weatherLabel.text = weatherForecastInfo.weather.main - cell.descriptionLabel.text = weatherForecastInfo.weather.description - cell.temperatureLabel.text = "\(weatherForecastInfo.main.temp)\(tempUnit.expression)" - - let date: Date = Date(timeIntervalSince1970: weatherForecastInfo.dt) - cell.dateLabel.text = dateFormatter.string(from: date) - - let iconName: String = weatherForecastInfo.weather.icon - let urlString: String = "https://openweathermap.org/img/wn/\(iconName)@2x.png" - - if let image = imageChache.object(forKey: urlString as NSString) { - cell.weatherIcon.image = image - return cell - } - - Task { - guard let url: URL = URL(string: urlString), - let (data, _) = try? await URLSession.shared.data(from: url), - let image: UIImage = UIImage(data: data) else { - return - } - - imageChache.setObject(image, forKey: urlString as NSString) - - if indexPath == tableView.indexPath(for: cell) { - cell.weatherIcon.image = image - } - } - - return cell - } -} - -extension ViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - - let detailViewController: WeatherDetailViewController = WeatherDetailViewController() - detailViewController.weatherForecastInfo = weatherJSON?.weatherForecast[indexPath.row] - detailViewController.cityInfo = weatherJSON?.city - detailViewController.tempUnit = tempUnit - navigationController?.show(detailViewController, sender: self) - } -} - - diff --git a/WeatherForecast/WeatherForecast/Weather.swift b/WeatherForecast/WeatherForecast/Weather.swift deleted file mode 100644 index ede7585..0000000 --- a/WeatherForecast/WeatherForecast/Weather.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// WeatherForecast - Weather.swift -// Created by yagom. -// Copyright © yagom. All rights reserved. -// - -import Foundation - -// MARK: - Weather JSON Format -class WeatherJSON: Decodable { - let weatherForecast: [WeatherForecastInfo] - let city: City -} - -// MARK: - List -class WeatherForecastInfo: Decodable { - let dt: TimeInterval - let main: MainInfo - let weather: Weather - let dtTxt: String -} - -// MARK: - MainClass -class MainInfo: Decodable { - let temp, feelsLike, tempMin, tempMax: Double - let pressure, seaLevel, grndLevel, humidity, pop: Double -} - -// MARK: - Weather -class Weather: Decodable { - let id: Int - let main: String - let description: String - let icon: String -} - -// MARK: - City -class City: Decodable { - let id: Int - let name: String - let coord: Coord - let country: String - let population, timezone: Int - let sunrise, sunset: TimeInterval -} - -// MARK: - Coord -class Coord: Decodable { - let lat, lon: Double -} - -// MARK: - Temperature Unit -enum TempUnit: String { - case metric, imperial - var expression: String { - switch self { - case .metric: return "℃" - case .imperial: return "℉" - } - } -} - diff --git a/WeatherForecast/WeatherForecast/WeatherDetailViewController.swift b/WeatherForecast/WeatherForecast/WeatherDetailViewController.swift deleted file mode 100644 index 69d3dfb..0000000 --- a/WeatherForecast/WeatherForecast/WeatherDetailViewController.swift +++ /dev/null @@ -1,127 +0,0 @@ -// -// WeatherForecast - WeatherDetailViewController.swift -// Created by yagom. -// Copyright © yagom. All rights reserved. -// - -import UIKit - -class WeatherDetailViewController: UIViewController { - - var weatherForecastInfo: WeatherForecastInfo? - var cityInfo: City? - var tempUnit: TempUnit = .metric - - let dateFormatter: DateFormatter = { - let formatter: DateFormatter = DateFormatter() - formatter.locale = .init(identifier: "ko_KR") - formatter.dateFormat = "yyyy-MM-dd(EEEEE) a HH:mm" - return formatter - }() - - override func viewDidLoad() { - super.viewDidLoad() - initialSetUp() - } - - private func initialSetUp() { - view.backgroundColor = .white - - guard let listInfo = weatherForecastInfo else { return } - - let date: Date = Date(timeIntervalSince1970: listInfo.dt) - navigationItem.title = dateFormatter.string(from: date) - - let iconImageView: UIImageView = UIImageView() - let weatherGroupLabel: UILabel = UILabel() - let weatherDescriptionLabel: UILabel = UILabel() - let temperatureLabel: UILabel = UILabel() - let feelsLikeLabel: UILabel = UILabel() - let maximumTemperatureLable: UILabel = UILabel() - let minimumTemperatureLable: UILabel = UILabel() - let popLabel: UILabel = UILabel() - let humidityLabel: UILabel = UILabel() - let sunriseTimeLabel: UILabel = UILabel() - let sunsetTimeLabel: UILabel = UILabel() - let spacingView: UIView = UIView() - spacingView.backgroundColor = .clear - spacingView.setContentHuggingPriority(.defaultLow, for: .vertical) - - let mainStackView: UIStackView = .init(arrangedSubviews: [ - iconImageView, - weatherGroupLabel, - weatherDescriptionLabel, - temperatureLabel, - feelsLikeLabel, - maximumTemperatureLable, - minimumTemperatureLable, - popLabel, - humidityLabel, - sunriseTimeLabel, - sunsetTimeLabel, - spacingView - ]) - - mainStackView.arrangedSubviews.forEach { subview in - guard let subview: UILabel = subview as? UILabel else { return } - subview.textColor = .black - subview.backgroundColor = .clear - subview.numberOfLines = 1 - subview.textAlignment = .center - subview.font = .preferredFont(forTextStyle: .body) - } - - weatherGroupLabel.font = .preferredFont(forTextStyle: .largeTitle) - weatherDescriptionLabel.font = .preferredFont(forTextStyle: .largeTitle) - - mainStackView.axis = .vertical - mainStackView.alignment = .center - mainStackView.spacing = 8 - view.addSubview(mainStackView) - mainStackView.translatesAutoresizingMaskIntoConstraints = false - - let safeArea: UILayoutGuide = view.safeAreaLayoutGuide - NSLayoutConstraint.activate([ - mainStackView.topAnchor.constraint(equalTo: safeArea.topAnchor), - mainStackView.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor), - mainStackView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, - constant: 16), - mainStackView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, - constant: -16), - iconImageView.widthAnchor.constraint(equalTo: iconImageView.heightAnchor), - iconImageView.widthAnchor.constraint(equalTo: safeArea.widthAnchor, - multiplier: 0.3) - ]) - - weatherGroupLabel.text = listInfo.weather.main - weatherDescriptionLabel.text = listInfo.weather.description - temperatureLabel.text = "현재 기온 : \(listInfo.main.temp)\(tempUnit.expression)" - feelsLikeLabel.text = "체감 기온 : \(listInfo.main.feelsLike)\(tempUnit.expression)" - maximumTemperatureLable.text = "최고 기온 : \(listInfo.main.tempMax)\(tempUnit.expression)" - minimumTemperatureLable.text = "최저 기온 : \(listInfo.main.tempMin)\(tempUnit.expression)" - popLabel.text = "강수 확률 : \(listInfo.main.pop * 100)%" - humidityLabel.text = "습도 : \(listInfo.main.humidity)%" - - if let cityInfo { - let formatter: DateFormatter = DateFormatter() - formatter.dateFormat = .none - formatter.timeStyle = .short - formatter.locale = .init(identifier: "ko_KR") - sunriseTimeLabel.text = "일출 : \(formatter.string(from: Date(timeIntervalSince1970: cityInfo.sunrise)))" - sunsetTimeLabel.text = "일몰 : \(formatter.string(from: Date(timeIntervalSince1970: cityInfo.sunset)))" - } - - Task { - let iconName: String = listInfo.weather.icon - let urlString: String = "https://openweathermap.org/img/wn/\(iconName)@2x.png" - - guard let url: URL = URL(string: urlString), - let (data, _) = try? await URLSession.shared.data(from: url), - let image: UIImage = UIImage(data: data) else { - return - } - - iconImageView.image = image - } - } -}