├── image ├── 1.png ├── 10.png ├── 2.png ├── 3.png ├── 4.png ├── 5.png ├── 6.png ├── 7.png ├── 8.png ├── preview.gif ├── preview2.png └── preview3.png ├── Weather ├── Weather │ ├── SupportingFiles │ │ ├── Assets.xcassets │ │ │ ├── Contents.json │ │ │ ├── Weather │ │ │ │ ├── Contents.json │ │ │ │ ├── shine.imageset │ │ │ │ │ ├── shine.png │ │ │ │ │ └── Contents.json │ │ │ │ ├── windy.imageset │ │ │ │ │ ├── windy.png │ │ │ │ │ └── Contents.json │ │ │ │ ├── cloudy.imageset │ │ │ │ │ ├── cloudy.png │ │ │ │ │ └── Contents.json │ │ │ │ ├── raining.imageset │ │ │ │ │ ├── raining.png │ │ │ │ │ └── Contents.json │ │ │ │ ├── snowing.imageset │ │ │ │ │ ├── snowing.png │ │ │ │ │ └── Contents.json │ │ │ │ ├── drizzling.imageset │ │ │ │ │ ├── drizzling.png │ │ │ │ │ └── Contents.json │ │ │ │ └── thunderstorms.imageset │ │ │ │ │ ├── thunderstorms.png │ │ │ │ │ └── Contents.json │ │ │ ├── AccentColor.colorset │ │ │ │ └── Contents.json │ │ │ └── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ ├── Weather.xcdatamodeld │ │ │ ├── .xccurrentversion │ │ │ └── Weather.xcdatamodel │ │ │ │ └── contents │ │ ├── Base.lproj │ │ │ └── LaunchScreen.storyboard │ │ └── Info.plist │ ├── Modules │ │ ├── Base │ │ │ ├── TransitionType.swift │ │ │ ├── BaseViewController.swift │ │ │ └── BaseRouter.swift │ │ ├── Models │ │ │ ├── Location.swift │ │ │ ├── User.swift │ │ │ ├── LocalWeather.swift │ │ │ └── WeatherResponse.swift │ │ ├── MainModule │ │ │ ├── SubViews │ │ │ │ ├── CollectionView │ │ │ │ │ ├── Common │ │ │ │ │ │ └── SeparateLineView.swift │ │ │ │ │ ├── SubInfoCell │ │ │ │ │ │ ├── SubInfoCell.swift │ │ │ │ │ │ ├── SubInfoCollectionView.swift │ │ │ │ │ │ └── SubInfoCollectionCell.swift │ │ │ │ │ ├── DailyCell │ │ │ │ │ │ ├── DailyViewCell.swift │ │ │ │ │ │ ├── DailyCollectionView.swift │ │ │ │ │ │ └── DailyCollectionCell.swift │ │ │ │ │ ├── HourlyCell │ │ │ │ │ │ ├── HourlyCollectionReusableView.swift │ │ │ │ │ │ ├── HourlyCollectionView.swift │ │ │ │ │ │ └── HourlyCollectionCell.swift │ │ │ │ │ ├── SummaryCell │ │ │ │ │ │ └── SummaryCell.swift │ │ │ │ │ └── CollectionView.swift │ │ │ │ ├── ToolBar │ │ │ │ │ └── Toolbar.swift │ │ │ │ └── Header │ │ │ │ │ └── HeaderView.swift │ │ │ ├── ViewModels │ │ │ │ ├── WeatherInfoViewModel.swift │ │ │ │ ├── WeatherDailyViewModel.swift │ │ │ │ └── WeatherViewModel.swift │ │ │ ├── MainRouter.swift │ │ │ ├── MainProtocol.swift │ │ │ ├── MainPresenter.swift │ │ │ ├── MainInteractor.swift │ │ │ └── MainViewController.swift │ │ └── SettingModule │ │ │ ├── SettingRouter.swift │ │ │ ├── SettingPresenter.swift │ │ │ ├── SettingProtocol.swift │ │ │ ├── SettingInteractor.swift │ │ │ └── SettingViewController.swift │ ├── Extensions │ │ ├── UIView │ │ │ ├── UIView+Subviews.swift │ │ │ └── UIView+LayoutAnchor.swift │ │ ├── UIKit │ │ │ ├── UIColor.swift │ │ │ └── UIFont.swift │ │ ├── Reusable │ │ │ ├── Reusable.swift │ │ │ ├── UITableView.swift │ │ │ └── UICollectionView.swift │ │ └── SwiftStandardLibrary │ │ │ ├── Double.swift │ │ │ └── Date.swift │ ├── Services │ │ ├── WeatherServiceType.swift │ │ ├── WeatherServiceError.swift │ │ └── WeatherService.swift │ ├── Managers │ │ ├── RealmManagerError.swift │ │ └── RealmManager.swift │ ├── SceneDelegate.swift │ └── AppDelegate.swift ├── Weather.xcodeproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── WeatherTests │ ├── Info.plist │ ├── Mocks │ ├── MockWeatherService.swift │ ├── MockWeatherServiceWithoutNetwork.swift │ ├── StubData.swift │ └── MockRealmManager.swift │ ├── Managers │ └── RealmManagerTests.swift │ ├── WeatherService │ └── WeatherServiceTests.swift │ └── Resources │ └── testWeatherData.json ├── LICENSE ├── .gitignore └── README.md /image/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iosdevted/weather-app/HEAD/image/1.png -------------------------------------------------------------------------------- /image/10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iosdevted/weather-app/HEAD/image/10.png -------------------------------------------------------------------------------- /image/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iosdevted/weather-app/HEAD/image/2.png -------------------------------------------------------------------------------- /image/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iosdevted/weather-app/HEAD/image/3.png -------------------------------------------------------------------------------- /image/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iosdevted/weather-app/HEAD/image/4.png -------------------------------------------------------------------------------- /image/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iosdevted/weather-app/HEAD/image/5.png -------------------------------------------------------------------------------- /image/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iosdevted/weather-app/HEAD/image/6.png -------------------------------------------------------------------------------- /image/7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iosdevted/weather-app/HEAD/image/7.png -------------------------------------------------------------------------------- /image/8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iosdevted/weather-app/HEAD/image/8.png -------------------------------------------------------------------------------- /image/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iosdevted/weather-app/HEAD/image/preview.gif -------------------------------------------------------------------------------- /image/preview2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iosdevted/weather-app/HEAD/image/preview2.png -------------------------------------------------------------------------------- /image/preview3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iosdevted/weather-app/HEAD/image/preview3.png -------------------------------------------------------------------------------- /Weather/Weather/SupportingFiles/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Weather/Weather/SupportingFiles/Assets.xcassets/Weather/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Weather/Weather/SupportingFiles/Assets.xcassets/Weather/shine.imageset/shine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iosdevted/weather-app/HEAD/Weather/Weather/SupportingFiles/Assets.xcassets/Weather/shine.imageset/shine.png -------------------------------------------------------------------------------- /Weather/Weather/SupportingFiles/Assets.xcassets/Weather/windy.imageset/windy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iosdevted/weather-app/HEAD/Weather/Weather/SupportingFiles/Assets.xcassets/Weather/windy.imageset/windy.png -------------------------------------------------------------------------------- /Weather/Weather/SupportingFiles/Assets.xcassets/Weather/cloudy.imageset/cloudy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iosdevted/weather-app/HEAD/Weather/Weather/SupportingFiles/Assets.xcassets/Weather/cloudy.imageset/cloudy.png -------------------------------------------------------------------------------- /Weather/Weather/SupportingFiles/Assets.xcassets/Weather/raining.imageset/raining.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iosdevted/weather-app/HEAD/Weather/Weather/SupportingFiles/Assets.xcassets/Weather/raining.imageset/raining.png -------------------------------------------------------------------------------- /Weather/Weather/SupportingFiles/Assets.xcassets/Weather/snowing.imageset/snowing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iosdevted/weather-app/HEAD/Weather/Weather/SupportingFiles/Assets.xcassets/Weather/snowing.imageset/snowing.png -------------------------------------------------------------------------------- /Weather/Weather.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Weather/Weather/SupportingFiles/Assets.xcassets/Weather/drizzling.imageset/drizzling.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iosdevted/weather-app/HEAD/Weather/Weather/SupportingFiles/Assets.xcassets/Weather/drizzling.imageset/drizzling.png -------------------------------------------------------------------------------- /Weather/Weather/SupportingFiles/Assets.xcassets/Weather/thunderstorms.imageset/thunderstorms.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iosdevted/weather-app/HEAD/Weather/Weather/SupportingFiles/Assets.xcassets/Weather/thunderstorms.imageset/thunderstorms.png -------------------------------------------------------------------------------- /Weather/Weather/SupportingFiles/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Weather/Weather/SupportingFiles/Weather.xcdatamodeld/.xccurrentversion: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/Base/TransitionType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TransitionType.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/16. 6 | // 7 | 8 | import UIKit 9 | 10 | enum TransitionType { 11 | case push 12 | case present(from: UIViewController) 13 | } 14 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/Models/Location.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Location.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/15. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Location { 11 | let name: String 12 | let latitude: Double 13 | let longitude: Double 14 | } 15 | -------------------------------------------------------------------------------- /Weather/Weather.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Weather/Weather/Extensions/UIView/UIView+Subviews.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIView+Subviews.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/11. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIView { 11 | func addSubviews(_ views: [UIView]) { 12 | for view in views { 13 | addSubview(view) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/Models/User.swift: -------------------------------------------------------------------------------- 1 | // 2 | // User.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/12. 6 | // 7 | 8 | import Foundation 9 | import RealmSwift 10 | 11 | class User: Object { 12 | @objc dynamic var userId: String? 13 | 14 | override class func primaryKey() -> String? { 15 | return "userId" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Weather/Weather/SupportingFiles/Weather.xcdatamodeld/Weather.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /Weather/Weather/SupportingFiles/Assets.xcassets/Weather/shine.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "shine.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Weather/Weather/SupportingFiles/Assets.xcassets/Weather/windy.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "windy.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Weather/Weather/SupportingFiles/Assets.xcassets/Weather/cloudy.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "cloudy.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Weather/Weather/SupportingFiles/Assets.xcassets/Weather/raining.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "raining.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Weather/Weather/SupportingFiles/Assets.xcassets/Weather/snowing.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "snowing.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Weather/Weather/SupportingFiles/Assets.xcassets/Weather/drizzling.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "drizzling.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Weather/Weather/Services/WeatherServiceType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherServiceType.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/11. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol WeatherServiceType { 11 | 12 | func fetchWeather(lat: Double, lon: Double, completion: @escaping (Result) -> Void) 13 | func fetchWeather(byCity city: String, completion: @escaping (Result) -> Void) 14 | } 15 | -------------------------------------------------------------------------------- /Weather/Weather/SupportingFiles/Assets.xcassets/Weather/thunderstorms.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "thunderstorms.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Weather/Weather/Extensions/UIKit/UIColor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIColor.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/11. 6 | // 7 | 8 | import UIKit.UIColor 9 | 10 | extension UIColor { 11 | 12 | static var warmBlack: UIColor { 13 | return .init(red: 0.29, green: 0.29, blue: 0.28, alpha: 1.00) 14 | } 15 | 16 | static var warmGray: UIColor { 17 | // .init(red: 89/255, green: 82/255, blue: 96/255, alpha: 1.00) 18 | return .lightGray 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Weather/Weather/Extensions/Reusable/Reusable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Reusable.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/17. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol Reusable: AnyObject { 11 | static var reuseIdentifier: String { get } 12 | } 13 | 14 | extension Reusable { 15 | //Make indentifier to its own class name 16 | //class SomeCell: UITableViewCell -> SomceCell 17 | static var reuseIdentifier: String { 18 | return String(describing: self) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Weather/Weather/Managers/RealmManagerError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RealmManagerError.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/16. 6 | // 7 | 8 | import Foundation 9 | 10 | enum RealmManagerError: Error, LocalizedError { 11 | 12 | case encodeError 13 | case decodeError 14 | 15 | var errorDescription: String? { 16 | switch self { 17 | case .encodeError: 18 | return "Hey, This is a Encode Error!" 19 | case .decodeError: 20 | return "Hey, This is a Decode Error!" 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/Base/BaseViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseViewController.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/16. 6 | // 7 | 8 | import UIKit 9 | 10 | class BaseViewController : UIViewController { 11 | 12 | //MARK: - Init 13 | 14 | override func viewDidLoad() { 15 | super.viewDidLoad() 16 | setupViews() 17 | } 18 | 19 | //MARK: - Setup Views 20 | 21 | func setupViews() { 22 | //override 23 | } 24 | 25 | func setupConstraints() { 26 | //override 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/Models/LocalWeather.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalWeather.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/12. 6 | // 7 | 8 | import Foundation 9 | import RealmSwift 10 | 11 | class LocalWeather: Object { 12 | @objc dynamic var weatherId: String? 13 | @objc dynamic var locationName: String? 14 | @objc dynamic var latitude: Double = 0 15 | @objc dynamic var longitude: Double = 0 16 | @objc dynamic var lastRefreshedDate: Date? 17 | @objc dynamic var weatherData: Data? = nil 18 | 19 | override class func primaryKey() -> String? { 20 | return "weatherId" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/MainModule/SubViews/CollectionView/Common/SeparateLineView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SeparateLineView.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/12. 6 | // 7 | 8 | import UIKit 9 | 10 | class SeparateLineView: UIView { 11 | 12 | //MARK: - Init 13 | 14 | override init(frame: CGRect) { 15 | super.init(frame: frame) 16 | backgroundColor = .warmBlack 17 | } 18 | 19 | required init?(coder aDecoder: NSCoder) { 20 | super.init(coder: aDecoder) 21 | } 22 | 23 | //MARK: - Layout & Constraints 24 | 25 | func setupConstraints(bottom: NSLayoutYAxisAnchor) { 26 | 27 | self.heightAnchor(constant: 0.5) 28 | .bottomAnchor(to: bottom) 29 | .activateAnchors() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Weather/WeatherTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Weather/WeatherTests/Mocks/MockWeatherService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockWeatherService.swift 3 | // WeatherTests 4 | // 5 | // Created by Ted on 2021/08/16. 6 | // 7 | 8 | import XCTest 9 | @testable import Weather 10 | 11 | class MockWeatherService: WeatherService { 12 | 13 | var fetchWeatherByCityCalled = 0 14 | var fetchWeatherByLotLon = 0 15 | 16 | override func fetchWeather(byCity city: String, completion: @escaping (Result) -> Void) { 17 | self.fetchWeatherByCityCalled += 1 18 | super.fetchWeather(byCity: city, completion: completion) 19 | } 20 | 21 | override func fetchWeather(lat: Double, lon: Double, completion: @escaping (Result) -> Void) { 22 | self.fetchWeatherByLotLon += 1 23 | super.fetchWeather(lat: lat, lon: lon, completion: completion) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Weather/Weather.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Realm", 6 | "repositoryURL": "https://github.com/realm/realm-cocoa.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "83a07f6a508c3427058d9e2c466208d0b6a960fa", 10 | "version": "10.12.0" 11 | } 12 | }, 13 | { 14 | "package": "RealmDatabase", 15 | "repositoryURL": "https://github.com/realm/realm-core", 16 | "state": { 17 | "branch": null, 18 | "revision": "e72b3078bfc5c3f69a0b18f7a220be27e28c463f", 19 | "version": "11.2.0" 20 | } 21 | }, 22 | { 23 | "package": "Then", 24 | "repositoryURL": "https://github.com/devxoul/Then.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "e421a7b3440a271834337694e6050133a3958bc7", 28 | "version": "2.7.0" 29 | } 30 | } 31 | ] 32 | }, 33 | "version": 1 34 | } 35 | -------------------------------------------------------------------------------- /Weather/Weather/Extensions/Reusable/UITableView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UITableView+Reusable.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/15. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UITableViewCell: Reusable {} 11 | 12 | extension UITableView { 13 | 14 | final func register(cellType: T.Type) { 15 | register(cellType.self, forCellReuseIdentifier: cellType.reuseIdentifier) 16 | } 17 | 18 | final func dequeueReusableCell(for indexPath: IndexPath, cellType: T.Type = T.self) -> T { 19 | guard let cell = self.dequeueReusableCell(withIdentifier: cellType.reuseIdentifier, for: indexPath) as? T else { 20 | fatalError( 21 | "Failed to dequeue a cell with identifier \(cellType.reuseIdentifier) matching type \(cellType.self). " 22 | + "Check that the reuseIdentifier is set properly in your XIB/Storyboard " 23 | + "and that you registered the cell beforehand" 24 | ) 25 | } 26 | return cell 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/MainModule/ViewModels/WeatherInfoViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherInfoViewModel.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/14. 6 | // 7 | 8 | import Foundation 9 | 10 | struct WeatherInfoViewModel { 11 | 12 | let infos: [String] 13 | 14 | init(infos: [String]) { 15 | self.infos = infos 16 | } 17 | 18 | static func getViewModel(with weatherViewModel: [WeatherViewModel]) -> WeatherInfoViewModel { 19 | var infos = [String]() 20 | if let recentWeather = weatherViewModel.first { 21 | infos.append(recentWeather.feelslike) 22 | infos.append(recentWeather.humidity) 23 | infos.append(recentWeather.pressure) 24 | infos.append(recentWeather.windSpeed) 25 | infos.append(recentWeather.windDirection) 26 | infos.append(recentWeather.visibility) 27 | } 28 | //infos -> [[feelslike, humidity, pressure, windSpeed, windDirection, visibility], [..], [..] ..] 29 | return WeatherInfoViewModel(infos: infos) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Weather/WeatherTests/Mocks/MockWeatherServiceWithoutNetwork.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockWeatherServiceWithoutNetwork.swift 3 | // WeatherTests 4 | // 5 | // Created by Ted on 2021/08/12. 6 | // 7 | 8 | import Foundation 9 | @testable import Weather 10 | 11 | class MockWeatherServiceWithoutNetwork: WeatherServiceType { 12 | func fetchWeather(lat: Double, lon: Double, completion: @escaping (Result) -> Void) { 13 | handleRequest(completion: completion) 14 | } 15 | 16 | func fetchWeather(byCity city: String, completion: @escaping (Result) -> Void) { 17 | handleRequest(completion: completion) 18 | } 19 | 20 | func handleRequest(completion: @escaping (Result) -> Void) { 21 | 22 | do { 23 | let result = try JSONDecoder().decode(WeatherResponse.self, from: StubData.shared.stubWeatherData()) 24 | completion(.success(result)) 25 | } catch { 26 | completion(.failure(.decodeError)) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Weather/WeatherTests/Mocks/StubData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StubData.swift 3 | // WeatherTests 4 | // 5 | // Created by Ted on 2021/08/12. 6 | // 7 | 8 | import XCTest 9 | 10 | class StubData { 11 | static let shared = StubData() 12 | 13 | func stubWeatherData() -> Data { 14 | guard let data = self.readJson(forResource: "testWeatherData") else { 15 | XCTAssert(false, "Can't get data from testWeatherData.json") 16 | return Data() 17 | } 18 | return data 19 | } 20 | } 21 | 22 | extension StubData { 23 | func readJson(forResource fileName: String ) -> Data? { 24 | let bundle = Bundle(for: type(of: self)) 25 | guard let url = bundle.url(forResource: fileName, withExtension: "json") else { 26 | XCTAssert(false, "Missing file: \(fileName).json") 27 | return nil 28 | } 29 | 30 | do { 31 | let data = try Data(contentsOf: url) 32 | return data 33 | } catch (_) { 34 | XCTAssert(false, "unable to read json") 35 | return nil 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Weather/Weather/Extensions/SwiftStandardLibrary/Double.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Double.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/11. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Double { 11 | 12 | // kelvin to celsius 13 | func makeCelsius() -> String { 14 | let argue = self - 273.15 15 | return String(format: "%.0f", arguments: [argue]) 16 | } 17 | 18 | // kelvin to fahrenheit 19 | func makeFahrenheit() -> String { 20 | let argue = (self * 9/5) - 459.67 21 | return String(format: "%.0f", arguments: [argue]) 22 | } 23 | 24 | // rounding double to 2 decimal place 25 | func makeRound() -> Double { 26 | return (self * 100).rounded() / 100 27 | } 28 | 29 | func degToCompass() -> String { 30 | let val = (Double(self / 22.5)).rounded() // round off the value 31 | let arr = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"] 32 | return arr[Int(val.truncatingRemainder(dividingBy: 16.0))] // truncatingRemainder -> Double % Double 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Weather/Weather/Services/WeatherServiceError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherServiceError.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/11. 6 | // 7 | 8 | import Foundation 9 | 10 | enum WeatherServiceError: Error, LocalizedError { 11 | 12 | case clientError 13 | case invalidStatusCode 14 | case noData 15 | case decodeError 16 | case unknown 17 | case invalidCity 18 | case custom(description: String) 19 | 20 | var errorDescription: String? { 21 | switch self { 22 | case .clientError: 23 | return "Client Error" 24 | case .invalidStatusCode: 25 | return "Hey, This is a invalid status code" 26 | case .noData: 27 | return "Hey, There is no Data" 28 | case .decodeError: 29 | return "Hey, This is a Decode Error!" 30 | case .unknown: 31 | return "Hey, This is an Unknown Error!" 32 | case .invalidCity: 33 | return "This is an invalid city. Please try again." 34 | case .custom(let description): 35 | return description 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ted.H 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/SettingModule/SettingRouter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingRouter.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/15. 6 | // 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | class SettingRouter: BaseRouter, PresenterToRouterSettingProtocol { 13 | 14 | // MARK: Static methods 15 | static func createModule() -> UIViewController { 16 | 17 | let viewController = SettingViewController() 18 | 19 | let presenter: ViewToPresenterSettingProtocol & InteractorToPresenterSettingProtocol = SettingPresenter() 20 | 21 | viewController.presenter = presenter 22 | viewController.presenter?.router = SettingRouter() 23 | viewController.presenter?.view = viewController 24 | viewController.presenter?.interactor = SettingInteractor() 25 | viewController.presenter?.interactor?.presenter = presenter 26 | 27 | return viewController 28 | } 29 | 30 | func popToRootViewController(view: PresenterToViewSettingProtocol?) { 31 | 32 | let viewController = view as! SettingViewController 33 | pop(from: viewController, animated: true) 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /Weather/Weather/Extensions/UIKit/UIFont.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIFont.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/11. 6 | // 7 | 8 | import UIKit.UIFont 9 | 10 | extension UIFont { 11 | 12 | static var mainDescriptionFont: UIFont { 13 | return UIFont.systemFont(ofSize: 20) 14 | } 15 | 16 | static var mainCityNameBoldFont: UIFont { 17 | return UIFont.boldSystemFont(ofSize: 30) 18 | } 19 | 20 | static var mainTemperatureBoldFont: UIFont { 21 | return UIFont.boldSystemFont(ofSize: 70) 22 | } 23 | 24 | static var hourlyBoldFont: UIFont { 25 | return UIFont.boldSystemFont(ofSize: 13) 26 | } 27 | 28 | static var dailyFont: UIFont { 29 | return UIFont.systemFont(ofSize: 17) 30 | } 31 | 32 | static var dailyBoldFont: UIFont { 33 | return UIFont.boldSystemFont(ofSize: 17) 34 | } 35 | 36 | static var summaryBoldFont: UIFont { 37 | return UIFont.boldSystemFont(ofSize: 16) 38 | } 39 | 40 | static var subInfoFont: UIFont { 41 | return UIFont.systemFont(ofSize: 15) 42 | } 43 | 44 | static var subInfoBoldFont: UIFont { 45 | return UIFont.boldSystemFont(ofSize: 23) 46 | } 47 | 48 | static var searchFont: UIFont { 49 | return UIFont.systemFont(ofSize: 15) 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /Weather/Weather/Extensions/Reusable/UICollectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UICollectionView+Reusable.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/12. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UICollectionReusableView: Reusable {} 11 | // We need to adopt Reusable for "forCellWithReuseIdentifier". 12 | 13 | extension UICollectionView { 14 | 15 | // register : Don't have to write "forCellWithReuseIdentifier" every time 16 | // dequeue : Don't have to wrtie "as?" every time. 17 | 18 | final func register(cellType: T.Type) { 19 | register(cellType.self, forCellWithReuseIdentifier: cellType.reuseIdentifier) 20 | } 21 | 22 | final func dequeueReusableCell(for indexPath: IndexPath, cellType: T.Type = T.self) -> T { 23 | let bareCell = self.dequeueReusableCell(withReuseIdentifier: cellType.reuseIdentifier, for: indexPath) 24 | guard let cell = bareCell as? T else { 25 | fatalError( 26 | "Failed to dequeue a cell with identifier \(cellType.reuseIdentifier) matching type \(cellType.self). " 27 | + "Check that the reuseIdentifier is set properly in your XIB/Storyboard " 28 | + "and that you registered the cell beforehand" 29 | ) 30 | } 31 | return cell 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /Weather/Weather/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/11. 6 | // 7 | 8 | import UIKit 9 | 10 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 11 | 12 | var window: UIWindow? 13 | 14 | 15 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 16 | guard let scene = (scene as? UIWindowScene) else { return } 17 | 18 | window = UIWindow(frame: UIScreen.main.bounds) 19 | window?.windowScene = scene 20 | let navigationController = UINavigationController(rootViewController: MainRouter.createModule()) 21 | navigationController.navigationBar.barStyle = .default 22 | navigationController.navigationBar.setBackgroundImage(UIImage(), for:.default) 23 | navigationController.navigationBar.shadowImage = UIImage() 24 | navigationController.navigationBar.layoutIfNeeded() 25 | window?.rootViewController = navigationController 26 | window?.makeKeyAndVisible() 27 | } 28 | 29 | func sceneDidDisconnect(_ scene: UIScene) { 30 | } 31 | 32 | func sceneDidBecomeActive(_ scene: UIScene) { 33 | } 34 | 35 | func sceneWillResignActive(_ scene: UIScene) { 36 | } 37 | 38 | func sceneWillEnterForeground(_ scene: UIScene) { 39 | } 40 | 41 | func sceneDidEnterBackground(_ scene: UIScene) { 42 | } 43 | 44 | 45 | } 46 | 47 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/MainModule/MainRouter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainRouter.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/11. 6 | // 7 | // 8 | 9 | import UIKit 10 | 11 | class MainRouter: BaseRouter, PresenterToRouterMainProtocol { 12 | 13 | // MARK: Static methods 14 | static func createModule() -> UIViewController { 15 | 16 | let viewController = MainViewController() 17 | 18 | let presenter: ViewToPresenterMainProtocol & InteractorToPresenterMainProtocol = MainPresenter() 19 | 20 | viewController.presenter = presenter 21 | viewController.presenter?.router = MainRouter() 22 | viewController.presenter?.view = viewController 23 | viewController.presenter?.interactor = MainInteractor() 24 | viewController.presenter?.interactor?.presenter = presenter 25 | 26 | return viewController 27 | } 28 | 29 | func openWeatherWebsite() { 30 | guard let url = URL(string: "https://weather.com/"), 31 | UIApplication.shared.canOpenURL(url) else { return } 32 | 33 | UIApplication.shared.open(url, options: [:], completionHandler: nil) 34 | } 35 | 36 | func pushToSettingViewController(view: PresenterToViewMainProtocol?) { 37 | let settingViewController = SettingRouter.createModule() 38 | let viewController = view as! MainViewController 39 | show(from: viewController, to: settingViewController, with: .push) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/Base/BaseRouter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseRouter.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/16. 6 | // 7 | 8 | import UIKit 9 | 10 | class BaseRouter { 11 | 12 | func show(from start: UIViewController, to destination: UIViewController, with type: TransitionType, animated: Bool = true) { 13 | switch type { 14 | case .push: 15 | guard let navigationController = start.navigationController else { 16 | fatalError("Can't push without a navigation controller") 17 | } 18 | navigationController.pushViewController(destination, animated: animated) 19 | case .present(let sender): 20 | sender.present(destination, animated: animated) 21 | } 22 | } 23 | 24 | func pop(from viewController: UIViewController, animated: Bool) { 25 | if let navigationController = viewController.navigationController { 26 | guard navigationController.popViewController(animated: animated) != nil else { 27 | if let presentingView = viewController.presentingViewController { 28 | return presentingView.dismiss(animated: animated) 29 | } else { 30 | fatalError("Can't navigate back") 31 | } 32 | } 33 | } else if let presentingView = viewController.presentingViewController { 34 | presentingView.dismiss(animated: animated) 35 | } else { 36 | fatalError("Neither modal nor navigation! Can't navigate back") 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/SettingModule/SettingPresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingPresenter.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/15. 6 | // 7 | // 8 | 9 | import UIKit 10 | 11 | class SettingPresenter { 12 | 13 | // MARK: Properties 14 | 15 | var view: PresenterToViewSettingProtocol? 16 | var interactor: PresenterToInteractorSettingProtocol? 17 | var router: PresenterToRouterSettingProtocol? 18 | 19 | } 20 | 21 | extension SettingPresenter: ViewToPresenterSettingProtocol { 22 | 23 | //MARK: -> Presenter 24 | 25 | func viewDidLoad() { 26 | interactor?.deliverDelegate() 27 | } 28 | 29 | func numberOfRows(in section: Int) -> Int { 30 | guard let numberOfRows = interactor?.locationSearchResultsCount() else { return 0 } 31 | return numberOfRows 32 | } 33 | 34 | func configureCell(_ cell: UITableViewCell, forRowAt indexPath: IndexPath) { 35 | cell.textLabel?.text = interactor?.searchResultsText(indexPath: indexPath) 36 | } 37 | 38 | func didSelectTableViewRow(at indexPath: IndexPath) { 39 | interactor?.saveSelectedLocationData(indexPath: indexPath) 40 | } 41 | 42 | func textDidChange(searchText: String) { 43 | interactor?.enterQueryFragment(with: searchText) 44 | } 45 | } 46 | 47 | extension SettingPresenter: InteractorToPresenterSettingProtocol { 48 | 49 | //MARK: Presenter <- 50 | 51 | func reloadTableView() { 52 | view?.reloadTableView() 53 | } 54 | 55 | func popToRootViewController() { 56 | router?.popToRootViewController(view: view) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/MainModule/SubViews/CollectionView/SubInfoCell/SubInfoCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SubInfoCell.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/12. 6 | // 7 | 8 | import UIKit 9 | 10 | class SubInfoCell: UICollectionViewCell { 11 | 12 | private struct UI { 13 | static let basicMargin = CGFloat(10) 14 | } 15 | 16 | //MARK: - Properties 17 | 18 | lazy var subInfoCollectionView: SubInfoCollectionView = { 19 | let layout = UICollectionViewFlowLayout() 20 | layout.scrollDirection = .horizontal 21 | layout.minimumLineSpacing = 0 22 | layout.minimumInteritemSpacing = 0 23 | let view = SubInfoCollectionView(frame: .zero, collectionViewLayout: layout) 24 | return view 25 | }() 26 | 27 | //MARK: - Init 28 | 29 | override init(frame: CGRect) { 30 | super.init(frame: frame) 31 | setupViews() 32 | } 33 | 34 | required init?(coder aDecoder: NSCoder) { 35 | super.init(coder: aDecoder) 36 | } 37 | } 38 | 39 | //MARK: - Setup Views 40 | 41 | extension SubInfoCell { 42 | 43 | private func setupViews() { 44 | backgroundColor = .clear 45 | addSubview(subInfoCollectionView) 46 | setupConstraints() 47 | } 48 | 49 | private func setupConstraints() { 50 | 51 | subInfoCollectionView 52 | .topAnchor(to: topAnchor) 53 | .leadingAnchor(to: leadingAnchor, constant: UI.basicMargin) 54 | .trailingAnchor(to: trailingAnchor, constant: -UI.basicMargin) 55 | .bottomAnchor(to: bottomAnchor) 56 | .activateAnchors() 57 | } 58 | } 59 | 60 | -------------------------------------------------------------------------------- /Weather/WeatherTests/Managers/RealmManagerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RealmManagerTests.swift 3 | // WeatherTests 4 | // 5 | // Created by Ted on 2021/08/16. 6 | // 7 | 8 | import RealmSwift 9 | import XCTest 10 | @testable import Weather 11 | 12 | class RealmManagerTests: XCTestCase { 13 | 14 | private var mockRealmManager: MockRealmManager? 15 | private var mockWeatherService: MockWeatherService? 16 | var weatherService = WeatherService() 17 | var data: WeatherResponse? 18 | 19 | private let locationData = Location(name: "Paris", latitude: 21.2859, longitude: 14.7832) 20 | 21 | override func setUp() { 22 | self.mockWeatherService = MockWeatherService() 23 | self.mockRealmManager = MockRealmManager() 24 | } 25 | 26 | override func tearDown() { 27 | self.mockRealmManager?.deleteLocalWeatherData() 28 | self.mockWeatherService = nil 29 | } 30 | 31 | func testSaveData_WhenLocationDataProvided_ShouldReturnTrue() throws { 32 | 33 | mockRealmManager?.saveLocationData(locationData) 34 | let realmData = mockRealmManager?.retrieveLocationData() 35 | 36 | XCTAssert(locationData.name == realmData?.name, "Expect created location name is Paris") 37 | XCTAssert(locationData.latitude == realmData?.latitude, "Expect created latitude is 21.2859") 38 | XCTAssert(locationData.longitude == realmData?.longitude, "Expect created longitude name is 14.7832") 39 | XCTAssert(mockRealmManager?.saveOnlyLocationDataCalled == 1, "Expect saveOnlyLocationDataCalled is 1") 40 | XCTAssert(mockRealmManager?.retrieveLocationDataCalled == 1, "Expect retrieveLocationDataCalled is 1") 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/MainModule/MainProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainProtocol.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/11. 6 | // 7 | // 8 | 9 | import UIKit 10 | 11 | // MARK: View Output (Presenter -> View) 12 | protocol PresenterToViewMainProtocol { 13 | 14 | func setupUIBinding(with viewModel: [WeatherViewModel], cityName: String) 15 | func setupUIBinding(with viewModel: WeatherDailyViewModel) 16 | func setupUIBinding(with viewModel: WeatherInfoViewModel) 17 | func reloadCollectionView() 18 | func showAlert(withMessage message: String, animated: Bool) 19 | } 20 | 21 | 22 | // MARK: View Input (View -> Presenter) 23 | protocol ViewToPresenterMainProtocol { 24 | 25 | var view: PresenterToViewMainProtocol? { get set } 26 | var interactor: PresenterToInteractorMainProtocol? { get set } 27 | var router: PresenterToRouterMainProtocol? { get set } 28 | 29 | func viewWillAppear() 30 | func viewDidAppear() 31 | func weatherButtonClicked() 32 | func settingButtonClicked() 33 | } 34 | 35 | 36 | // MARK: Interactor Input (Presenter -> Interactor) 37 | protocol PresenterToInteractorMainProtocol { 38 | 39 | var presenter: InteractorToPresenterMainProtocol? { get set } 40 | 41 | func fetchWeatherData() 42 | } 43 | 44 | 45 | // MARK: Interactor Output (Interactor -> Presenter) 46 | 47 | protocol InteractorToPresenterMainProtocol { 48 | 49 | func handleResult(_ result: WeatherResponse, cityName: String) 50 | func handleError(_ error: WeatherServiceError) 51 | } 52 | 53 | 54 | // MARK: Router Input (Presenter -> Router) 55 | protocol PresenterToRouterMainProtocol { 56 | func openWeatherWebsite() 57 | func pushToSettingViewController(view: PresenterToViewMainProtocol?) 58 | } 59 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/SettingModule/SettingProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingContract.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/15. 6 | // 7 | // 8 | 9 | import UIKit 10 | 11 | 12 | // MARK: View Output (Presenter -> View) 13 | protocol PresenterToViewSettingProtocol { 14 | func reloadTableView() 15 | } 16 | 17 | 18 | // MARK: View Input (View -> Presenter) 19 | protocol ViewToPresenterSettingProtocol { 20 | 21 | var view: PresenterToViewSettingProtocol? { get set } 22 | var interactor: PresenterToInteractorSettingProtocol? { get set } 23 | var router: PresenterToRouterSettingProtocol? { get set } 24 | 25 | func viewDidLoad() 26 | func numberOfRows(in section: Int) -> Int 27 | func configureCell(_ cell: UITableViewCell, forRowAt indexPath: IndexPath) 28 | func didSelectTableViewRow(at indexPath: IndexPath) 29 | func textDidChange(searchText: String) 30 | } 31 | 32 | 33 | // MARK: Interactor Input (Presenter -> Interactor) 34 | protocol PresenterToInteractorSettingProtocol { 35 | 36 | var presenter: InteractorToPresenterSettingProtocol? { get set } 37 | 38 | func deliverDelegate() 39 | func locationSearchResultsCount() -> Int 40 | func searchResultsText(indexPath: IndexPath) -> String 41 | func saveSelectedLocationData(indexPath: IndexPath) 42 | func enterQueryFragment(with searchText: String) 43 | } 44 | 45 | 46 | // MARK: Interactor Output (Interactor -> Presenter) 47 | protocol InteractorToPresenterSettingProtocol { 48 | func reloadTableView() 49 | func popToRootViewController() 50 | } 51 | 52 | 53 | // MARK: Router Input (Presenter -> Router) 54 | protocol PresenterToRouterSettingProtocol { 55 | func popToRootViewController(view: PresenterToViewSettingProtocol?) 56 | } 57 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/MainModule/SubViews/CollectionView/DailyCell/DailyViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DailyViewCell.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/12. 6 | // 7 | 8 | import UIKit 9 | 10 | class DailyViewCell: UICollectionViewCell { 11 | 12 | //MARK: - Properties 13 | 14 | private lazy var separateLineView = SeparateLineView() 15 | lazy var dailyCollectionView: DailyCollectionView = { 16 | let layout = UICollectionViewFlowLayout() 17 | layout.scrollDirection = .vertical 18 | layout.minimumLineSpacing = 0 19 | 20 | let view = DailyCollectionView(frame: .zero, collectionViewLayout: layout) 21 | return view 22 | }() 23 | 24 | //MARK: - Init 25 | 26 | override init(frame: CGRect) { 27 | super.init(frame: frame) 28 | setupViews() 29 | } 30 | 31 | required init?(coder aDecoder: NSCoder) { 32 | super.init(coder: aDecoder) 33 | } 34 | } 35 | 36 | //MARK: - Setup Views 37 | 38 | extension DailyViewCell { 39 | 40 | private func setupViews() { 41 | backgroundColor = .clear 42 | addSubviews([dailyCollectionView, separateLineView]) 43 | setupConstraints() 44 | } 45 | 46 | private func setupConstraints() { 47 | 48 | dailyCollectionView 49 | .topAnchor(to: topAnchor) 50 | .leadingAnchor(to: leadingAnchor) 51 | .trailingAnchor(to: trailingAnchor) 52 | .bottomAnchor(to: bottomAnchor) 53 | .activateAnchors() 54 | 55 | separateLineView 56 | .leadingAnchor(to: leadingAnchor) 57 | .trailingAnchor(to: trailingAnchor) 58 | .setupConstraints(bottom: dailyCollectionView.bottomAnchor) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Weather/Weather/SupportingFiles/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/MainModule/MainPresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainPresenter.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/11. 6 | // 7 | // 8 | 9 | import Foundation 10 | 11 | class MainPresenter { 12 | 13 | // MARK: - Properties 14 | 15 | var view: PresenterToViewMainProtocol? 16 | var interactor: PresenterToInteractorMainProtocol? 17 | var router: PresenterToRouterMainProtocol? 18 | } 19 | 20 | extension MainPresenter: ViewToPresenterMainProtocol { 21 | 22 | //MARK: -> Presenter 23 | 24 | func viewWillAppear() { 25 | interactor?.fetchWeatherData() 26 | } 27 | 28 | func viewDidAppear() { 29 | //request Location Authorization If needed 30 | } 31 | 32 | func weatherButtonClicked() { 33 | router?.openWeatherWebsite() 34 | } 35 | 36 | func settingButtonClicked() { 37 | router?.pushToSettingViewController(view: view) 38 | } 39 | } 40 | 41 | extension MainPresenter: InteractorToPresenterMainProtocol { 42 | 43 | //MARK: Presenter <- 44 | 45 | func handleResult(_ response: WeatherResponse, cityName: String) { 46 | 47 | let weatherViewModel = WeatherViewModel.getViewModels(with: response) 48 | let weatherDailyViewModel = WeatherDailyViewModel.getViewModel(with: weatherViewModel) 49 | let weatherInfoViewModel = WeatherInfoViewModel.getViewModel(with: weatherViewModel) 50 | 51 | view?.setupUIBinding(with: weatherViewModel, cityName: cityName) 52 | view?.setupUIBinding(with: weatherDailyViewModel) 53 | view?.setupUIBinding(with: weatherInfoViewModel) 54 | view?.reloadCollectionView() 55 | } 56 | 57 | func handleError(_ error: WeatherServiceError) { 58 | view?.showAlert(withMessage: error.localizedDescription, animated: true) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/Models/WeatherResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherResponse.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/11. 6 | // 7 | 8 | import UIKit 9 | 10 | struct WeatherResponse: Codable { 11 | let cod: String 12 | let message: Double 13 | let cnt: UInt 14 | let list: [WeatherListResponse] 15 | let city: WeatherCityResponse 16 | } 17 | 18 | struct WeatherListResponse: Codable { 19 | let dt: Double 20 | let main: WeatherListMainResponse 21 | let weather: [WeatherListWeatherResponse] 22 | let wind: WeatherListWindResponse 23 | let visibility: Int 24 | let dtTxt: String 25 | 26 | enum CodingKeys: String, CodingKey { 27 | case dt, main, weather, wind, visibility 28 | case dtTxt = "dt_txt" 29 | } 30 | } 31 | 32 | struct WeatherListMainResponse: Codable { 33 | let temp: Double 34 | let feelsLike: Double 35 | let tempMin: Double 36 | let tempMax: Double 37 | let pressure: Double 38 | let humidity: Int 39 | 40 | enum CodingKeys: String, CodingKey { 41 | case temp, pressure, humidity 42 | case feelsLike = "feels_like" 43 | case tempMin = "temp_min" 44 | case tempMax = "temp_max" 45 | } 46 | } 47 | 48 | struct WeatherListWeatherResponse: Codable { 49 | let id: Int 50 | let main: String 51 | let description: String 52 | let icon: String 53 | } 54 | 55 | struct WeatherListWindResponse: Codable { 56 | let speed: Double 57 | let deg: Double 58 | } 59 | 60 | struct WeatherCityResponse: Codable { 61 | let id: Int 62 | let name: String 63 | let coord: WeatherCityCoordResponse 64 | let country: String 65 | let timezone: Int 66 | let population: Int? 67 | } 68 | 69 | struct WeatherCityCoordResponse: Codable { 70 | let lat: Double 71 | let lon: Double 72 | } 73 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/MainModule/SubViews/CollectionView/HourlyCell/HourlyCollectionReusableView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HourlyCollectionReusableView.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/12. 6 | // 7 | 8 | import UIKit 9 | 10 | class HourlyCollectionReusableView: UICollectionReusableView { 11 | 12 | //MARK: - Properties 13 | 14 | private lazy var separateLineView = SeparateLineView() 15 | lazy var hourlyCollectionView: HourlyCollectionView = { 16 | let layout = UICollectionViewFlowLayout() 17 | layout.scrollDirection = .horizontal 18 | layout.minimumLineSpacing = 0 19 | 20 | let view = HourlyCollectionView(frame: .zero, collectionViewLayout: layout) 21 | return view 22 | }() 23 | 24 | //MARK: - Init 25 | 26 | override init(frame: CGRect) { 27 | super.init(frame: frame) 28 | setupViews() 29 | } 30 | 31 | required init?(coder aDecoder: NSCoder) { 32 | super.init(coder: aDecoder) 33 | } 34 | } 35 | 36 | //MARK: - Setup Views 37 | 38 | extension HourlyCollectionReusableView { 39 | 40 | private func setupViews() { 41 | backgroundColor = .clear 42 | addSubviews([hourlyCollectionView, separateLineView]) 43 | setupConstraints() 44 | } 45 | 46 | private func setupConstraints() { 47 | 48 | hourlyCollectionView 49 | .topAnchor(to: topAnchor) 50 | .leadingAnchor(to: leadingAnchor) 51 | .trailingAnchor(to: trailingAnchor) 52 | .bottomAnchor(to: bottomAnchor) 53 | .activateAnchors() 54 | 55 | separateLineView 56 | .leadingAnchor(to: leadingAnchor) 57 | .trailingAnchor(to: trailingAnchor) 58 | .setupConstraints(bottom: hourlyCollectionView.bottomAnchor) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Weather/Weather/SupportingFiles/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Weather/Weather/SupportingFiles/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UISceneConfigurationName 33 | Default Configuration 34 | UISceneDelegateClassName 35 | $(PRODUCT_MODULE_NAME).SceneDelegate 36 | 37 | 38 | 39 | 40 | UIApplicationSupportsIndirectInputEvents 41 | 42 | UILaunchStoryboardName 43 | LaunchScreen 44 | UIRequiredDeviceCapabilities 45 | 46 | armv7 47 | 48 | UISupportedInterfaceOrientations 49 | 50 | UIInterfaceOrientationPortrait 51 | 52 | UISupportedInterfaceOrientations~ipad 53 | 54 | UIInterfaceOrientationPortrait 55 | UIInterfaceOrientationPortraitUpsideDown 56 | UIInterfaceOrientationLandscapeLeft 57 | UIInterfaceOrientationLandscapeRight 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/MainModule/SubViews/CollectionView/SummaryCell/SummaryCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SummaryCell.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/12. 6 | // 7 | 8 | import Then 9 | import UIKit 10 | 11 | class SummaryCell: UICollectionViewCell { 12 | 13 | //MARK: - UI Metrics 14 | 15 | private struct UI { 16 | static let basicMargin = CGFloat(10) 17 | } 18 | 19 | //MARK: - Properties 20 | 21 | private lazy var separateLineView = SeparateLineView() 22 | private lazy var descriptionLabel = UILabel().then { 23 | $0.textColor = .warmBlack 24 | $0.font = .summaryBoldFont 25 | $0.numberOfLines = 0 26 | } 27 | 28 | 29 | //MARK: - Init 30 | 31 | override init(frame: CGRect) { 32 | super.init(frame: frame) 33 | setupViews() 34 | } 35 | 36 | required init?(coder aDecoder: NSCoder) { 37 | super.init(coder: aDecoder) 38 | } 39 | } 40 | 41 | //MARK: - Setup Views 42 | 43 | extension SummaryCell { 44 | 45 | private func setupViews() { 46 | backgroundColor = .clear 47 | addSubviews([descriptionLabel, separateLineView]) 48 | setupConstraints() 49 | } 50 | 51 | private func setupConstraints() { 52 | 53 | descriptionLabel 54 | .leadingAnchor(to: leadingAnchor, constant: UI.basicMargin) 55 | .trailingAnchor(to: trailingAnchor, constant: -UI.basicMargin) 56 | .topAnchor(to: topAnchor, constant: UI.basicMargin) 57 | .bottomAnchor(to: bottomAnchor, constant: -UI.basicMargin) 58 | .activateAnchors() 59 | 60 | separateLineView 61 | .leadingAnchor(to: leadingAnchor) 62 | .trailingAnchor(to: trailingAnchor) 63 | .setupConstraints(bottom: bottomAnchor) 64 | } 65 | } 66 | //MARK: - Configure Cell 67 | 68 | extension SummaryCell { 69 | func configureCell(viewModel: WeatherDailyViewModel) { 70 | descriptionLabel.text = "Today: Mostly \(viewModel.conditionImage[0]). The high today was forecast as \(viewModel.temp_max[0])" 71 | } 72 | } 73 | 74 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/MainModule/SubViews/CollectionView/SubInfoCell/SubInfoCollectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SubInfoCollectionView.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/13. 6 | // 7 | 8 | import UIKit 9 | 10 | class SubInfoCollectionView: UICollectionView { 11 | 12 | //MARK: - CallBack 13 | 14 | var collectionCellDidLoad: ((SubInfoCollectionCell, IndexPath) -> Void)? 15 | 16 | //MARK: - Init 17 | 18 | override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) { 19 | super.init(frame: frame, collectionViewLayout: layout) 20 | setupViews() 21 | } 22 | 23 | required init?(coder aDecoder: NSCoder) { 24 | super.init(coder: aDecoder) 25 | } 26 | } 27 | 28 | //MARK: - Setup Views 29 | 30 | extension SubInfoCollectionView { 31 | 32 | private func setupViews() { 33 | backgroundColor = .clear 34 | allowsSelection = false 35 | isScrollEnabled = false 36 | dataSource = self 37 | delegate = self 38 | register(cellType: SubInfoCollectionCell.self) 39 | } 40 | } 41 | 42 | //MARK: - UICollectionViewDataSource 43 | 44 | extension SubInfoCollectionView: UICollectionViewDataSource { 45 | func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 46 | return 6 47 | } 48 | 49 | func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 50 | 51 | let cell = collectionView.dequeueReusableCell(for: indexPath, cellType: SubInfoCollectionCell.self) 52 | 53 | if let collectionCellDidLoad = collectionCellDidLoad { 54 | collectionCellDidLoad(cell, indexPath) 55 | } else { 56 | return cell 57 | } 58 | 59 | return cell 60 | } 61 | 62 | } 63 | 64 | //MARK: - UICollectionViewDelegateFlowLayout 65 | 66 | extension SubInfoCollectionView: UICollectionViewDelegateFlowLayout { 67 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { 68 | return CGSize(width: UIScreen.main.bounds.width / 2, 69 | height: frame.height / 3) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/MainModule/ViewModels/WeatherDailyViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherDailyViewModel.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/13. 6 | // 7 | 8 | import Foundation 9 | 10 | // This API doesn't offer a daily weather forecast. 😫 11 | // So I made this view model to calculate the highest and lowest temperature. 12 | // but this is very temporary way. Better to find another API to offer a daily weather forecast. 13 | 14 | struct WeatherDailyViewModel { 15 | 16 | let day: [String] 17 | let temp_min: [String] 18 | let temp_max: [String] 19 | let conditionImage: [String] 20 | 21 | init(temp_min: [String], temp_max: [String], conditionImage: [String], day: [String]) { 22 | self.day = day 23 | self.temp_min = temp_min 24 | self.temp_max = temp_max 25 | self.conditionImage = conditionImage 26 | } 27 | 28 | static func getViewModel(with weatherViewModel: [WeatherViewModel]) -> WeatherDailyViewModel { 29 | 30 | var minTempArray = [String]() 31 | var maxTempArray = [String]() 32 | var conditionIDArray = [String]() 33 | // weatherViewModel -> [WeatherViewModel] 34 | let temporaryDailyDictionary = Dictionary(grouping: weatherViewModel, by: { $0.dateWithMonth }) 35 | // temporaryDailyDictionary -> ["17/08": [WeatherViewModel], 18/08: [WeatherViewModel] ..] 36 | let keysArray = Array(temporaryDailyDictionary.keys).sorted(by: { $0 < $1 }) 37 | // keysArray -> ["17/08, 18/08, .."] 38 | 39 | temporaryDailyDictionary.forEach { key, value in 40 | // value => [WeatherViewModel] 41 | // find highest temperature using max. 42 | let tempMax = value.max { $0.tempMaxInt < $1.tempMaxInt } 43 | // find lowest temperature using min. 44 | let tempMin = value.min { $0.tempMinInt < $1.tempMinInt } 45 | guard let tempMax = tempMax, let tempMin = tempMin else { return } 46 | 47 | maxTempArray.append(tempMax.tempMax) 48 | minTempArray.append(tempMin.tempMin) 49 | conditionIDArray.append(tempMax.conditionImage) 50 | } 51 | 52 | return WeatherDailyViewModel(temp_min: minTempArray, temp_max: maxTempArray, conditionImage: conditionIDArray, day: keysArray) 53 | } 54 | } 55 | 56 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/MainModule/SubViews/CollectionView/HourlyCell/HourlyCollectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HourlyCollectionView.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/12. 6 | // 7 | 8 | import UIKit 9 | 10 | class HourlyCollectionView: UICollectionView { 11 | 12 | //MARK: - UI Metrics 13 | 14 | private struct UI { 15 | static let cellWidth = CGFloat(65) 16 | } 17 | 18 | //MARK: - CallBack 19 | 20 | var hourlyCellDidLoad: ((HourlyCollectionCell, IndexPath) -> Void)? 21 | 22 | //MARK: - Init 23 | 24 | override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) { 25 | super.init(frame: frame, collectionViewLayout: layout) 26 | setupViews() 27 | } 28 | 29 | required init?(coder aDecoder: NSCoder) { 30 | super.init(coder: aDecoder) 31 | } 32 | } 33 | 34 | //MARK: - Setup Views 35 | 36 | extension HourlyCollectionView { 37 | 38 | private func setupViews() { 39 | backgroundColor = .clear 40 | showsHorizontalScrollIndicator = false 41 | dataSource = self 42 | delegate = self 43 | register(cellType: HourlyCollectionCell.self) 44 | } 45 | } 46 | 47 | //MARK: - UICollectionViewDataSource & UICollectionViewDelegateFlowLayout 48 | 49 | extension HourlyCollectionView: UICollectionViewDataSource { 50 | func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 51 | return 24 52 | } 53 | 54 | func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 55 | 56 | let cell = collectionView.dequeueReusableCell(for: indexPath, cellType: HourlyCollectionCell.self) 57 | 58 | if let hourlyCellDidLoad = self.hourlyCellDidLoad { 59 | hourlyCellDidLoad(cell, indexPath) 60 | } else { 61 | return cell 62 | } 63 | 64 | return cell 65 | } 66 | } 67 | 68 | extension HourlyCollectionView: UICollectionViewDelegateFlowLayout { 69 | 70 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { 71 | return CGSize(width: UI.cellWidth, height: self.frame.height) 72 | } 73 | } 74 | 75 | 76 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/MainModule/SubViews/CollectionView/SubInfoCell/SubInfoCollectionCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SubInfoCollectionCell.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/13. 6 | // 7 | 8 | import Then 9 | import UIKit 10 | 11 | class SubInfoCollectionCell: UICollectionViewCell { 12 | 13 | //MARK: - UI Metrics 14 | 15 | private struct UI { 16 | static let basicMargin = CGFloat(5) 17 | } 18 | 19 | //MARK: - Properties 20 | 21 | private let subInfoTitles = ["Feels like", "Humidity", "Pressure", "Wind Speed", "Wind Direction", "Visibility"] 22 | private lazy var separateLineView = SeparateLineView() 23 | private lazy var topLabel = UILabel().then { 24 | $0.textColor = .warmGray 25 | $0.font = .subInfoFont 26 | } 27 | private lazy var bottomLabel = UILabel().then { 28 | $0.textColor = .warmBlack 29 | $0.font = .subInfoBoldFont 30 | } 31 | 32 | //MARK: - Init 33 | 34 | override init(frame: CGRect) { 35 | super.init(frame: frame) 36 | setupViews() 37 | } 38 | 39 | required init?(coder aDecoder: NSCoder) { 40 | super.init(coder: aDecoder) 41 | } 42 | } 43 | 44 | //MARK: - Setup Views 45 | 46 | extension SubInfoCollectionCell { 47 | 48 | private func setupViews() { 49 | backgroundColor = .clear 50 | addSubviews([topLabel, bottomLabel]) 51 | setupConstraints() 52 | } 53 | 54 | private func setupConstraints() { 55 | 56 | topLabel 57 | .leadingAnchor(to: leadingAnchor) 58 | .topAnchor(to: topAnchor, constant: UI.basicMargin) 59 | .trailingAnchor(to: trailingAnchor) 60 | .heightAnchor(constant: self.frame.height * 0.3) 61 | .activateAnchors() 62 | 63 | bottomLabel 64 | .leadingAnchor(to: leadingAnchor) 65 | .topAnchor(to: topLabel.bottomAnchor) 66 | .trailingAnchor(to: trailingAnchor) 67 | .bottomAnchor(to: bottomAnchor) 68 | .activateAnchors() 69 | } 70 | 71 | } 72 | //MARK: - Configure Cell 73 | 74 | extension SubInfoCollectionCell { 75 | 76 | func configureCell(viewModel: WeatherInfoViewModel, item: Int) { 77 | self.topLabel.text = subInfoTitles[item] 78 | self.bottomLabel.text = viewModel.infos[item] 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Weather/Weather/Extensions/SwiftStandardLibrary/Date.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/11. 6 | // 7 | 8 | import UIKit 9 | 10 | extension Date { 11 | 12 | func minutes(from date: Date) -> Int { 13 | return Calendar.current.dateComponents([.minute], from: date, to: self).minute ?? 0 14 | } 15 | 16 | static func calcuateGMT(time: Int) -> String { 17 | // time -> 7200(France) 18 | let timeZone = abs(time / 3600) 19 | let compare = time < 0 ? "-" : "+" 20 | 21 | if timeZone < 10 { 22 | // GMT+08, GMT-08 23 | return "GMT\(compare)0\(timeZone)" 24 | } else { 25 | // GMT+10, GMT-11 26 | return "GMT\(compare)\(timeZone)" 27 | } 28 | } 29 | 30 | static func convertToUTCDate(from timestamp: String) -> Date? { 31 | let df = DateFormatter() 32 | df.dateFormat = "yyyy-MM-dd HH:mm:ss" 33 | df.timeZone = TimeZone(abbreviation: "UTC") 34 | return df.date(from: timestamp) 35 | } 36 | 37 | static func convertToLocalTimeString(date: Date?, timeZone: Int, dateFormat: String) -> String { 38 | let df = DateFormatter() 39 | df.timeZone = TimeZone(abbreviation: calcuateGMT(time: timeZone)) 40 | df.dateFormat = dateFormat 41 | return df.string(from: date!) 42 | } 43 | 44 | static func getddMMYYYYFormat(timestamp: String, timeZone: Int) -> String { 45 | let date = convertToUTCDate(from: timestamp) 46 | return convertToLocalTimeString(date: date, timeZone: timeZone, dateFormat: "dd/MM/YYYY") 47 | } 48 | 49 | static func getddMMFormat(timestamp: String, timeZone: Int) -> String { 50 | let date = convertToUTCDate(from: timestamp) 51 | return convertToLocalTimeString(date: date, timeZone: timeZone, dateFormat: "dd/MM") 52 | } 53 | 54 | static func getHHFormat(timestamp: String, timeZone: Int) -> String { 55 | let date = convertToUTCDate(from: timestamp) 56 | return convertToLocalTimeString(date: date, timeZone: timeZone, dateFormat: "HH'h'") 57 | } 58 | 59 | static func getWeekDay(timestamp: String, timeZone: Int) -> String { 60 | let date = convertToUTCDate(from: timestamp) 61 | return convertToLocalTimeString(date: date, timeZone: timeZone, dateFormat: "EEEE") 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/MainModule/SubViews/CollectionView/DailyCell/DailyCollectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DailyCollectionView.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/13. 6 | // 7 | 8 | import UIKit 9 | 10 | class DailyCollectionView: UICollectionView { 11 | 12 | //MARK: - UI Metrics 13 | 14 | private struct UI { 15 | static let numberOfItemsInSection = Int(6) 16 | } 17 | 18 | //MARK: - CallBack 19 | 20 | var dailyCellDidLoad: ((DailyCollectionCell, IndexPath) -> Void)? 21 | 22 | //MARK: - Init 23 | 24 | override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) { 25 | super.init(frame: frame, collectionViewLayout: layout) 26 | setupViews() 27 | } 28 | 29 | required init?(coder aDecoder: NSCoder) { 30 | super.init(coder: aDecoder) 31 | } 32 | } 33 | 34 | //MARK: - Setup Views 35 | 36 | extension DailyCollectionView { 37 | 38 | private func setupViews() { 39 | backgroundColor = .clear 40 | allowsSelection = false 41 | dataSource = self 42 | delegate = self 43 | register(cellType: DailyCollectionCell.self) 44 | } 45 | } 46 | 47 | //MARK: - UICollectionViewDataSource 48 | 49 | extension DailyCollectionView: UICollectionViewDataSource { 50 | func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 51 | return UI.numberOfItemsInSection 52 | } 53 | 54 | func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 55 | 56 | let cell = collectionView.dequeueReusableCell(for: indexPath, cellType: DailyCollectionCell.self) 57 | 58 | if let dailyCellDidLoad = dailyCellDidLoad { 59 | dailyCellDidLoad(cell, indexPath) 60 | } else { 61 | return cell 62 | } 63 | 64 | return cell 65 | } 66 | 67 | } 68 | 69 | //MARK: - UICollectionViewDelegateFlowLayout 70 | 71 | extension DailyCollectionView: UICollectionViewDelegateFlowLayout { 72 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { 73 | return CGSize(width: UIScreen.main.bounds.width, 74 | height: frame.height / CGFloat(UI.numberOfItemsInSection)) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Weather/Weather/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/11. 6 | // 7 | 8 | import UIKit 9 | import RealmSwift 10 | 11 | @main 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | 15 | 16 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 17 | let config = Realm.Configuration( 18 | schemaVersion: 5, // Set the new schema version. 19 | migrationBlock: { migration, oldSchemaVersion in 20 | if oldSchemaVersion < 5 { 21 | // The enumerateObjects(ofType:_:) method iterates over 22 | // every Person object stored in the Realm file 23 | migration.enumerateObjects(ofType: LocalWeather.className()) { oldObject, newObject in 24 | newObject!["locationName"] = String() 25 | } 26 | migration.enumerateObjects(ofType: LocalWeather.className()) { oldObject, newObject in 27 | newObject!["latitude"] = Double() 28 | } 29 | migration.enumerateObjects(ofType: LocalWeather.className()) { oldObject, newObject in 30 | newObject!["longitude"] = Double() 31 | } 32 | migration.enumerateObjects(ofType: LocalWeather.className()) { oldObject, newObject in 33 | newObject!["lastRefreshedDate"] = Date() 34 | } 35 | } 36 | } 37 | ) 38 | // Tell Realm to use this new configuration object for the default Realm 39 | Realm.Configuration.defaultConfiguration = config 40 | // Now that we've told Realm how to handle the schema change, opening the file 41 | // will automatically perform the migration 42 | let _ = try! Realm() 43 | 44 | return true 45 | } 46 | 47 | // MARK: UISceneSession Lifecycle 48 | 49 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 50 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 51 | } 52 | 53 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 54 | } 55 | } 56 | 57 | -------------------------------------------------------------------------------- /Weather/Weather/Extensions/UIView/UIView+LayoutAnchor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIView+LayoutAnchor.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/11. 6 | // 7 | 8 | import UIKit 9 | 10 | // NSLayoutAnchor Helper 11 | extension UIView { 12 | func topAnchor(to anchor: NSLayoutYAxisAnchor, constant: CGFloat = 0) -> Self { 13 | topAnchor.constraint(equalTo: anchor, constant: constant).isActive = true 14 | return self 15 | } 16 | 17 | func leadingAnchor(to anchor: NSLayoutXAxisAnchor, constant: CGFloat = 0) -> Self { 18 | leadingAnchor.constraint(equalTo: anchor, constant: constant).isActive = true 19 | return self 20 | } 21 | 22 | func bottomAnchor(to anchor: NSLayoutYAxisAnchor, constant: CGFloat = 0) -> Self { 23 | bottomAnchor.constraint(equalTo: anchor, constant: constant).isActive = true 24 | return self 25 | } 26 | 27 | func trailingAnchor(to anchor: NSLayoutXAxisAnchor, constant: CGFloat = 0) -> Self { 28 | trailingAnchor.constraint(equalTo: anchor, constant: constant).isActive = true 29 | return self 30 | } 31 | 32 | func widthAnchor(constant: CGFloat) -> Self { 33 | widthAnchor.constraint(equalToConstant: constant).isActive = true 34 | return self 35 | } 36 | 37 | func heightAnchor(constant: CGFloat) -> Self { 38 | heightAnchor.constraint(equalToConstant: constant).isActive = true 39 | return self 40 | } 41 | 42 | func dimensionAnchors(width widthConstant: CGFloat, height heightConstant: CGFloat) -> Self { 43 | widthAnchor.constraint(equalToConstant: widthConstant).isActive = true 44 | heightAnchor.constraint(equalToConstant: heightConstant).isActive = true 45 | return self 46 | } 47 | 48 | func dimensionAnchors(size: CGSize) -> Self { 49 | widthAnchor.constraint(equalToConstant: size.width).isActive = true 50 | heightAnchor.constraint(equalToConstant: size.height).isActive = true 51 | return self 52 | } 53 | 54 | func centerYAnchor(to anchor: NSLayoutYAxisAnchor) -> Self { 55 | centerYAnchor.constraint(equalTo: anchor).isActive = true 56 | return self 57 | } 58 | 59 | func centerXAnchor(to anchor: NSLayoutXAxisAnchor) -> Self { 60 | centerXAnchor.constraint(equalTo: anchor).isActive = true 61 | return self 62 | } 63 | 64 | func activateAnchors() { 65 | translatesAutoresizingMaskIntoConstraints = false 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | 92 | .DS_Store 93 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/MainModule/SubViews/CollectionView/HourlyCell/HourlyCollectionCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HourlyCollectionCell.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/12. 6 | // 7 | 8 | import Then 9 | import UIKit 10 | 11 | class HourlyCollectionCell: UICollectionViewCell { 12 | 13 | //MARK: - UI Metrics 14 | 15 | private struct UI { 16 | static let labelHeight = CGFloat(15) 17 | static let miniMargin = CGFloat(5) 18 | static let basicMargin = CGFloat(10) 19 | } 20 | 21 | //MARK: - Properties 22 | 23 | private lazy var hourLabel = UILabel().then { 24 | $0.font = .hourlyBoldFont 25 | $0.textColor = .warmBlack 26 | } 27 | private lazy var weatherIconImageView = UIImageView().then { 28 | $0.contentMode = .scaleAspectFit 29 | } 30 | private lazy var tempLabel = UILabel().then { 31 | $0.font = .hourlyBoldFont 32 | $0.textColor = .warmBlack 33 | } 34 | 35 | //MARK: - Init 36 | 37 | override init(frame: CGRect) { 38 | super.init(frame: frame) 39 | setupViews() 40 | } 41 | 42 | required init?(coder aDecoder: NSCoder) { 43 | super.init(coder: aDecoder) 44 | } 45 | } 46 | 47 | //MARK: - Setup Views 48 | 49 | extension HourlyCollectionCell { 50 | 51 | private func setupViews() { 52 | backgroundColor = .clear 53 | addSubviews([hourLabel, weatherIconImageView, tempLabel]) 54 | setupConstraints() 55 | } 56 | 57 | private func setupConstraints() { 58 | 59 | hourLabel 60 | .centerXAnchor(to: centerXAnchor) 61 | .heightAnchor(constant: UI.labelHeight) 62 | .topAnchor(to: topAnchor) 63 | .activateAnchors() 64 | 65 | tempLabel 66 | .centerXAnchor(to: centerXAnchor) 67 | .heightAnchor(constant: UI.labelHeight) 68 | .bottomAnchor(to: bottomAnchor, constant: -UI.miniMargin) 69 | .activateAnchors() 70 | 71 | weatherIconImageView 72 | .leadingAnchor(to: leadingAnchor, constant: UI.basicMargin) 73 | .trailingAnchor(to: trailingAnchor, constant: -UI.basicMargin) 74 | .topAnchor(to: hourLabel.bottomAnchor, constant: UI.basicMargin) 75 | .bottomAnchor(to: tempLabel.topAnchor, constant: -UI.basicMargin) 76 | .activateAnchors() 77 | } 78 | } 79 | 80 | //MARK: - Configure Cell 81 | 82 | extension HourlyCollectionCell { 83 | 84 | func configureCell(viewModel: [WeatherViewModel], item: Int) { 85 | hourLabel.text = viewModel[item].hour 86 | weatherIconImageView.image = UIImage(named: viewModel[item].conditionImage) 87 | tempLabel.text = viewModel[item].temp 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Weather/WeatherTests/Mocks/MockRealmManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockRealmManager.swift 3 | // WeatherTests 4 | // 5 | // Created by Ted on 2021/08/16. 6 | // 7 | 8 | import RealmSwift 9 | import Foundation 10 | @testable import Weather 11 | 12 | class MockRealmManager: RealmManager { 13 | 14 | var saveOnlyLocationDataCalled = 0 15 | var saveOnlyWeatherDataCalled = 0 16 | var saveAllDataCalled = 0 17 | var retrieveAllDataCalled = 0 18 | var retrieveDecodedWeatherDataCalled = 0 19 | var retrieveLocationDataCalled = 0 20 | 21 | override func saveLocalWeatherData(weather: WeatherResponse, location: Location) { 22 | self.saveAllDataCalled += 1 23 | Realm.Configuration.defaultConfiguration.inMemoryIdentifier = "weather-realmManager-tests" 24 | super.saveLocalWeatherData(weather: weather, location: location) 25 | } 26 | 27 | override func saveLocationData(_ location: Location) { 28 | self.saveOnlyLocationDataCalled += 1 29 | Realm.Configuration.defaultConfiguration.inMemoryIdentifier = "weather-realmManager-tests" 30 | super.saveLocationData(location) 31 | } 32 | 33 | override func saveWeatherResponse(_ weather: WeatherResponse) { 34 | self.saveOnlyWeatherDataCalled += 1 35 | Realm.Configuration.defaultConfiguration.inMemoryIdentifier = "weather-realmManager-tests" 36 | super.saveWeatherResponse(weather) 37 | } 38 | 39 | override func retrieveLocalWeatherData() -> LocalWeather? { 40 | self.retrieveAllDataCalled += 1 41 | Realm.Configuration.defaultConfiguration.inMemoryIdentifier = "weather-realmManager-tests" 42 | return super.retrieveLocalWeatherData() 43 | } 44 | 45 | override func retrieveWeatherResponse() -> WeatherResponse? { 46 | self.retrieveDecodedWeatherDataCalled += 1 47 | Realm.Configuration.defaultConfiguration.inMemoryIdentifier = "weather-realmManager-tests" 48 | return self.retrieveWeatherResponse() 49 | } 50 | 51 | func retrieveLocationData() -> Location? { 52 | self.retrieveLocationDataCalled += 1 53 | Realm.Configuration.defaultConfiguration.inMemoryIdentifier = "weather-realmManager-tests" 54 | 55 | let testRealm = try! Realm() 56 | 57 | guard let localWeather = testRealm.objects(LocalWeather.self).first 58 | else { 59 | return nil 60 | } 61 | 62 | let location = Location(name: localWeather.locationName!, latitude: localWeather.latitude, longitude: localWeather.longitude) 63 | 64 | return location 65 | } 66 | 67 | override func deleteLocalWeatherData() { 68 | Realm.Configuration.defaultConfiguration.inMemoryIdentifier = "weather-realmManager-tests" 69 | super.deleteLocalWeatherData() 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/MainModule/SubViews/ToolBar/Toolbar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Toolbar.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/14. 6 | // 7 | 8 | import Then 9 | import UIKit 10 | 11 | class ToolBar: UIToolbar { 12 | 13 | //MARK: - UI Metrics 14 | 15 | private struct UI { 16 | static let basicMargin = CGFloat(15) 17 | } 18 | 19 | // MARK: - Callback 20 | 21 | var weatherButtonDidTap: (() -> ())? 22 | var settingButtonDidTap: (() -> ())? 23 | 24 | //MARK: - Properties 25 | 26 | private let configuration = UIImage.SymbolConfiguration(pointSize: 23, weight: .regular, scale: .default) 27 | private lazy var weatherButton = UIButton().then { 28 | $0.sizeToFit() 29 | $0.tintColor = .warmBlack 30 | $0.setImage(UIImage(systemName: "safari", withConfiguration: configuration), for: .normal) 31 | $0.addTarget(self, action: #selector(weatherButtondidTap(_:)), for: .touchUpInside) 32 | } 33 | 34 | private lazy var locationListButton = UIButton().then { 35 | $0.sizeToFit() 36 | $0.tintColor = .warmBlack 37 | $0.setImage(UIImage(systemName: "location.viewfinder", withConfiguration: configuration), for: .normal) 38 | $0.addTarget(self, action: #selector(locationButtondidTap(_:)), for: .touchUpInside) 39 | } 40 | 41 | //MARK: - Init 42 | 43 | override init(frame: CGRect) { 44 | super.init(frame: frame) 45 | setupViews() 46 | } 47 | 48 | required init?(coder aDecoder: NSCoder) { 49 | super.init(coder: aDecoder) 50 | } 51 | } 52 | 53 | //MARK: - Setup Views 54 | 55 | extension ToolBar { 56 | 57 | private func setupViews() { 58 | barTintColor = .white 59 | addSubviews([weatherButton, locationListButton]) 60 | setupConstraints() 61 | } 62 | 63 | private func setupConstraints() { 64 | 65 | weatherButton 66 | .centerYAnchor(to: centerYAnchor) 67 | .leadingAnchor(to: safeAreaLayoutGuide.leadingAnchor, constant: UI.basicMargin) 68 | .activateAnchors() 69 | 70 | locationListButton 71 | .centerYAnchor(to: centerYAnchor) 72 | .trailingAnchor(to: safeAreaLayoutGuide.trailingAnchor, constant: -UI.basicMargin) 73 | .activateAnchors() 74 | } 75 | } 76 | 77 | //MARK: - Selector 78 | 79 | extension ToolBar { 80 | 81 | @objc private func weatherButtondidTap(_ sender: UIButton) { 82 | 83 | if let weatherButtonDidTap = weatherButtonDidTap { 84 | weatherButtonDidTap() 85 | } 86 | } 87 | 88 | @objc private func locationButtondidTap(_ sender: UIButton) { 89 | 90 | if let locationListButtonDidTap = settingButtonDidTap { 91 | locationListButtonDidTap() 92 | } 93 | } 94 | } 95 | 96 | -------------------------------------------------------------------------------- /Weather/Weather/Services/WeatherService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherService.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/11. 6 | // 7 | 8 | import Foundation 9 | 10 | class WeatherService: WeatherServiceType { 11 | 12 | private let baseURL = "https://api.openweathermap.org/data/2.5/forecast" 13 | private let API_KEY = "da69ade359c47e35161bf2e2dad374e8" 14 | 15 | func fetchWeather(lat: Double, lon: Double, completion: @escaping (Result) -> Void) { 16 | 17 | var urlComponent = URLComponents(string: baseURL) 18 | 19 | urlComponent?.queryItems = [ 20 | URLQueryItem(name: "APPID", value: "\(API_KEY)"), 21 | URLQueryItem(name: "lat", value: "\(lat)"), 22 | URLQueryItem(name: "lon", value: "\(lon)"), 23 | URLQueryItem(name: "units", value: "metric") 24 | ] 25 | //NEED 26 | guard let url = urlComponent?.url else { return } 27 | handleRequest(url: url, completion: completion) 28 | } 29 | 30 | 31 | func fetchWeather(byCity city: String, completion: @escaping (Result) -> Void) { 32 | //Returns the character set for characters allowed in a query URL component. 33 | //(Korean) -> (url component) 34 | let cityName = city.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? city 35 | 36 | var urlComponent = URLComponents(string: baseURL) 37 | 38 | urlComponent?.queryItems = [ 39 | URLQueryItem(name: "q", value: "\(cityName)"), 40 | URLQueryItem(name: "APPID", value: "\(API_KEY)"), 41 | URLQueryItem(name: "units", value: "metric") 42 | ] 43 | 44 | guard let url = urlComponent?.url else { return } 45 | handleRequest(url: url, completion: completion) 46 | } 47 | 48 | func handleRequest(url: URL, completion: @escaping (Result) -> Void) { 49 | 50 | let task = URLSession.shared.dataTask(with: url) { data, response, error in 51 | guard error == nil else { 52 | return completion(.failure(.clientError)) 53 | } 54 | 55 | guard let header = response as? HTTPURLResponse, (200..<300) ~= header.statusCode else { 56 | return completion(.failure(.invalidStatusCode)) 57 | } 58 | 59 | guard let data = data else { 60 | return completion(.failure(.noData)) 61 | } 62 | 63 | do { 64 | let result = try JSONDecoder().decode(WeatherResponse.self, from: data) 65 | completion(.success(result)) 66 | } catch { 67 | completion(.failure(.decodeError)) 68 | } 69 | } 70 | task.resume() 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/SettingModule/SettingInteractor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingInteractor.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/15. 6 | // 7 | // 8 | 9 | import MapKit 10 | import Foundation 11 | 12 | class SettingInteractor: NSObject { 13 | 14 | // MARK: Properties 15 | var presenter: InteractorToPresenterSettingProtocol? 16 | 17 | private var searchResults = [MKLocalSearchCompletion]() 18 | private var searchCompleter = MKLocalSearchCompleter() 19 | private let realmManager = RealmManager() 20 | } 21 | 22 | //MARK: -> Interactor 23 | 24 | extension SettingInteractor: PresenterToInteractorSettingProtocol { 25 | func deliverDelegate() { 26 | searchCompleter.delegate = self 27 | } 28 | 29 | // numberOfRows 30 | func locationSearchResultsCount() -> Int { 31 | return searchResults.count 32 | } 33 | 34 | // cellForRowAt / configureCell 35 | func searchResultsText(indexPath: IndexPath) -> String { 36 | let searchResult = searchResults[indexPath.row] 37 | return searchResult.title 38 | } 39 | 40 | // didSelectTableViewRow 41 | func saveSelectedLocationData(indexPath: IndexPath) { 42 | let selectedResult = searchResults[indexPath.row] 43 | let searchRequest = MKLocalSearch.Request(completion: selectedResult) 44 | let search = MKLocalSearch(request: searchRequest) 45 | 46 | // find the location name, latitude and, longitude 47 | search.start { (response, error) in 48 | guard error == nil else { return } 49 | guard let placeMark = response?.mapItems[0].placemark else { return } 50 | guard let locationName = placeMark.name else { return } 51 | 52 | let location = Location(name: locationName, latitude: placeMark.coordinate.latitude, longitude: placeMark.coordinate.longitude) 53 | // Delete All local data and Save Only Location Data (No Weather Data) 54 | self.realmManager.saveLocationData(location) 55 | // go back to RootViewController 56 | self.presenter?.popToRootViewController() 57 | } 58 | } 59 | 60 | // textDidChange 61 | func enterQueryFragment(with searchText: String) { 62 | if searchText == "" { 63 | searchResults.removeAll() 64 | presenter?.reloadTableView() 65 | } 66 | // The text entered by the user in the search bar is put into the auto-completion target. 67 | searchCompleter.queryFragment = searchText 68 | } 69 | } 70 | 71 | //MARK: - MKLocalSearchCompleterDelegate 72 | 73 | extension SettingInteractor: MKLocalSearchCompleterDelegate { 74 | func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) { 75 | // update search results 76 | searchResults = completer.results 77 | presenter?.reloadTableView() 78 | } 79 | func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) { 80 | print("Cancel") 81 | } 82 | } 83 | 84 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/MainModule/SubViews/Header/HeaderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainWeatherView.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/11. 6 | // 7 | 8 | import Then 9 | import UIKit 10 | 11 | class HeaderView: UIView { 12 | 13 | //MARK: - UI Metrics 14 | 15 | private struct UI { 16 | static let basicMargin = CGFloat(10) 17 | } 18 | 19 | //MARK: - Properties 20 | 21 | private lazy var cityNameLabel = UILabel().then { 22 | $0.font = .mainCityNameBoldFont 23 | $0.textColor = .warmBlack 24 | $0.textAlignment = .center 25 | } 26 | private lazy var temperatureLabel = UILabel().then { 27 | $0.font = .mainTemperatureBoldFont 28 | $0.textColor = .warmBlack 29 | $0.textAlignment = .center 30 | } 31 | private lazy var weatherDescriptionLabel = UILabel().then { 32 | $0.font = .mainDescriptionFont 33 | $0.textColor = .warmBlack 34 | $0.textAlignment = .center 35 | } 36 | 37 | //MARK: - Init 38 | 39 | override init(frame: CGRect) { 40 | super.init(frame: frame) 41 | setupViews() 42 | } 43 | 44 | required init?(coder: NSCoder) { 45 | fatalError("init(coder:) has not been implemented") 46 | } 47 | } 48 | 49 | //MARK: - Setup Views 50 | 51 | extension HeaderView { 52 | 53 | private func setupViews() { 54 | backgroundColor = .clear 55 | addSubviews([cityNameLabel, temperatureLabel, weatherDescriptionLabel]) 56 | setupConstraints() 57 | } 58 | 59 | private func setupConstraints() { 60 | 61 | cityNameLabel 62 | .bottomAnchor(to: weatherDescriptionLabel.topAnchor, constant: -UI.basicMargin) 63 | .centerXAnchor(to: centerXAnchor) 64 | .leadingAnchor(to: leadingAnchor) 65 | .trailingAnchor(to: trailingAnchor) 66 | .heightAnchor(constant: 40) 67 | .activateAnchors() 68 | 69 | weatherDescriptionLabel 70 | .centerYAnchor(to: centerYAnchor) 71 | .centerXAnchor(to: centerXAnchor) 72 | .leadingAnchor(to: leadingAnchor) 73 | .trailingAnchor(to: trailingAnchor) 74 | .heightAnchor(constant: 30) 75 | .activateAnchors() 76 | 77 | temperatureLabel 78 | .topAnchor(to: weatherDescriptionLabel.bottomAnchor, constant: UI.basicMargin) 79 | .centerXAnchor(to: centerXAnchor) 80 | .leadingAnchor(to: leadingAnchor) 81 | .trailingAnchor(to: trailingAnchor) 82 | .heightAnchor(constant: 60) 83 | .activateAnchors() 84 | } 85 | } 86 | 87 | //MARK: - Configure View 88 | 89 | extension HeaderView { 90 | func configureView(viewModel: [WeatherViewModel], cityName: String) { 91 | guard let recentData = viewModel.first else { return } 92 | cityNameLabel.text = cityName 93 | temperatureLabel.text = recentData.tempOriginal 94 | weatherDescriptionLabel.text = recentData.description 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/MainModule/SubViews/CollectionView/DailyCell/DailyCollectionCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DailyCollectionCell.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/13. 6 | // 7 | 8 | import Then 9 | import UIKit 10 | 11 | class DailyCollectionCell: UICollectionViewCell { 12 | 13 | //MARK: - UI Metrics 14 | 15 | private struct UI { 16 | static let basicMargin = CGFloat(10) 17 | static let imageSize = CGFloat(30) 18 | } 19 | 20 | //MARK: - Properties 21 | 22 | private lazy var dayLabel = UILabel().then { 23 | $0.textColor = .warmBlack 24 | $0.font = .dailyBoldFont 25 | } 26 | private lazy var weatherIconImageView = UIImageView().then { 27 | $0.contentMode = .scaleAspectFit 28 | } 29 | private lazy var maxTempLabel = UILabel().then { 30 | $0.textColor = .warmBlack 31 | $0.font = .dailyBoldFont 32 | } 33 | private lazy var minTempLabel = UILabel().then { 34 | $0.textColor = .warmGray 35 | $0.font = .dailyBoldFont 36 | } 37 | private var stackView = UIStackView() 38 | 39 | //MARK: - Init 40 | 41 | override init(frame: CGRect) { 42 | super.init(frame: frame) 43 | setupViews() 44 | } 45 | 46 | required init?(coder aDecoder: NSCoder) { 47 | super.init(coder: aDecoder) 48 | } 49 | } 50 | 51 | //MARK: - Setup Views 52 | 53 | extension DailyCollectionCell { 54 | 55 | private func setupViews() { 56 | backgroundColor = .clear 57 | stackView = UIStackView(arrangedSubviews: [maxTempLabel, minTempLabel]) 58 | 59 | stackView.do { 60 | $0.axis = .horizontal 61 | $0.spacing = 15 62 | $0.alignment = .fill 63 | $0.distribution = .fill 64 | } 65 | 66 | addSubviews([dayLabel, weatherIconImageView, stackView]) 67 | setupConstraints() 68 | } 69 | 70 | private func setupConstraints() { 71 | 72 | dayLabel 73 | .leadingAnchor(to: leadingAnchor, constant: UI.basicMargin) 74 | .centerYAnchor(to: centerYAnchor) 75 | .activateAnchors() 76 | 77 | weatherIconImageView 78 | .centerXAnchor(to: centerXAnchor) 79 | .centerYAnchor(to: centerYAnchor) 80 | .heightAnchor(constant: UI.imageSize) 81 | .widthAnchor(constant: UI.imageSize) 82 | .activateAnchors() 83 | 84 | stackView 85 | .centerYAnchor(to: centerYAnchor) 86 | .trailingAnchor(to: trailingAnchor, constant: -UI.basicMargin) 87 | .activateAnchors() 88 | } 89 | } 90 | 91 | //MARK: - Configure Cell 92 | 93 | extension DailyCollectionCell { 94 | 95 | func configureCell(viewModel: WeatherDailyViewModel, item: Int) { 96 | self.dayLabel.text = viewModel.day[item] 97 | self.weatherIconImageView.image = UIImage(named: viewModel.conditionImage[item]) 98 | self.maxTempLabel.text = viewModel.temp_max[item] 99 | self.minTempLabel.text = viewModel.temp_min[item] 100 | } 101 | } 102 | 103 | 104 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/SettingModule/SettingViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingViewController.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/15. 6 | // 7 | // 8 | 9 | import Then 10 | import UIKit 11 | 12 | class SettingViewController: BaseViewController { 13 | 14 | //MARK: - Properties 15 | 16 | var presenter: ViewToPresenterSettingProtocol? 17 | 18 | private lazy var searchBar = UISearchBar().then { 19 | $0.placeholder = "Enter city, zip, code, or airport location" 20 | $0.tintColor = .warmBlack 21 | } 22 | 23 | private lazy var searchTableView = UITableView().then { 24 | $0.separatorStyle = .none 25 | $0.backgroundColor = .white 26 | } 27 | 28 | //MARK: - Init 29 | 30 | override func viewDidLoad() { 31 | super.viewDidLoad() 32 | presenter?.viewDidLoad() 33 | setupViews() 34 | } 35 | 36 | override func viewDidAppear(_ animated: Bool) { 37 | super.viewDidAppear(animated) 38 | searchBar.becomeFirstResponder() 39 | } 40 | 41 | //MARK: - Setup Views 42 | 43 | override func setupViews() { 44 | searchBar.delegate = self 45 | searchTableView.dataSource = self 46 | searchTableView.delegate = self 47 | searchTableView.register(cellType: UITableViewCell.self) 48 | 49 | view.backgroundColor = .white 50 | view.addSubviews([searchBar, searchTableView]) 51 | setupConstraints() 52 | } 53 | 54 | override func setupConstraints() { 55 | searchBar 56 | .topAnchor(to: view.safeAreaLayoutGuide.topAnchor) 57 | .leadingAnchor(to: view.leadingAnchor) 58 | .trailingAnchor(to: view.trailingAnchor) 59 | .heightAnchor(constant: 60) 60 | .activateAnchors() 61 | 62 | searchTableView 63 | .topAnchor(to: searchBar.bottomAnchor) 64 | .leadingAnchor(to: view.leadingAnchor) 65 | .trailingAnchor(to: view.trailingAnchor) 66 | .bottomAnchor(to: view.bottomAnchor) 67 | .activateAnchors() 68 | } 69 | } 70 | 71 | //MARK: -> Presenter 72 | 73 | extension SettingViewController: UITableViewDataSource, UITableViewDelegate { 74 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 75 | return presenter?.numberOfRows(in: section) ?? 0 76 | } 77 | 78 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 79 | let cell = tableView.dequeueReusableCell(for: indexPath, cellType: UITableViewCell.self) 80 | presenter?.configureCell(cell, forRowAt: indexPath) 81 | return cell 82 | } 83 | 84 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 85 | presenter?.didSelectTableViewRow(at: indexPath) 86 | } 87 | } 88 | 89 | extension SettingViewController: UISearchBarDelegate { 90 | func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { 91 | presenter?.textDidChange(searchText: searchText) 92 | } 93 | } 94 | 95 | //MARK: View <- 96 | 97 | extension SettingViewController: PresenterToViewSettingProtocol{ 98 | func reloadTableView() { 99 | searchTableView.reloadData() 100 | } 101 | } 102 | 103 | 104 | -------------------------------------------------------------------------------- /Weather/Weather/Managers/RealmManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RealmManager.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/12. 6 | // 7 | 8 | import Foundation 9 | import RealmSwift 10 | 11 | class RealmManager: NSObject { 12 | 13 | //MARK: - Weather 14 | 15 | // Retrieve ALL Data 16 | func retrieveLocalWeatherData() -> LocalWeather? { 17 | let realmObject = try! Realm() 18 | 19 | guard let localWeather = realmObject.objects(LocalWeather.self).first 20 | else { 21 | return nil 22 | } 23 | 24 | return localWeather 25 | } 26 | 27 | // Retrieve ONLY Weather Response 28 | func retrieveWeatherResponse() -> WeatherResponse? { 29 | let realmObject = try! Realm() 30 | 31 | guard let localWeather = realmObject.objects(LocalWeather.self).first, 32 | let weatherData = localWeather.weatherData 33 | else { 34 | return nil 35 | } 36 | 37 | return decode(data: weatherData) 38 | } 39 | 40 | // Delete ALL Data 41 | func deleteLocalWeatherData() { 42 | let realmObject = try! Realm() 43 | let localWeather = realmObject.objects(LocalWeather.self) 44 | 45 | try! realmObject.write { 46 | realmObject.delete(localWeather) 47 | } 48 | } 49 | 50 | // Save ALL Data 51 | func saveLocalWeatherData(weather: WeatherResponse, location: Location) { 52 | deleteLocalWeatherData() 53 | 54 | let localWeatherModel = LocalWeather() 55 | localWeatherModel.weatherId = UUID().uuidString 56 | localWeatherModel.locationName = location.name 57 | localWeatherModel.longitude = location.longitude 58 | localWeatherModel.latitude = location.latitude 59 | localWeatherModel.lastRefreshedDate = Date() 60 | localWeatherModel.weatherData = encode(model: weather) 61 | 62 | add(localWeatherModel) 63 | } 64 | 65 | // Save ONLY Weather Response 66 | func saveWeatherResponse(_ weather: WeatherResponse) { 67 | deleteLocalWeatherData() 68 | 69 | let localWeatherModel = LocalWeather() 70 | localWeatherModel.weatherId = UUID().uuidString 71 | localWeatherModel.lastRefreshedDate = Date() 72 | localWeatherModel.weatherData = encode(model: weather) 73 | 74 | add(localWeatherModel) 75 | } 76 | 77 | // Save Only Location Data 78 | func saveLocationData(_ location: Location) { 79 | deleteLocalWeatherData() 80 | 81 | let LocationModel = LocalWeather() 82 | LocationModel.locationName = location.name 83 | LocationModel.latitude = location.latitude 84 | LocationModel.longitude = location.longitude 85 | 86 | add(LocationModel) 87 | } 88 | 89 | func getDocumentsDirectory() -> URL { 90 | let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) 91 | let documentsDirectory = paths[0] 92 | return documentsDirectory 93 | } 94 | } 95 | 96 | //MARK: - Extension 97 | 98 | extension RealmManager { 99 | 100 | private func add(_ object : Object) { 101 | let realmObject = try! Realm() 102 | 103 | try! realmObject.write { 104 | realmObject.add(object) 105 | } 106 | } 107 | 108 | private func encode(model: WeatherResponse) -> Data? { 109 | do { 110 | let jsonData = try JSONEncoder().encode(model) 111 | return jsonData 112 | } catch { 113 | print(RealmManagerError.encodeError) 114 | } 115 | return nil 116 | } 117 | 118 | private func decode(data: Data) -> WeatherResponse? { 119 | do { 120 | let decodedWeather = try JSONDecoder().decode(WeatherResponse.self, from: data) 121 | return decodedWeather 122 | } catch { 123 | print(RealmManagerError.decodeError) 124 | } 125 | return nil 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/MainModule/MainInteractor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainInteractor.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/11. 6 | // 7 | // 8 | 9 | import Foundation 10 | 11 | class MainInteractor { 12 | 13 | //MARK: Properties 14 | 15 | var presenter: InteractorToPresenterMainProtocol? 16 | 17 | private let weatherService = WeatherService() 18 | private let relamManager = RealmManager() 19 | private var localData: LocalWeather? 20 | } 21 | 22 | extension MainInteractor { 23 | 24 | //MARK: - Helpers 25 | 26 | private func fetchWeatherData(by city: String) { 27 | weatherService.fetchWeather(byCity: city) { [weak self] result in 28 | guard let `self` = self else { return } 29 | switch result { 30 | case .success(let response): 31 | self.relamManager.saveWeatherResponse(response) 32 | guard let data = self.relamManager.retrieveWeatherResponse() else { return } 33 | self.presenter?.handleResult(data, cityName: city) 34 | case .failure(let error): 35 | self.presenter?.handleError(error) 36 | } 37 | } 38 | } 39 | 40 | private func fetchWeatherData(by location: Location) { 41 | self.weatherService.fetchWeather(lat: location.latitude, lon: location.longitude) { [weak self] result in 42 | guard let `self` = self else { return } 43 | switch result { 44 | case .success(let response): 45 | self.relamManager.saveLocalWeatherData(weather: response, location: location) 46 | guard let data = self.relamManager.retrieveWeatherResponse() else { return } 47 | self.presenter?.handleResult(data, cityName: location.name) 48 | case .failure(let error): 49 | self.presenter?.handleError(error) 50 | } 51 | } 52 | } 53 | 54 | private func fetchedAPI180MinutesAgo(from lastRefreshDate: Date) -> Bool { 55 | let currentDate = Date() 56 | return currentDate.minutes(from: lastRefreshDate) >= 180 ? true : false 57 | } 58 | 59 | private func sendLocalData(to presenter: InteractorToPresenterMainProtocol?, 60 | with locationName: String, 61 | with response: WeatherResponse) { 62 | self.presenter?.handleResult(response, cityName: locationName) 63 | } 64 | } 65 | 66 | extension MainInteractor: PresenterToInteractorMainProtocol { 67 | 68 | //MARK: -> Interactor 69 | 70 | func fetchWeatherData() { 71 | localData = relamManager.retrieveLocalWeatherData() 72 | 73 | // #Case 1: When the location information is not saved 74 | // Because the location has never been changed 75 | guard let locationName = localData?.locationName, 76 | let locationLatitude = localData?.latitude, 77 | let locationLongitude = localData?.longitude 78 | else { 79 | fetchWeatherData(by: "Paris") 80 | return 81 | } 82 | // Save the location information so that we can use later 83 | let location = Location(name: locationName, latitude: locationLatitude, longitude: locationLongitude) 84 | 85 | // #Case 2: When location information is changed in settingModule, 86 | // In this case, location information exists but no weather information. 87 | guard let _ = localData?.weatherData, 88 | let weatherResponse = relamManager.retrieveWeatherResponse() 89 | else { 90 | fetchWeatherData(by: location) 91 | return 92 | } 93 | 94 | // #Case 3: All Local Data Exsits but if the API was fetched 3 hours ago, 95 | // local Realm data will be used, 96 | // otherwise, we fetch the API newly. 97 | if let lastRefreshDate = localData?.lastRefreshedDate { 98 | fetchedAPI180MinutesAgo(from: lastRefreshDate) ? 99 | fetchWeatherData(by: location) : 100 | sendLocalData(to: presenter, with: locationName, with: weatherResponse) 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Weather/WeatherTests/WeatherService/WeatherServiceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherServiceTests.swift 3 | // WeatherTests 4 | // 5 | // Created by Ted on 2021/08/12. 6 | // 7 | 8 | import XCTest 9 | @testable import Weather 10 | 11 | class WeatherServiceTests: XCTestCase { 12 | 13 | var mockWeatherServiceWithoutNetwork: MockWeatherServiceWithoutNetwork? 14 | var mockWeatherService: MockWeatherService? 15 | 16 | override func setUpWithError() throws { 17 | self.mockWeatherServiceWithoutNetwork = MockWeatherServiceWithoutNetwork() 18 | self.mockWeatherService = MockWeatherService() 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | self.mockWeatherServiceWithoutNetwork = nil 23 | self.mockWeatherService = nil 24 | } 25 | 26 | func testFetchWeather_WhenJSONFileProvided_ShouldReturnTrue() throws { 27 | 28 | mockWeatherServiceWithoutNetwork?.fetchWeather(byCity: "", completion: { result in 29 | switch result { 30 | case .success(let data): 31 | XCTAssert(data.cod == "200", "Expect created cod is 200") 32 | XCTAssert(data.message == 0, "Expect created message is 0") 33 | XCTAssert(data.cnt == 40, "Expect created cnt is 40") 34 | XCTAssert(data.city.id == 2988507, "Expect created city id is 2988507") 35 | XCTAssert(data.city.name == "Paris", "Expect created city name is Paris") 36 | XCTAssert(data.city.coord.lat == 48.8534, "Expect created latitude is 48.8534") 37 | XCTAssert(data.list.first?.dt == 1628694000, "Expect created dt is 1628694000") 38 | 39 | guard let main = data.list.first?.main else { return } 40 | XCTAssert(main.feelsLike == 26.09, "Expect created feelsLike is 26.09") 41 | XCTAssert(main.temp == 26.09, "Expect created temp is 26.09") 42 | XCTAssert(main.pressure == 1019, "Expect created pressure is 1019") 43 | XCTAssert(main.tempMin == 26.09, "Expect created pressure is 26.09") 44 | XCTAssert(main.tempMax == 27.42, "Expect created pressure is 27.42") 45 | 46 | guard let weather = data.list.first?.weather else { return } 47 | 48 | XCTAssert(weather.first?.id == 801, "Expect created weather id is 801") 49 | XCTAssert(weather.first?.main == "Clouds", "Expect created weather main is Clouds") 50 | XCTAssert(weather.first?.description == "few clouds", "Expect created weather description is few clouds") 51 | 52 | guard let wind = data.list.first?.wind else { return } 53 | 54 | XCTAssert(wind.speed == 0.58, "Expect created wind speed is 0.58") 55 | XCTAssert(wind.deg == 336, "Expect created wind degree is 336") 56 | 57 | case .failure: 58 | break 59 | } 60 | }) 61 | } 62 | 63 | func testFetchWeatherByLocation_WhenLocationProvided_ShouldReturnTrue() throws { 64 | let weatherExpectation: XCTestExpectation = self.expectation(description: "weatherExpectation") 65 | 66 | mockWeatherService?.fetchWeather(lat: 32.832942, lon: -0.293174, completion: { result in 67 | switch result { 68 | case .success: 69 | weatherExpectation.fulfill() 70 | case .failure(let error): 71 | XCTFail(error.localizedDescription) 72 | } 73 | }) 74 | XCTAssert(mockWeatherService?.fetchWeatherByLotLon == 1, "Expect fetchWeatherByLotLon is 1") 75 | self.waitForExpectations(timeout: 20.0, handler: nil) 76 | } 77 | 78 | func testFetchWeatherByCityName_WhenCityNameProvided_ShouldReturnTrue() throws { 79 | let weatherExpectation: XCTestExpectation = self.expectation(description: "weatherExpectation") 80 | 81 | mockWeatherService?.fetchWeather(byCity: "Paris", completion: { result in 82 | switch result { 83 | case .success: 84 | weatherExpectation.fulfill() 85 | case .failure(let error): 86 | XCTFail(error.localizedDescription) 87 | } 88 | }) 89 | XCTAssert(mockWeatherService?.fetchWeatherByCityCalled == 1, "Expect fetchWeatherByCityCalled is 1") 90 | self.waitForExpectations(timeout: 20.0, handler: nil) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/MainModule/ViewModels/WeatherViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherModel.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/13. 6 | // 7 | 8 | import Foundation 9 | 10 | struct WeatherViewModel { 11 | 12 | let date: String 13 | let dateWithMonth: String 14 | let hour: String 15 | let day: String 16 | let temp: String 17 | let tempOriginal: String 18 | let tempMinInt: Int 19 | let tempMaxInt: Int 20 | let tempMin: String 21 | let tempMax: String 22 | let description: String 23 | let feelslike: String 24 | let humidity: String 25 | let pressure: String 26 | let windSpeed: String 27 | let windDirection: String 28 | let visibility: String 29 | let conditionId: Int 30 | 31 | var conditionImage: String { 32 | switch conditionId { 33 | case 200...299: 34 | return "thunderstorms" 35 | case 300...399: 36 | return "drizzling" 37 | case 500...599: 38 | return "raining" 39 | case 600...699: 40 | return "snowing" 41 | case 700...799: 42 | return "windy" 43 | case 800: 44 | return "shine" 45 | default: 46 | return "cloudy" 47 | } 48 | } 49 | 50 | init(dt_txt: String, dateWithMonth: String, hour: String, day: String, temp: String, tempOriginal: String, temp_min: String, temp_max: String, description: String, conditionId: Int, temp_min_int: Int, temp_max_int: Int, feelslike: String, humidity: String, pressure: String, windSpeed: String, windDirection: String, visibility: String) { 51 | self.date = dt_txt 52 | self.dateWithMonth = dateWithMonth 53 | self.hour = hour 54 | self.day = day 55 | self.temp = temp 56 | self.tempOriginal = tempOriginal 57 | self.tempMinInt = temp_min_int 58 | self.tempMaxInt = temp_max_int 59 | self.tempMin = temp_min 60 | self.tempMax = temp_max 61 | self.description = description 62 | self.conditionId = conditionId 63 | self.feelslike = feelslike 64 | self.humidity = humidity 65 | self.pressure = pressure 66 | self.windSpeed = windSpeed 67 | self.windDirection = windDirection 68 | self.visibility = visibility 69 | } 70 | 71 | static func getViewModels(with weatherResponse: WeatherResponse) -> [WeatherViewModel] { 72 | return weatherResponse.list.map { getViewModel(eachWeather: $0, response: weatherResponse) } 73 | } 74 | 75 | static func getViewModel(eachWeather: WeatherListResponse, response: WeatherResponse) -> WeatherViewModel { 76 | let timeZone = response.city.timezone 77 | let date = Date.getddMMYYYYFormat(timestamp: eachWeather.dtTxt, timeZone: timeZone) 78 | let dateWithMonth = Date.getddMMFormat(timestamp: eachWeather.dtTxt, timeZone: timeZone) 79 | let hour = Date.getHHFormat(timestamp: eachWeather.dtTxt, timeZone: timeZone) 80 | let day = Date.getWeekDay(timestamp: eachWeather.dtTxt, timeZone: timeZone) 81 | let tempOriginal = "\(Int(eachWeather.main.temp))" 82 | let temp = "\(Int(eachWeather.main.temp))°C" 83 | let temp_min_int = (Int(eachWeather.main.tempMin)) 84 | let temp_max_int = (Int(eachWeather.main.tempMax)) 85 | let temp_min = "\(Int(eachWeather.main.tempMin))°C" 86 | let temp_max = "\(Int(eachWeather.main.tempMax))°C" 87 | var description: String = "" 88 | let feelslike = "\(Int(eachWeather.main.feelsLike))°C" 89 | let humidity = "\(eachWeather.main.humidity)%" 90 | let pressure = "\(Int(eachWeather.main.pressure))hPa" 91 | let windSpeed = "\(Int(eachWeather.wind.speed * 3.6))km/h" 92 | let windDirection = (eachWeather.wind.deg).degToCompass() 93 | let visibility = "\(eachWeather.visibility / 1000)km" 94 | var conditionId: Int = 800 95 | 96 | if let weather = eachWeather.weather.first { 97 | description = weather.description 98 | conditionId = weather.id 99 | } 100 | 101 | return WeatherViewModel(dt_txt: date, dateWithMonth: dateWithMonth, hour: hour, day: day, temp: temp, tempOriginal: tempOriginal, temp_min: temp_min, temp_max: temp_max, description: description, conditionId: conditionId, temp_min_int: temp_min_int, temp_max_int: temp_max_int, feelslike: feelslike, humidity: humidity, pressure: pressure, windSpeed: windSpeed, windDirection: windDirection, visibility: visibility) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/MainModule/SubViews/CollectionView/CollectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CollectionView.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/12. 6 | // 7 | 8 | import UIKit 9 | 10 | class CollectionView: UICollectionView { 11 | 12 | //MARK: - UI Metrics 13 | 14 | private struct UI { 15 | static let dailyCellHeightRatio = CGFloat(0.42) 16 | static let summaryCellHeightRatio = CGFloat(0.1) 17 | static let subInfoCellHeightRatio = CGFloat(0.25) 18 | } 19 | 20 | // MARK: - Callback 21 | 22 | var hourlyCollectionDidLoad: ((HourlyCollectionReusableView) -> Void)? 23 | var dailyCollectionDidLoad: ((DailyViewCell) -> Void)? 24 | var summaryCollectionDidLoad: ((SummaryCell) -> Void)? 25 | var SubInfoCollectionDidLoad: ((SubInfoCell) -> Void)? 26 | 27 | //MARK: - Init 28 | 29 | override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) { 30 | super.init(frame: frame, collectionViewLayout: layout) 31 | setupViews() 32 | } 33 | 34 | required init?(coder aDecoder: NSCoder) { 35 | super.init(coder: aDecoder) 36 | } 37 | 38 | deinit { 39 | hourlyCollectionDidLoad = nil 40 | dailyCollectionDidLoad = nil 41 | summaryCollectionDidLoad = nil 42 | SubInfoCollectionDidLoad = nil 43 | } 44 | } 45 | 46 | //MARK: - Setup Views 47 | 48 | extension CollectionView { 49 | 50 | private func setupViews() { 51 | dataSource = self 52 | delegate = self 53 | 54 | showsVerticalScrollIndicator = false 55 | backgroundColor = .clear 56 | 57 | configureSubViews() 58 | } 59 | 60 | private func configureSubViews() { 61 | register(HourlyCollectionReusableView.self, 62 | forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, 63 | withReuseIdentifier: HourlyCollectionReusableView.reuseIdentifier) 64 | 65 | register(cellType: DailyViewCell.self) 66 | register(cellType: SummaryCell.self) 67 | register(cellType: SubInfoCell.self) 68 | } 69 | } 70 | 71 | //MARK: - UICollectionViewDataSource 72 | 73 | extension CollectionView: UICollectionViewDataSource { 74 | func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { 75 | 76 | let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: HourlyCollectionReusableView.reuseIdentifier, for: indexPath) as! HourlyCollectionReusableView 77 | 78 | if let hourlyCollectionDidLoad = hourlyCollectionDidLoad { 79 | hourlyCollectionDidLoad(header) 80 | } else { 81 | return header 82 | } 83 | 84 | return header 85 | } 86 | 87 | func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 88 | return 3 89 | } 90 | 91 | func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 92 | 93 | switch indexPath.item { 94 | case 0: 95 | let cell = collectionView.dequeueReusableCell(for: indexPath, cellType: DailyViewCell.self) 96 | if let dailyCollectionDidLoad = dailyCollectionDidLoad { 97 | dailyCollectionDidLoad(cell) 98 | } else { 99 | return cell 100 | } 101 | return cell 102 | case 1: 103 | let cell = collectionView.dequeueReusableCell(for: indexPath, cellType: SummaryCell.self) 104 | if let summaryCollectionDidLoad = summaryCollectionDidLoad { 105 | summaryCollectionDidLoad(cell) 106 | } else { 107 | return cell 108 | } 109 | return cell 110 | case 2: 111 | let cell = collectionView.dequeueReusableCell(for: indexPath, cellType: SubInfoCell.self) 112 | if let SubInfoCollectionDidLoad = SubInfoCollectionDidLoad { 113 | SubInfoCollectionDidLoad(cell) 114 | } else { 115 | return cell 116 | } 117 | return cell 118 | default: 119 | break 120 | } 121 | 122 | return UICollectionViewCell() 123 | } 124 | } 125 | 126 | //MARK: - UICollectionViewDelegateFlowLayout 127 | 128 | extension CollectionView: UICollectionViewDelegateFlowLayout { 129 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { 130 | 131 | switch indexPath.item { 132 | case 0: 133 | return CGSize(width: UIScreen.main.bounds.width, 134 | height: UIScreen.main.bounds.height * UI.dailyCellHeightRatio) 135 | case 1: 136 | return CGSize(width: UIScreen.main.bounds.width, 137 | height: UIScreen.main.bounds.height * UI.summaryCellHeightRatio) 138 | case 2: 139 | return CGSize(width: UIScreen.main.bounds.width, 140 | height: UIScreen.main.bounds.height * UI.subInfoCellHeightRatio) 141 | default: 142 | break 143 | } 144 | 145 | return CGSize(width: UIScreen.main.bounds.width, height: 100) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /Weather/Weather/Modules/MainModule/MainViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainViewController.swift 3 | // Weather 4 | // 5 | // Created by Ted on 2021/08/11. 6 | // 7 | // 8 | 9 | import UIKit 10 | 11 | class MainViewController: BaseViewController { 12 | 13 | //MARK: - UI Metrics 14 | 15 | private struct UI { 16 | static let headerViewHeightRatio = CGFloat(0.38) 17 | static let hourlyCellHeightRatio = CGFloat(0.12) 18 | } 19 | 20 | //MARK: - Properties 21 | 22 | var presenter: ViewToPresenterMainProtocol? 23 | 24 | private lazy var headerView = HeaderView() 25 | private lazy var toolBar = ToolBar() 26 | private lazy var collectionView: CollectionView = { 27 | let layout = UICollectionViewFlowLayout() 28 | layout.scrollDirection = .vertical 29 | layout.minimumLineSpacing = 0 30 | layout.headerReferenceSize = CGSize(width: UIScreen.main.bounds.width, 31 | height: UIScreen.main.bounds.height * UI.hourlyCellHeightRatio) 32 | let view = CollectionView(frame: .zero, collectionViewLayout: layout) 33 | return view 34 | }() 35 | private var alertView: UIAlertController? 36 | 37 | //MARK: - Life Cycle 38 | 39 | override func viewDidLoad() { 40 | super.viewDidLoad() 41 | setupViews() 42 | setupEventBinding() 43 | } 44 | 45 | override func viewWillAppear(_ animated: Bool) { 46 | super.viewWillAppear(animated) 47 | // better with observer 48 | presenter?.viewWillAppear() 49 | } 50 | 51 | override func viewDidAppear(_ animated: Bool) { 52 | super.viewDidAppear(animated) 53 | presenter?.viewDidAppear() 54 | } 55 | 56 | //MARK: - Setup Views 57 | 58 | override func setupViews() { 59 | view.backgroundColor = .white 60 | view.addSubviews([headerView, collectionView, toolBar]) 61 | setupConstraints() 62 | } 63 | 64 | override func setupConstraints() { 65 | headerView 66 | .leadingAnchor(to: view.leadingAnchor) 67 | .trailingAnchor(to: view.trailingAnchor) 68 | .topAnchor(to: view.topAnchor) 69 | .heightAnchor(constant: UIScreen.main.bounds.height * UI.headerViewHeightRatio) 70 | .activateAnchors() 71 | 72 | collectionView 73 | .leadingAnchor(to: view.leadingAnchor) 74 | .trailingAnchor(to: view.trailingAnchor) 75 | .topAnchor(to: headerView.bottomAnchor) 76 | .activateAnchors() 77 | 78 | toolBar 79 | .leadingAnchor(to: view.safeAreaLayoutGuide.leadingAnchor) 80 | .trailingAnchor(to: view.safeAreaLayoutGuide.trailingAnchor) 81 | .topAnchor(to: collectionView.bottomAnchor) 82 | .bottomAnchor(to: view.safeAreaLayoutGuide.bottomAnchor) 83 | .activateAnchors() 84 | } 85 | } 86 | 87 | extension MainViewController { 88 | 89 | //MARK: -> Presenter / EventBinding 90 | 91 | func setupEventBinding() { 92 | 93 | toolBar.weatherButtonDidTap = { 94 | self.presenter?.weatherButtonClicked() 95 | } 96 | 97 | toolBar.settingButtonDidTap = { 98 | self.presenter?.settingButtonClicked() 99 | } 100 | } 101 | } 102 | 103 | extension MainViewController: PresenterToViewMainProtocol { 104 | 105 | //MARK: UI Binding / View <- 106 | 107 | func setupUIBinding(with viewModel: [WeatherViewModel], cityName: String) { 108 | DispatchQueue.main.async { 109 | self.headerView.configureView(viewModel: viewModel, cityName: cityName) 110 | self.collectionView.hourlyCollectionDidLoad = { header in 111 | header.hourlyCollectionView.hourlyCellDidLoad = { cell, indexPath in 112 | cell.configureCell(viewModel: viewModel, item: indexPath.item) 113 | } 114 | header.hourlyCollectionView.reloadData() 115 | } 116 | } 117 | } 118 | 119 | func setupUIBinding(with viewModel: WeatherDailyViewModel) { 120 | DispatchQueue.main.async { 121 | self.collectionView.dailyCollectionDidLoad = { daily in 122 | daily.dailyCollectionView.dailyCellDidLoad = { cell, indexPath in 123 | cell.configureCell(viewModel: viewModel, item: indexPath.item) 124 | } 125 | daily.dailyCollectionView.reloadData() 126 | } 127 | 128 | self.collectionView.summaryCollectionDidLoad = { summary in 129 | summary.configureCell(viewModel: viewModel) 130 | } 131 | } 132 | } 133 | 134 | func setupUIBinding(with viewModel: WeatherInfoViewModel) { 135 | DispatchQueue.main.async { 136 | self.collectionView.SubInfoCollectionDidLoad = { subInfo in 137 | subInfo.subInfoCollectionView.collectionCellDidLoad = { cell, indexPath in 138 | cell.configureCell(viewModel: viewModel, item: indexPath.item) 139 | } 140 | subInfo.subInfoCollectionView.reloadData() 141 | } 142 | } 143 | } 144 | 145 | func reloadCollectionView() { 146 | DispatchQueue.main.async { 147 | self.collectionView.reloadData() 148 | } 149 | } 150 | 151 | func showAlert(withMessage message: String, animated: Bool) { 152 | let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert) 153 | alert.addAction(UIAlertAction(title: "OK", style: .cancel, handler: nil)) 154 | alertView = alert 155 | 156 | present(alert, animated: animated, completion: nil) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ☔️ Weather App 2 | > 11.08.2021 ~ 16.08.2021 3 | 4 |

5 | 6 | 7 | 8 |

9 | 10 | ### Design Pattern 11 | 12 | ![design-pattern](./image/1.png) 13 | 14 | `Interactor` — contains business logic related to the data (Entities) or networking, like creating new instances of entities or fetching them from the server. For those purposes you’ll use some Services and Managers which are not considered as a part of VIPER module but rather an external dependency. 15 | 16 | `Presenter` — contains the UI related (but UIKit independent) business logic, invokes methods on the Interactor. 17 | 18 | `Entities` — your plain data objects, not the data access layer, because that is a responsibility of the Interactor. 19 | 20 | `Router` — responsible for the segues between the VIPER modules. 21 | 22 | In this project, I used `view models` to tranform lots of data from the model and inject information to the view directly. 23 | 24 | The VIPER architecture is based on the single responsibility principle (S.O.L.I.D.) which leads us to the theory of a clean architecture. 25 | 26 | Using this architecture one can easily test at the boundaries between each layers. One feature, one module. For each module VIPER has five (sometimes six) different classes with distinct roles. VIPER makes the code easier to isolate dependencies and to test the interactions at the boundaries between layers. 27 | 28 | ### Structure 29 | 30 | ![2](./image/2.png) 31 | 32 | ![10](./image/10.png) 33 | 34 | ![3](./image/3.png) 35 | 36 | ![4](./image/4.png) 37 | 38 | ![5](./image/5.png) 39 | 40 | ![6](./image/6.png) 41 | 42 | ![7](./image/7.png) 43 | 44 | ![8](./image/8.png) 45 | 46 | ### API 47 | 48 | [5 day weather forecast documentation](https://openweathermap.org/forecast5) 49 | 50 | [Weather conditions documentation](https://openweathermap.org/weather-conditions#Weather-Condition-Codes-2) 51 | 52 | [API Example](https://api.openweathermap.org/data/2.5/forecast?q=paris&APPID=da69ade359c47e35161bf2e2dad374e8&units=metric) 53 | 54 | ### Consideration 55 | 56 | For the SOLID Principle(Dependency Inversion Principle), Should I write the code like below? 57 | 58 | ```swift 59 | protocol NetworkRequest: AnyObject { 60 | associatedtype ModelType 61 | func decode(_ data: Data) -> ModelType? 62 | func load(withCompletion completion: @escaping (ModelType?) -> Void) 63 | } 64 | 65 | extension NetworkRequest { 66 | fileprivate func load(_ url: URL, withCompletion completion: @escaping (ModelType?) -> Void) { 67 | let session = URLSession(configuration: .default, delegate: nil, delegateQueue: .main) 68 | let task = session.dataTask(with: url, completionHandler: { [weak self] (data: Data?, response: URLResponse?, error: Error?) -> Void in 69 | guard error == nil else { 70 | return completion(nil) 71 | } 72 | 73 | guard let header = response as? HTTPURLResponse, (200..<300) ~= header.statusCode else { 74 | return completion(nil) 75 | } 76 | 77 | guard let data = data else { 78 | return completion(nil) 79 | } 80 | 81 | completion(self?.decode(data)) 82 | }) 83 | task.resume() 84 | } 85 | } 86 | 87 | protocol APIResource { 88 | associatedtype ModelType: Decodable 89 | var latitude: String { get } 90 | var longitude: String { get } 91 | } 92 | 93 | extension APIResource { 94 | var url: URL { 95 | 96 | let baseURL = "https://api.openweathermap.org/data/2.5/forecast" 97 | let API_KEY = "" 98 | 99 | var urlComponent = URLComponents(string: baseURL) 100 | 101 | urlComponent?.queryItems = [ 102 | URLQueryItem(name: "APPID", value: "\(API_KEY)"), 103 | URLQueryItem(name: "lat", value: "\(latitude)"), 104 | URLQueryItem(name: "lon", value: "\(longitude)"), 105 | URLQueryItem(name: "units", value: "metric") 106 | ] 107 | 108 | return (urlComponent?.url)! 109 | } 110 | } 111 | 112 | struct WeatherResource: APIResource { 113 | typealias ModelType = WeatherResponse 114 | 115 | let longitude: String 116 | let latitude: String 117 | } 118 | 119 | 120 | class APIRequest { 121 | let resource: Resource 122 | 123 | init(resource: Resource) { 124 | self.resource = resource 125 | } 126 | } 127 | 128 | extension APIRequest: NetworkRequest { 129 | func decode(_ data: Data) -> (Resource.ModelType)? { 130 | do { 131 | let result = try JSONDecoder().decode(WeatherResponse.self, from: data) 132 | return result as? Resource.ModelType 133 | } catch { 134 | return nil 135 | } 136 | } 137 | 138 | func load(withCompletion completion: @escaping (Resource.ModelType?) -> Void) { 139 | load(resource.url, withCompletion: completion) 140 | } 141 | } 142 | ``` 143 | 144 | ### Git Commit Message Guide 145 | 146 | - `feat`: A new feature 147 | - `fix`: A bug fix 148 | - `docs`: Changes to documentation 149 | - `style`: Formatting, missing semi colons, etc; no code change 150 | - `refactor`: Refactoring production code 151 | - `test`: Adding tests, refactoring test; no production code change 152 | - `chore`: Updating build tasks, package manager configs, etc; no production code change 153 | 154 | ### Third-Party Libraries 155 | 156 | * Database: [Realm](https://github.com/realm/realm-cocoa) 157 | * Tool: [Then](https://github.com/devxoul/Then) 158 | 159 | ### After `Code Review` 160 | 161 | 1. Indiscriminate use of Singleton is not good. 162 | 2. If I use local data, it is better to use it actively than to try to reduce its use. 163 | 3. I should also consider the possibility that data may be different in the process of saving it to local data. Therefore, if there is information to be brought from local data, it is better to bring all the information that way. 164 | 4. It is more effective to use observer than to use viewWillAppear. 165 | 5. If I think I don't need to use lazy var, I don't need to use it. 166 | 6. I need to think more about the effective method to implement something. -------------------------------------------------------------------------------- /Weather/WeatherTests/Resources/testWeatherData.json: -------------------------------------------------------------------------------- 1 | { 2 | "cod": "200", 3 | "message": 0, 4 | "cnt": 40, 5 | "list": [ 6 | { 7 | "dt": 1628715600, 8 | "main": { 9 | "temp": 21.13, 10 | "feels_like": 21.1, 11 | "temp_min": 21.13, 12 | "temp_max": 22.09, 13 | "pressure": 1019, 14 | "sea_level": 1019, 15 | "grnd_level": 1014, 16 | "humidity": 69, 17 | "temp_kf": -0.96 18 | }, 19 | "weather": [ 20 | { 21 | "id": 800, 22 | "main": "Clear", 23 | "description": "clear sky", 24 | "icon": "01n" 25 | } 26 | ], 27 | "clouds": { 28 | "all": 0 29 | }, 30 | "wind": { 31 | "speed": 2.32, 32 | "deg": 51, 33 | "gust": 4.93 34 | }, 35 | "visibility": 10000, 36 | "pop": 0, 37 | "sys": { 38 | "pod": "n" 39 | }, 40 | "dt_txt": "2021-08-11 21:00:00" 41 | }, 42 | { 43 | "dt": 1628726400, 44 | "main": { 45 | "temp": 20.79, 46 | "feels_like": 20.67, 47 | "temp_min": 20.1, 48 | "temp_max": 20.79, 49 | "pressure": 1019, 50 | "sea_level": 1019, 51 | "grnd_level": 1015, 52 | "humidity": 67, 53 | "temp_kf": 0.69 54 | }, 55 | "weather": [ 56 | { 57 | "id": 802, 58 | "main": "Clouds", 59 | "description": "scattered clouds", 60 | "icon": "03n" 61 | } 62 | ], 63 | "clouds": { 64 | "all": 33 65 | }, 66 | "wind": { 67 | "speed": 1.53, 68 | "deg": 58, 69 | "gust": 3.13 70 | }, 71 | "visibility": 10000, 72 | "pop": 0, 73 | "sys": { 74 | "pod": "n" 75 | }, 76 | "dt_txt": "2021-08-12 00:00:00" 77 | }, 78 | { 79 | "dt": 1628737200, 80 | "main": { 81 | "temp": 19.48, 82 | "feels_like": 19.34, 83 | "temp_min": 18.65, 84 | "temp_max": 19.48, 85 | "pressure": 1019, 86 | "sea_level": 1019, 87 | "grnd_level": 1014, 88 | "humidity": 71, 89 | "temp_kf": 0.83 90 | }, 91 | "weather": [ 92 | { 93 | "id": 803, 94 | "main": "Clouds", 95 | "description": "broken clouds", 96 | "icon": "04n" 97 | } 98 | ], 99 | "clouds": { 100 | "all": 65 101 | }, 102 | "wind": { 103 | "speed": 1.07, 104 | "deg": 56, 105 | "gust": 1.23 106 | }, 107 | "visibility": 10000, 108 | "pop": 0, 109 | "sys": { 110 | "pod": "n" 111 | }, 112 | "dt_txt": "2021-08-12 03:00:00" 113 | }, 114 | { 115 | "dt": 1628748000, 116 | "main": { 117 | "temp": 19.02, 118 | "feels_like": 18.88, 119 | "temp_min": 19.02, 120 | "temp_max": 19.02, 121 | "pressure": 1019, 122 | "sea_level": 1019, 123 | "grnd_level": 1014, 124 | "humidity": 73, 125 | "temp_kf": 0 126 | }, 127 | "weather": [ 128 | { 129 | "id": 804, 130 | "main": "Clouds", 131 | "description": "overcast clouds", 132 | "icon": "04d" 133 | } 134 | ], 135 | "clouds": { 136 | "all": 99 137 | }, 138 | "wind": { 139 | "speed": 1.26, 140 | "deg": 54, 141 | "gust": 2 142 | }, 143 | "visibility": 10000, 144 | "pop": 0, 145 | "sys": { 146 | "pod": "d" 147 | }, 148 | "dt_txt": "2021-08-12 06:00:00" 149 | }, 150 | { 151 | "dt": 1628758800, 152 | "main": { 153 | "temp": 23.7, 154 | "feels_like": 23.61, 155 | "temp_min": 23.7, 156 | "temp_max": 23.7, 157 | "pressure": 1019, 158 | "sea_level": 1019, 159 | "grnd_level": 1015, 160 | "humidity": 57, 161 | "temp_kf": 0 162 | }, 163 | "weather": [ 164 | { 165 | "id": 804, 166 | "main": "Clouds", 167 | "description": "overcast clouds", 168 | "icon": "04d" 169 | } 170 | ], 171 | "clouds": { 172 | "all": 100 173 | }, 174 | "wind": { 175 | "speed": 1.28, 176 | "deg": 65, 177 | "gust": 1.35 178 | }, 179 | "visibility": 10000, 180 | "pop": 0, 181 | "sys": { 182 | "pod": "d" 183 | }, 184 | "dt_txt": "2021-08-12 09:00:00" 185 | }, 186 | { 187 | "dt": 1628769600, 188 | "main": { 189 | "temp": 29.19, 190 | "feels_like": 28.71, 191 | "temp_min": 29.19, 192 | "temp_max": 29.19, 193 | "pressure": 1018, 194 | "sea_level": 1018, 195 | "grnd_level": 1014, 196 | "humidity": 39, 197 | "temp_kf": 0 198 | }, 199 | "weather": [ 200 | { 201 | "id": 804, 202 | "main": "Clouds", 203 | "description": "overcast clouds", 204 | "icon": "04d" 205 | } 206 | ], 207 | "clouds": { 208 | "all": 96 209 | }, 210 | "wind": { 211 | "speed": 0.62, 212 | "deg": 212, 213 | "gust": 1.94 214 | }, 215 | "visibility": 10000, 216 | "pop": 0, 217 | "sys": { 218 | "pod": "d" 219 | }, 220 | "dt_txt": "2021-08-12 12:00:00" 221 | }, 222 | { 223 | "dt": 1628780400, 224 | "main": { 225 | "temp": 30.61, 226 | "feels_like": 29.93, 227 | "temp_min": 30.61, 228 | "temp_max": 30.61, 229 | "pressure": 1017, 230 | "sea_level": 1017, 231 | "grnd_level": 1013, 232 | "humidity": 36, 233 | "temp_kf": 0 234 | }, 235 | "weather": [ 236 | { 237 | "id": 804, 238 | "main": "Clouds", 239 | "description": "overcast clouds", 240 | "icon": "04d" 241 | } 242 | ], 243 | "clouds": { 244 | "all": 100 245 | }, 246 | "wind": { 247 | "speed": 1.33, 248 | "deg": 273, 249 | "gust": 3.42 250 | }, 251 | "visibility": 10000, 252 | "pop": 0, 253 | "sys": { 254 | "pod": "d" 255 | }, 256 | "dt_txt": "2021-08-12 15:00:00" 257 | }, 258 | { 259 | "dt": 1628791200, 260 | "main": { 261 | "temp": 28.8, 262 | "feels_like": 28.58, 263 | "temp_min": 28.8, 264 | "temp_max": 28.8, 265 | "pressure": 1017, 266 | "sea_level": 1017, 267 | "grnd_level": 1013, 268 | "humidity": 42, 269 | "temp_kf": 0 270 | }, 271 | "weather": [ 272 | { 273 | "id": 803, 274 | "main": "Clouds", 275 | "description": "broken clouds", 276 | "icon": "04d" 277 | } 278 | ], 279 | "clouds": { 280 | "all": 80 281 | }, 282 | "wind": { 283 | "speed": 2.37, 284 | "deg": 356, 285 | "gust": 2.92 286 | }, 287 | "visibility": 10000, 288 | "pop": 0, 289 | "sys": { 290 | "pod": "d" 291 | }, 292 | "dt_txt": "2021-08-12 18:00:00" 293 | }, 294 | { 295 | "dt": 1628802000, 296 | "main": { 297 | "temp": 23.62, 298 | "feels_like": 23.66, 299 | "temp_min": 23.62, 300 | "temp_max": 23.62, 301 | "pressure": 1019, 302 | "sea_level": 1019, 303 | "grnd_level": 1015, 304 | "humidity": 62, 305 | "temp_kf": 0 306 | }, 307 | "weather": [ 308 | { 309 | "id": 802, 310 | "main": "Clouds", 311 | "description": "scattered clouds", 312 | "icon": "03n" 313 | } 314 | ], 315 | "clouds": { 316 | "all": 25 317 | }, 318 | "wind": { 319 | "speed": 2.98, 320 | "deg": 7, 321 | "gust": 6.05 322 | }, 323 | "visibility": 10000, 324 | "pop": 0, 325 | "sys": { 326 | "pod": "n" 327 | }, 328 | "dt_txt": "2021-08-12 21:00:00" 329 | }, 330 | { 331 | "dt": 1628812800, 332 | "main": { 333 | "temp": 20.87, 334 | "feels_like": 20.81, 335 | "temp_min": 20.87, 336 | "temp_max": 20.87, 337 | "pressure": 1020, 338 | "sea_level": 1020, 339 | "grnd_level": 1016, 340 | "humidity": 69, 341 | "temp_kf": 0 342 | }, 343 | "weather": [ 344 | { 345 | "id": 802, 346 | "main": "Clouds", 347 | "description": "scattered clouds", 348 | "icon": "03n" 349 | } 350 | ], 351 | "clouds": { 352 | "all": 47 353 | }, 354 | "wind": { 355 | "speed": 3.25, 356 | "deg": 352, 357 | "gust": 6.54 358 | }, 359 | "visibility": 10000, 360 | "pop": 0, 361 | "sys": { 362 | "pod": "n" 363 | }, 364 | "dt_txt": "2021-08-13 00:00:00" 365 | }, 366 | { 367 | "dt": 1628823600, 368 | "main": { 369 | "temp": 19.1, 370 | "feels_like": 18.82, 371 | "temp_min": 19.1, 372 | "temp_max": 19.1, 373 | "pressure": 1020, 374 | "sea_level": 1020, 375 | "grnd_level": 1016, 376 | "humidity": 67, 377 | "temp_kf": 0 378 | }, 379 | "weather": [ 380 | { 381 | "id": 803, 382 | "main": "Clouds", 383 | "description": "broken clouds", 384 | "icon": "04n" 385 | } 386 | ], 387 | "clouds": { 388 | "all": 80 389 | }, 390 | "wind": { 391 | "speed": 2.59, 392 | "deg": 344, 393 | "gust": 4.99 394 | }, 395 | "visibility": 10000, 396 | "pop": 0, 397 | "sys": { 398 | "pod": "n" 399 | }, 400 | "dt_txt": "2021-08-13 03:00:00" 401 | }, 402 | { 403 | "dt": 1628834400, 404 | "main": { 405 | "temp": 18.56, 406 | "feels_like": 18.22, 407 | "temp_min": 18.56, 408 | "temp_max": 18.56, 409 | "pressure": 1022, 410 | "sea_level": 1022, 411 | "grnd_level": 1017, 412 | "humidity": 67, 413 | "temp_kf": 0 414 | }, 415 | "weather": [ 416 | { 417 | "id": 803, 418 | "main": "Clouds", 419 | "description": "broken clouds", 420 | "icon": "04d" 421 | } 422 | ], 423 | "clouds": { 424 | "all": 70 425 | }, 426 | "wind": { 427 | "speed": 2.46, 428 | "deg": 342, 429 | "gust": 4.53 430 | }, 431 | "visibility": 10000, 432 | "pop": 0, 433 | "sys": { 434 | "pod": "d" 435 | }, 436 | "dt_txt": "2021-08-13 06:00:00" 437 | }, 438 | { 439 | "dt": 1628845200, 440 | "main": { 441 | "temp": 22.65, 442 | "feels_like": 22.38, 443 | "temp_min": 22.65, 444 | "temp_max": 22.65, 445 | "pressure": 1023, 446 | "sea_level": 1023, 447 | "grnd_level": 1018, 448 | "humidity": 54, 449 | "temp_kf": 0 450 | }, 451 | "weather": [ 452 | { 453 | "id": 803, 454 | "main": "Clouds", 455 | "description": "broken clouds", 456 | "icon": "04d" 457 | } 458 | ], 459 | "clouds": { 460 | "all": 67 461 | }, 462 | "wind": { 463 | "speed": 2.51, 464 | "deg": 334, 465 | "gust": 3.12 466 | }, 467 | "visibility": 10000, 468 | "pop": 0, 469 | "sys": { 470 | "pod": "d" 471 | }, 472 | "dt_txt": "2021-08-13 09:00:00" 473 | }, 474 | { 475 | "dt": 1628856000, 476 | "main": { 477 | "temp": 26.63, 478 | "feels_like": 26.63, 479 | "temp_min": 26.63, 480 | "temp_max": 26.63, 481 | "pressure": 1023, 482 | "sea_level": 1023, 483 | "grnd_level": 1018, 484 | "humidity": 42, 485 | "temp_kf": 0 486 | }, 487 | "weather": [ 488 | { 489 | "id": 803, 490 | "main": "Clouds", 491 | "description": "broken clouds", 492 | "icon": "04d" 493 | } 494 | ], 495 | "clouds": { 496 | "all": 72 497 | }, 498 | "wind": { 499 | "speed": 2.56, 500 | "deg": 307, 501 | "gust": 3.46 502 | }, 503 | "visibility": 10000, 504 | "pop": 0, 505 | "sys": { 506 | "pod": "d" 507 | }, 508 | "dt_txt": "2021-08-13 12:00:00" 509 | }, 510 | { 511 | "dt": 1628866800, 512 | "main": { 513 | "temp": 28.15, 514 | "feels_like": 27.47, 515 | "temp_min": 28.15, 516 | "temp_max": 28.15, 517 | "pressure": 1022, 518 | "sea_level": 1022, 519 | "grnd_level": 1017, 520 | "humidity": 35, 521 | "temp_kf": 0 522 | }, 523 | "weather": [ 524 | { 525 | "id": 802, 526 | "main": "Clouds", 527 | "description": "scattered clouds", 528 | "icon": "03d" 529 | } 530 | ], 531 | "clouds": { 532 | "all": 35 533 | }, 534 | "wind": { 535 | "speed": 3.13, 536 | "deg": 300, 537 | "gust": 3.72 538 | }, 539 | "visibility": 10000, 540 | "pop": 0, 541 | "sys": { 542 | "pod": "d" 543 | }, 544 | "dt_txt": "2021-08-13 15:00:00" 545 | }, 546 | { 547 | "dt": 1628877600, 548 | "main": { 549 | "temp": 25.87, 550 | "feels_like": 25.48, 551 | "temp_min": 25.87, 552 | "temp_max": 25.87, 553 | "pressure": 1022, 554 | "sea_level": 1022, 555 | "grnd_level": 1018, 556 | "humidity": 37, 557 | "temp_kf": 0 558 | }, 559 | "weather": [ 560 | { 561 | "id": 801, 562 | "main": "Clouds", 563 | "description": "few clouds", 564 | "icon": "02d" 565 | } 566 | ], 567 | "clouds": { 568 | "all": 23 569 | }, 570 | "wind": { 571 | "speed": 3.6, 572 | "deg": 335, 573 | "gust": 3.83 574 | }, 575 | "visibility": 10000, 576 | "pop": 0, 577 | "sys": { 578 | "pod": "d" 579 | }, 580 | "dt_txt": "2021-08-13 18:00:00" 581 | }, 582 | { 583 | "dt": 1628888400, 584 | "main": { 585 | "temp": 21.3, 586 | "feels_like": 20.69, 587 | "temp_min": 21.3, 588 | "temp_max": 21.3, 589 | "pressure": 1023, 590 | "sea_level": 1023, 591 | "grnd_level": 1019, 592 | "humidity": 46, 593 | "temp_kf": 0 594 | }, 595 | "weather": [ 596 | { 597 | "id": 800, 598 | "main": "Clear", 599 | "description": "clear sky", 600 | "icon": "01n" 601 | } 602 | ], 603 | "clouds": { 604 | "all": 1 605 | }, 606 | "wind": { 607 | "speed": 2.51, 608 | "deg": 11, 609 | "gust": 4.84 610 | }, 611 | "visibility": 10000, 612 | "pop": 0, 613 | "sys": { 614 | "pod": "n" 615 | }, 616 | "dt_txt": "2021-08-13 21:00:00" 617 | }, 618 | { 619 | "dt": 1628899200, 620 | "main": { 621 | "temp": 19.06, 622 | "feels_like": 18.54, 623 | "temp_min": 19.06, 624 | "temp_max": 19.06, 625 | "pressure": 1023, 626 | "sea_level": 1023, 627 | "grnd_level": 1019, 628 | "humidity": 58, 629 | "temp_kf": 0 630 | }, 631 | "weather": [ 632 | { 633 | "id": 800, 634 | "main": "Clear", 635 | "description": "clear sky", 636 | "icon": "01n" 637 | } 638 | ], 639 | "clouds": { 640 | "all": 2 641 | }, 642 | "wind": { 643 | "speed": 2.15, 644 | "deg": 18, 645 | "gust": 3.68 646 | }, 647 | "visibility": 10000, 648 | "pop": 0, 649 | "sys": { 650 | "pod": "n" 651 | }, 652 | "dt_txt": "2021-08-14 00:00:00" 653 | }, 654 | { 655 | "dt": 1628910000, 656 | "main": { 657 | "temp": 17.59, 658 | "feels_like": 17.13, 659 | "temp_min": 17.59, 660 | "temp_max": 17.59, 661 | "pressure": 1022, 662 | "sea_level": 1022, 663 | "grnd_level": 1018, 664 | "humidity": 66, 665 | "temp_kf": 0 666 | }, 667 | "weather": [ 668 | { 669 | "id": 800, 670 | "main": "Clear", 671 | "description": "clear sky", 672 | "icon": "01n" 673 | } 674 | ], 675 | "clouds": { 676 | "all": 1 677 | }, 678 | "wind": { 679 | "speed": 1.54, 680 | "deg": 28, 681 | "gust": 2.44 682 | }, 683 | "visibility": 10000, 684 | "pop": 0, 685 | "sys": { 686 | "pod": "n" 687 | }, 688 | "dt_txt": "2021-08-14 03:00:00" 689 | }, 690 | { 691 | "dt": 1628920800, 692 | "main": { 693 | "temp": 17.63, 694 | "feels_like": 17.12, 695 | "temp_min": 17.63, 696 | "temp_max": 17.63, 697 | "pressure": 1022, 698 | "sea_level": 1022, 699 | "grnd_level": 1018, 700 | "humidity": 64, 701 | "temp_kf": 0 702 | }, 703 | "weather": [ 704 | { 705 | "id": 800, 706 | "main": "Clear", 707 | "description": "clear sky", 708 | "icon": "01d" 709 | } 710 | ], 711 | "clouds": { 712 | "all": 0 713 | }, 714 | "wind": { 715 | "speed": 1.55, 716 | "deg": 33, 717 | "gust": 2.24 718 | }, 719 | "visibility": 10000, 720 | "pop": 0, 721 | "sys": { 722 | "pod": "d" 723 | }, 724 | "dt_txt": "2021-08-14 06:00:00" 725 | }, 726 | { 727 | "dt": 1628931600, 728 | "main": { 729 | "temp": 23.17, 730 | "feels_like": 22.67, 731 | "temp_min": 23.17, 732 | "temp_max": 23.17, 733 | "pressure": 1022, 734 | "sea_level": 1022, 735 | "grnd_level": 1017, 736 | "humidity": 43, 737 | "temp_kf": 0 738 | }, 739 | "weather": [ 740 | { 741 | "id": 800, 742 | "main": "Clear", 743 | "description": "clear sky", 744 | "icon": "01d" 745 | } 746 | ], 747 | "clouds": { 748 | "all": 0 749 | }, 750 | "wind": { 751 | "speed": 1.27, 752 | "deg": 28, 753 | "gust": 0.95 754 | }, 755 | "visibility": 10000, 756 | "pop": 0, 757 | "sys": { 758 | "pod": "d" 759 | }, 760 | "dt_txt": "2021-08-14 09:00:00" 761 | }, 762 | { 763 | "dt": 1628942400, 764 | "main": { 765 | "temp": 28.05, 766 | "feels_like": 27.18, 767 | "temp_min": 28.05, 768 | "temp_max": 28.05, 769 | "pressure": 1020, 770 | "sea_level": 1020, 771 | "grnd_level": 1016, 772 | "humidity": 31, 773 | "temp_kf": 0 774 | }, 775 | "weather": [ 776 | { 777 | "id": 800, 778 | "main": "Clear", 779 | "description": "clear sky", 780 | "icon": "01d" 781 | } 782 | ], 783 | "clouds": { 784 | "all": 0 785 | }, 786 | "wind": { 787 | "speed": 1.18, 788 | "deg": 7, 789 | "gust": 1.09 790 | }, 791 | "visibility": 10000, 792 | "pop": 0, 793 | "sys": { 794 | "pod": "d" 795 | }, 796 | "dt_txt": "2021-08-14 12:00:00" 797 | }, 798 | { 799 | "dt": 1628953200, 800 | "main": { 801 | "temp": 29.73, 802 | "feels_like": 28.26, 803 | "temp_min": 29.73, 804 | "temp_max": 29.73, 805 | "pressure": 1019, 806 | "sea_level": 1019, 807 | "grnd_level": 1014, 808 | "humidity": 26, 809 | "temp_kf": 0 810 | }, 811 | "weather": [ 812 | { 813 | "id": 800, 814 | "main": "Clear", 815 | "description": "clear sky", 816 | "icon": "01d" 817 | } 818 | ], 819 | "clouds": { 820 | "all": 0 821 | }, 822 | "wind": { 823 | "speed": 1.51, 824 | "deg": 358, 825 | "gust": 1.81 826 | }, 827 | "visibility": 10000, 828 | "pop": 0, 829 | "sys": { 830 | "pod": "d" 831 | }, 832 | "dt_txt": "2021-08-14 15:00:00" 833 | }, 834 | { 835 | "dt": 1628964000, 836 | "main": { 837 | "temp": 27.58, 838 | "feels_like": 26.89, 839 | "temp_min": 27.58, 840 | "temp_max": 27.58, 841 | "pressure": 1018, 842 | "sea_level": 1018, 843 | "grnd_level": 1013, 844 | "humidity": 32, 845 | "temp_kf": 0 846 | }, 847 | "weather": [ 848 | { 849 | "id": 800, 850 | "main": "Clear", 851 | "description": "clear sky", 852 | "icon": "01d" 853 | } 854 | ], 855 | "clouds": { 856 | "all": 0 857 | }, 858 | "wind": { 859 | "speed": 2.99, 860 | "deg": 9, 861 | "gust": 3.21 862 | }, 863 | "visibility": 10000, 864 | "pop": 0, 865 | "sys": { 866 | "pod": "d" 867 | }, 868 | "dt_txt": "2021-08-14 18:00:00" 869 | }, 870 | { 871 | "dt": 1628974800, 872 | "main": { 873 | "temp": 22.95, 874 | "feels_like": 22.58, 875 | "temp_min": 22.95, 876 | "temp_max": 22.95, 877 | "pressure": 1018, 878 | "sea_level": 1018, 879 | "grnd_level": 1014, 880 | "humidity": 49, 881 | "temp_kf": 0 882 | }, 883 | "weather": [ 884 | { 885 | "id": 800, 886 | "main": "Clear", 887 | "description": "clear sky", 888 | "icon": "01n" 889 | } 890 | ], 891 | "clouds": { 892 | "all": 0 893 | }, 894 | "wind": { 895 | "speed": 2.92, 896 | "deg": 28, 897 | "gust": 6.54 898 | }, 899 | "visibility": 10000, 900 | "pop": 0, 901 | "sys": { 902 | "pod": "n" 903 | }, 904 | "dt_txt": "2021-08-14 21:00:00" 905 | }, 906 | { 907 | "dt": 1628985600, 908 | "main": { 909 | "temp": 20.13, 910 | "feels_like": 19.84, 911 | "temp_min": 20.13, 912 | "temp_max": 20.13, 913 | "pressure": 1018, 914 | "sea_level": 1018, 915 | "grnd_level": 1013, 916 | "humidity": 63, 917 | "temp_kf": 0 918 | }, 919 | "weather": [ 920 | { 921 | "id": 800, 922 | "main": "Clear", 923 | "description": "clear sky", 924 | "icon": "01n" 925 | } 926 | ], 927 | "clouds": { 928 | "all": 0 929 | }, 930 | "wind": { 931 | "speed": 2.41, 932 | "deg": 39, 933 | "gust": 4.68 934 | }, 935 | "visibility": 10000, 936 | "pop": 0, 937 | "sys": { 938 | "pod": "n" 939 | }, 940 | "dt_txt": "2021-08-15 00:00:00" 941 | }, 942 | { 943 | "dt": 1628996400, 944 | "main": { 945 | "temp": 18.62, 946 | "feels_like": 18.29, 947 | "temp_min": 18.62, 948 | "temp_max": 18.62, 949 | "pressure": 1017, 950 | "sea_level": 1017, 951 | "grnd_level": 1012, 952 | "humidity": 67, 953 | "temp_kf": 0 954 | }, 955 | "weather": [ 956 | { 957 | "id": 800, 958 | "main": "Clear", 959 | "description": "clear sky", 960 | "icon": "01n" 961 | } 962 | ], 963 | "clouds": { 964 | "all": 4 965 | }, 966 | "wind": { 967 | "speed": 1.78, 968 | "deg": 31, 969 | "gust": 3.08 970 | }, 971 | "visibility": 10000, 972 | "pop": 0, 973 | "sys": { 974 | "pod": "n" 975 | }, 976 | "dt_txt": "2021-08-15 03:00:00" 977 | }, 978 | { 979 | "dt": 1629007200, 980 | "main": { 981 | "temp": 18.88, 982 | "feels_like": 18.49, 983 | "temp_min": 18.88, 984 | "temp_max": 18.88, 985 | "pressure": 1016, 986 | "sea_level": 1016, 987 | "grnd_level": 1012, 988 | "humidity": 64, 989 | "temp_kf": 0 990 | }, 991 | "weather": [ 992 | { 993 | "id": 802, 994 | "main": "Clouds", 995 | "description": "scattered clouds", 996 | "icon": "03d" 997 | } 998 | ], 999 | "clouds": { 1000 | "all": 48 1001 | }, 1002 | "wind": { 1003 | "speed": 1.29, 1004 | "deg": 42, 1005 | "gust": 1.97 1006 | }, 1007 | "visibility": 10000, 1008 | "pop": 0, 1009 | "sys": { 1010 | "pod": "d" 1011 | }, 1012 | "dt_txt": "2021-08-15 06:00:00" 1013 | }, 1014 | { 1015 | "dt": 1629018000, 1016 | "main": { 1017 | "temp": 23.64, 1018 | "feels_like": 23.29, 1019 | "temp_min": 23.64, 1020 | "temp_max": 23.64, 1021 | "pressure": 1016, 1022 | "sea_level": 1016, 1023 | "grnd_level": 1012, 1024 | "humidity": 47, 1025 | "temp_kf": 0 1026 | }, 1027 | "weather": [ 1028 | { 1029 | "id": 804, 1030 | "main": "Clouds", 1031 | "description": "overcast clouds", 1032 | "icon": "04d" 1033 | } 1034 | ], 1035 | "clouds": { 1036 | "all": 98 1037 | }, 1038 | "wind": { 1039 | "speed": 1.15, 1040 | "deg": 40, 1041 | "gust": 0.94 1042 | }, 1043 | "visibility": 10000, 1044 | "pop": 0, 1045 | "sys": { 1046 | "pod": "d" 1047 | }, 1048 | "dt_txt": "2021-08-15 09:00:00" 1049 | }, 1050 | { 1051 | "dt": 1629028800, 1052 | "main": { 1053 | "temp": 28.53, 1054 | "feels_like": 27.79, 1055 | "temp_min": 28.53, 1056 | "temp_max": 28.53, 1057 | "pressure": 1015, 1058 | "sea_level": 1015, 1059 | "grnd_level": 1010, 1060 | "humidity": 35, 1061 | "temp_kf": 0 1062 | }, 1063 | "weather": [ 1064 | { 1065 | "id": 804, 1066 | "main": "Clouds", 1067 | "description": "overcast clouds", 1068 | "icon": "04d" 1069 | } 1070 | ], 1071 | "clouds": { 1072 | "all": 99 1073 | }, 1074 | "wind": { 1075 | "speed": 0.73, 1076 | "deg": 317, 1077 | "gust": 1.4 1078 | }, 1079 | "visibility": 10000, 1080 | "pop": 0, 1081 | "sys": { 1082 | "pod": "d" 1083 | }, 1084 | "dt_txt": "2021-08-15 12:00:00" 1085 | }, 1086 | { 1087 | "dt": 1629039600, 1088 | "main": { 1089 | "temp": 30.47, 1090 | "feels_like": 29.05, 1091 | "temp_min": 30.47, 1092 | "temp_max": 30.47, 1093 | "pressure": 1013, 1094 | "sea_level": 1013, 1095 | "grnd_level": 1008, 1096 | "humidity": 28, 1097 | "temp_kf": 0 1098 | }, 1099 | "weather": [ 1100 | { 1101 | "id": 803, 1102 | "main": "Clouds", 1103 | "description": "broken clouds", 1104 | "icon": "04d" 1105 | } 1106 | ], 1107 | "clouds": { 1108 | "all": 55 1109 | }, 1110 | "wind": { 1111 | "speed": 2.46, 1112 | "deg": 280, 1113 | "gust": 5.13 1114 | }, 1115 | "visibility": 10000, 1116 | "pop": 0, 1117 | "sys": { 1118 | "pod": "d" 1119 | }, 1120 | "dt_txt": "2021-08-15 15:00:00" 1121 | }, 1122 | { 1123 | "dt": 1629050400, 1124 | "main": { 1125 | "temp": 28.76, 1126 | "feels_like": 27.85, 1127 | "temp_min": 28.76, 1128 | "temp_max": 28.76, 1129 | "pressure": 1011, 1130 | "sea_level": 1011, 1131 | "grnd_level": 1007, 1132 | "humidity": 33, 1133 | "temp_kf": 0 1134 | }, 1135 | "weather": [ 1136 | { 1137 | "id": 802, 1138 | "main": "Clouds", 1139 | "description": "scattered clouds", 1140 | "icon": "03d" 1141 | } 1142 | ], 1143 | "clouds": { 1144 | "all": 29 1145 | }, 1146 | "wind": { 1147 | "speed": 3.06, 1148 | "deg": 294, 1149 | "gust": 5.08 1150 | }, 1151 | "visibility": 10000, 1152 | "pop": 0, 1153 | "sys": { 1154 | "pod": "d" 1155 | }, 1156 | "dt_txt": "2021-08-15 18:00:00" 1157 | }, 1158 | { 1159 | "dt": 1629061200, 1160 | "main": { 1161 | "temp": 23.41, 1162 | "feels_like": 23.27, 1163 | "temp_min": 23.41, 1164 | "temp_max": 23.41, 1165 | "pressure": 1012, 1166 | "sea_level": 1012, 1167 | "grnd_level": 1007, 1168 | "humidity": 56, 1169 | "temp_kf": 0 1170 | }, 1171 | "weather": [ 1172 | { 1173 | "id": 800, 1174 | "main": "Clear", 1175 | "description": "clear sky", 1176 | "icon": "01n" 1177 | } 1178 | ], 1179 | "clouds": { 1180 | "all": 2 1181 | }, 1182 | "wind": { 1183 | "speed": 2.77, 1184 | "deg": 10, 1185 | "gust": 4.76 1186 | }, 1187 | "visibility": 10000, 1188 | "pop": 0, 1189 | "sys": { 1190 | "pod": "n" 1191 | }, 1192 | "dt_txt": "2021-08-15 21:00:00" 1193 | }, 1194 | { 1195 | "dt": 1629072000, 1196 | "main": { 1197 | "temp": 20.47, 1198 | "feels_like": 20.24, 1199 | "temp_min": 20.47, 1200 | "temp_max": 20.47, 1201 | "pressure": 1012, 1202 | "sea_level": 1012, 1203 | "grnd_level": 1007, 1204 | "humidity": 64, 1205 | "temp_kf": 0 1206 | }, 1207 | "weather": [ 1208 | { 1209 | "id": 800, 1210 | "main": "Clear", 1211 | "description": "clear sky", 1212 | "icon": "01d" 1213 | } 1214 | ], 1215 | "clouds": { 1216 | "all": 1 1217 | }, 1218 | "wind": { 1219 | "speed": 3.39, 1220 | "deg": 311, 1221 | "gust": 5.77 1222 | }, 1223 | "visibility": 10000, 1224 | "pop": 0, 1225 | "sys": { 1226 | "pod": "d" 1227 | }, 1228 | "dt_txt": "2021-08-16 00:00:00" 1229 | }, 1230 | { 1231 | "dt": 1629082800, 1232 | "main": { 1233 | "temp": 17.86, 1234 | "feels_like": 17.69, 1235 | "temp_min": 17.86, 1236 | "temp_max": 17.86, 1237 | "pressure": 1011, 1238 | "sea_level": 1011, 1239 | "grnd_level": 1007, 1240 | "humidity": 76, 1241 | "temp_kf": 0 1242 | }, 1243 | "weather": [ 1244 | { 1245 | "id": 800, 1246 | "main": "Clear", 1247 | "description": "clear sky", 1248 | "icon": "01d" 1249 | } 1250 | ], 1251 | "clouds": { 1252 | "all": 8 1253 | }, 1254 | "wind": { 1255 | "speed": 3.04, 1256 | "deg": 268, 1257 | "gust": 6.26 1258 | }, 1259 | "visibility": 10000, 1260 | "pop": 0, 1261 | "sys": { 1262 | "pod": "d" 1263 | }, 1264 | "dt_txt": "2021-08-16 03:00:00" 1265 | }, 1266 | { 1267 | "dt": 1629093600, 1268 | "main": { 1269 | "temp": 16.79, 1270 | "feels_like": 16.43, 1271 | "temp_min": 16.79, 1272 | "temp_max": 16.79, 1273 | "pressure": 1011, 1274 | "sea_level": 1011, 1275 | "grnd_level": 1007, 1276 | "humidity": 73, 1277 | "temp_kf": 0 1278 | }, 1279 | "weather": [ 1280 | { 1281 | "id": 802, 1282 | "main": "Clouds", 1283 | "description": "scattered clouds", 1284 | "icon": "03d" 1285 | } 1286 | ], 1287 | "clouds": { 1288 | "all": 31 1289 | }, 1290 | "wind": { 1291 | "speed": 5.14, 1292 | "deg": 234, 1293 | "gust": 8.89 1294 | }, 1295 | "visibility": 10000, 1296 | "pop": 0, 1297 | "sys": { 1298 | "pod": "d" 1299 | }, 1300 | "dt_txt": "2021-08-16 06:00:00" 1301 | }, 1302 | { 1303 | "dt": 1629104400, 1304 | "main": { 1305 | "temp": 17.45, 1306 | "feels_like": 17.16, 1307 | "temp_min": 17.45, 1308 | "temp_max": 17.45, 1309 | "pressure": 1012, 1310 | "sea_level": 1012, 1311 | "grnd_level": 1007, 1312 | "humidity": 73, 1313 | "temp_kf": 0 1314 | }, 1315 | "weather": [ 1316 | { 1317 | "id": 804, 1318 | "main": "Clouds", 1319 | "description": "overcast clouds", 1320 | "icon": "04d" 1321 | } 1322 | ], 1323 | "clouds": { 1324 | "all": 95 1325 | }, 1326 | "wind": { 1327 | "speed": 5.65, 1328 | "deg": 223, 1329 | "gust": 10.84 1330 | }, 1331 | "visibility": 10000, 1332 | "pop": 0, 1333 | "sys": { 1334 | "pod": "d" 1335 | }, 1336 | "dt_txt": "2021-08-16 09:00:00" 1337 | }, 1338 | { 1339 | "dt": 1629115200, 1340 | "main": { 1341 | "temp": 19.16, 1342 | "feels_like": 18.99, 1343 | "temp_min": 19.16, 1344 | "temp_max": 19.16, 1345 | "pressure": 1012, 1346 | "sea_level": 1012, 1347 | "grnd_level": 1007, 1348 | "humidity": 71, 1349 | "temp_kf": 0 1350 | }, 1351 | "weather": [ 1352 | { 1353 | "id": 500, 1354 | "main": "Rain", 1355 | "description": "light rain", 1356 | "icon": "10d" 1357 | } 1358 | ], 1359 | "clouds": { 1360 | "all": 97 1361 | }, 1362 | "wind": { 1363 | "speed": 5.79, 1364 | "deg": 280, 1365 | "gust": 9.82 1366 | }, 1367 | "visibility": 10000, 1368 | "pop": 0.87, 1369 | "rain": { 1370 | "3h": 1.18 1371 | }, 1372 | "sys": { 1373 | "pod": "d" 1374 | }, 1375 | "dt_txt": "2021-08-16 12:00:00" 1376 | }, 1377 | { 1378 | "dt": 1629126000, 1379 | "main": { 1380 | "temp": 22.42, 1381 | "feels_like": 21.71, 1382 | "temp_min": 22.42, 1383 | "temp_max": 22.42, 1384 | "pressure": 1012, 1385 | "sea_level": 1012, 1386 | "grnd_level": 1007, 1387 | "humidity": 38, 1388 | "temp_kf": 0 1389 | }, 1390 | "weather": [ 1391 | { 1392 | "id": 500, 1393 | "main": "Rain", 1394 | "description": "light rain", 1395 | "icon": "10d" 1396 | } 1397 | ], 1398 | "clouds": { 1399 | "all": 40 1400 | }, 1401 | "wind": { 1402 | "speed": 7.05, 1403 | "deg": 294, 1404 | "gust": 10.55 1405 | }, 1406 | "visibility": 10000, 1407 | "pop": 0.35, 1408 | "rain": { 1409 | "3h": 0.13 1410 | }, 1411 | "sys": { 1412 | "pod": "d" 1413 | }, 1414 | "dt_txt": "2021-08-16 15:00:00" 1415 | }, 1416 | { 1417 | "dt": 1629136800, 1418 | "main": { 1419 | "temp": 19.14, 1420 | "feels_like": 18.34, 1421 | "temp_min": 19.14, 1422 | "temp_max": 19.14, 1423 | "pressure": 1014, 1424 | "sea_level": 1014, 1425 | "grnd_level": 1009, 1426 | "humidity": 47, 1427 | "temp_kf": 0 1428 | }, 1429 | "weather": [ 1430 | { 1431 | "id": 802, 1432 | "main": "Clouds", 1433 | "description": "scattered clouds", 1434 | "icon": "03d" 1435 | } 1436 | ], 1437 | "clouds": { 1438 | "all": 29 1439 | }, 1440 | "wind": { 1441 | "speed": 6.03, 1442 | "deg": 295, 1443 | "gust": 10.84 1444 | }, 1445 | "visibility": 10000, 1446 | "pop": 0.16, 1447 | "sys": { 1448 | "pod": "d" 1449 | }, 1450 | "dt_txt": "2021-08-16 18:00:00" 1451 | } 1452 | ], 1453 | "city": { 1454 | "id": 2988507, 1455 | "name": "Paris", 1456 | "coord": { 1457 | "lat": 48.8534, 1458 | "lon": 2.3488 1459 | }, 1460 | "country": "FR", 1461 | "population": 2138551, 1462 | "timezone": 7200, 1463 | "sunrise": 1628656670, 1464 | "sunset": 1628709235 1465 | } 1466 | } 1467 | 🐵 1468 | --------------------------------------------------------------------------------