├── 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 | 
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 | 
31 |
32 | 
33 |
34 | 
35 |
36 | 
37 |
38 | 
39 |
40 | 
41 |
42 | 
43 |
44 | 
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 |
--------------------------------------------------------------------------------