├── .gitignore ├── LICENSE.md ├── README.md ├── Weather.xcodeproj ├── project.pbxproj └── xcshareddata │ └── xcschemes │ └── Weather.xcscheme ├── Weather ├── Resources │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── Base.lproj │ │ └── LaunchScreen.storyboard │ ├── Mocks │ │ ├── MockOneCallResponse.json │ │ ├── MockWeatherFetcher.swift │ │ └── MockWeatherSummary.swift │ └── Preview Content │ │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Sources │ ├── DataManager │ │ └── DataManager.swift │ ├── Models │ │ ├── OneCallResponse.swift │ │ ├── WeatherError.swift │ │ └── WeatherSummary.swift │ ├── Utilities │ │ └── DismissKeyboard.swift │ └── Views │ │ ├── CurrentSummary │ │ ├── CurrentSummaryView.swift │ │ └── CurrentSummaryViewModel.swift │ │ ├── DaySummary │ │ ├── DaySummaryView.swift │ │ └── DaySummaryViewModel.swift │ │ ├── HourSummary │ │ ├── HourInformationViewModel.swift │ │ ├── HourSummaryView.swift │ │ └── HourSummaryViewModel.swift │ │ ├── SearchBar │ │ └── SearchBar.swift │ │ └── WeatherSummary │ │ ├── WeatherSummaryView.swift │ │ └── WeatherSummaryViewModel.swift └── Support │ ├── AppDelegate.swift │ ├── Info.plist │ └── SceneDelegate.swift ├── WeatherTests ├── CurrentSummaryViewModelTests.swift ├── DaySummaryViewModelTests.swift ├── HourSummaryViewModelTests.swift ├── Info.plist ├── WeatherSummaryViewModelTests.swift └── WeatherTests.swift └── screenshots ├── screencap1.gif └── screenshot1.png /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/xcode,macos,cocoapods 3 | # Edit at https://www.gitignore.io/?templates=xcode,macos,cocoapods 4 | 5 | ### CocoaPods ### 6 | ## CocoaPods GitIgnore Template 7 | 8 | # CocoaPods - Only use to conserve bandwidth / Save time on Pushing 9 | # - Also handy if you have a large number of dependant pods 10 | # - AS PER https://guides.cocoapods.org/using/using-cocoapods.html NEVER IGNORE THE LOCK FILE 11 | Pods/ 12 | 13 | ### macOS ### 14 | # General 15 | .DS_Store 16 | .AppleDouble 17 | .LSOverride 18 | 19 | # Icon must end with two \r 20 | Icon 21 | 22 | # Thumbnails 23 | ._* 24 | 25 | # Files that might appear in the root of a volume 26 | .DocumentRevisions-V100 27 | .fseventsd 28 | .Spotlight-V100 29 | .TemporaryItems 30 | .Trashes 31 | .VolumeIcon.icns 32 | .com.apple.timemachine.donotpresent 33 | 34 | # Directories potentially created on remote AFP share 35 | .AppleDB 36 | .AppleDesktop 37 | Network Trash Folder 38 | Temporary Items 39 | .apdisk 40 | 41 | ### Xcode ### 42 | # Xcode 43 | # 44 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 45 | 46 | ## User settings 47 | xcuserdata/ 48 | 49 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 50 | *.xcscmblueprint 51 | *.xccheckout 52 | 53 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 54 | build/ 55 | DerivedData/ 56 | *.moved-aside 57 | *.pbxuser 58 | !default.pbxuser 59 | *.mode1v3 60 | !default.mode1v3 61 | *.mode2v3 62 | !default.mode2v3 63 | *.perspectivev3 64 | !default.perspectivev3 65 | 66 | ## Xcode Patch 67 | *.xcodeproj/* 68 | !*.xcodeproj/project.pbxproj 69 | !*.xcodeproj/xcshareddata/ 70 | !*.xcworkspace/contents.xcworkspacedata 71 | /*.gcno 72 | 73 | ### Xcode Patch ### 74 | **/xcshareddata/WorkspaceSettings.xcsettings 75 | 76 | # End of https://www.gitignore.io/api/xcode,macos,cocoapods 77 | 78 | Secrets.* 79 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Alex Fargo 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftUI Weather 2 | 3 | This is an app I made in a weekend to learn more about using Combine and SwiftUI. I am pretty unfamiliar with Combine and haven't really used many reactive libraries before, so it was a little tough wrapping my head around how Combine works. 4 | 5 | ## Motivation 6 | 7 | I've been messing around with SwiftUI ever since it was announced at WWDC19 but not much with Combine. I was kind of afraid of it because of the few times I tried using it, I could never really understand exactly what I was doing. 8 | 9 | Now that I'm a bit more of a mature iOS developer (since I have been working professionally for a year in addition to the couple of years of unprofessional experience before that) I thought I would give Combine a shot again. 10 | 11 | ## Screenshots 12 | 13 | screenshot1 screencap1 14 | 15 | ## What I Learned 16 | 17 | So the main goal of this project was to learn more about using Combine and SwiftUI together. Boy did I certainly learn a few things... 18 | 19 | One of the biggest problems I ran into was searching for weather by named location. 20 | 21 | See, the endpoint I chose to use in the Open Weather API has two main parameters `lat` latitude and `lon` longitude. Now, obviously it would be a horrible UX if I had the users type in their coordinates just to search for weather conditions at a location, so I did some searching for a way to convert a location string to some coordinates. Luckily, Apple provides such a feature in the CoreLocation API. Great 🙌. Except it is an asynchronous call (makes sense) that does not use Combine 😰. I already had code up and running for making a call to the Open Weather API endpoint with a `dataTaskPublisher` and some predetermined coordinates which wasn't too bad to setup. But now I had to figure how to link together these two calls. 22 | 23 | Had I not been using Combine, I probably would have just used a Semaphore and used the result of the CoreLocation call in the Open Weather call. But no, we are using Combine! 24 | 25 | So, I learned about this other Publisher type called a `Future` which is a little more familiar looking cause it is an asynchronous call with a completion (kind of). With a `Future`, you have a `Promise`, which in this case is a closure to be called in the *future* with a `Result` type parameter. This was super easy to understand and implement. 26 | 27 | The hard part came when I had to link these two calls together. I knew I wanted the location call to occur first then the weather call, but how the heck do I do this in Combine?? I started doing lots of searching and basically discovered that `map` is my best friend. As long as I got the types to match at the beginning and end of each of the transformation methods, then everything should work out. Boy was it a pain figuring out the types on each end and how to get them to that point. 28 | 29 | You will see in the code that there are lots of `.eraseToAnyPublisher()` calls. At a basic level, what this does is what it sounds like, it strips the publisher type from the publisher to its bare bones, the `Output` type and the `Failure` type, then wraps it in `AnyPublisher`. Then you have some `Publisher` that has X output type and Y failure type. As long as you match those in your next publisher transformation, then you are good 👍 30 | 31 | Eventuall I got it to work and I was so relieved and happy. I really do feel like I understand Publishers and subscribers A LOT more now. Hopefully this all made sense... Feel free to email me if you want help or don't understand something I've said. Combine is super tricky and it won't make sense on your first try, but that's okay. Just keep trying and eventually it will click! 32 | 33 | ## Usage 34 | 35 | To download and use this project, you must obtain an Open Weather API key from [here](https://openweathermap.org/api). 36 | 37 | Once you have a key, navigate to the Support folder in the project and create a property list file named "Secrets.plist." 38 | 39 | Create a key named `openWeatherAPIKey` and assign the key that you obtained to the value. 40 | 41 | ## Contribute 42 | 43 | If you would like to contribute, please do! 44 | 45 | ## License 46 | 47 | MIT (c) [Alex Fargo](https://github.com/flexaargo) [(x)](https://twitter.com/flexaargo) 48 | 49 | -------------------------------------------------------------------------------- /Weather.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 50; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | D07EF08B2456A0A4004189AC /* OneCallResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EF08A2456A0A4004189AC /* OneCallResponse.swift */; }; 11 | D07EF0922456A2CA004189AC /* DataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EF0912456A2CA004189AC /* DataManager.swift */; }; 12 | D07EF0942456A383004189AC /* WeatherSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ABD7492456993000840F37 /* WeatherSummaryView.swift */; }; 13 | D07EF0972456A6EE004189AC /* MockOneCallResponse.json in Resources */ = {isa = PBXBuildFile; fileRef = D07EF0962456A6EE004189AC /* MockOneCallResponse.json */; }; 14 | D07EF09924575F3F004189AC /* WeatherError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EF09824575F3F004189AC /* WeatherError.swift */; }; 15 | D07EF09B24575FCB004189AC /* Secrets.plist in Resources */ = {isa = PBXBuildFile; fileRef = D07EF09A24575FCB004189AC /* Secrets.plist */; }; 16 | D07EF09D24576267004189AC /* WeatherSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EF09C24576267004189AC /* WeatherSummary.swift */; }; 17 | D07EF09E24578D6B004189AC /* WeatherSummaryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EF08C2456A26B004189AC /* WeatherSummaryViewModel.swift */; }; 18 | D07EF0A024578E00004189AC /* MockWeatherFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EF09F24578E00004189AC /* MockWeatherFetcher.swift */; }; 19 | D07EF0A42457C196004189AC /* CurrentSummaryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EF0A32457C196004189AC /* CurrentSummaryViewModel.swift */; }; 20 | D07EF0A62457C1A7004189AC /* CurrentSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EF0A52457C1A7004189AC /* CurrentSummaryView.swift */; }; 21 | D07EF0A92457C2E9004189AC /* MockWeatherSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EF0A82457C2E9004189AC /* MockWeatherSummary.swift */; }; 22 | D07EF0AC2457D83F004189AC /* HourSummaryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EF0AB2457D83F004189AC /* HourSummaryViewModel.swift */; }; 23 | D07EF0AE2457D897004189AC /* HourSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EF0AD2457D897004189AC /* HourSummaryView.swift */; }; 24 | D07EF0B12457E13B004189AC /* DaySummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EF0B02457E13B004189AC /* DaySummaryView.swift */; }; 25 | D07EF0B32457E152004189AC /* DaySummaryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EF0B22457E152004189AC /* DaySummaryViewModel.swift */; }; 26 | D07EF0B62457ED40004189AC /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EF0B52457ED40004189AC /* SearchBar.swift */; }; 27 | D07EF0B82457F203004189AC /* DismissKeyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EF0B72457F203004189AC /* DismissKeyboard.swift */; }; 28 | D07EF0BA2457F5AD004189AC /* DaySummaryViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EF0B92457F5AD004189AC /* DaySummaryViewModelTests.swift */; }; 29 | D07EF0BE2457F742004189AC /* HourSummaryViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EF0BD2457F742004189AC /* HourSummaryViewModelTests.swift */; }; 30 | D07EF0C02457F869004189AC /* CurrentSummaryViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EF0BF2457F869004189AC /* CurrentSummaryViewModelTests.swift */; }; 31 | D07EF0C22457FA12004189AC /* WeatherSummaryViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EF0C12457FA12004189AC /* WeatherSummaryViewModelTests.swift */; }; 32 | D0ABD7462456993000840F37 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ABD7452456993000840F37 /* AppDelegate.swift */; }; 33 | D0ABD7482456993000840F37 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ABD7472456993000840F37 /* SceneDelegate.swift */; }; 34 | D0ABD74C2456993200840F37 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D0ABD74B2456993200840F37 /* Assets.xcassets */; }; 35 | D0ABD74F2456993200840F37 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D0ABD74E2456993200840F37 /* Preview Assets.xcassets */; }; 36 | D0ABD7522456993200840F37 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D0ABD7502456993200840F37 /* LaunchScreen.storyboard */; }; 37 | D0ABD75D2456993200840F37 /* WeatherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ABD75C2456993200840F37 /* WeatherTests.swift */; }; 38 | /* End PBXBuildFile section */ 39 | 40 | /* Begin PBXContainerItemProxy section */ 41 | D0ABD7592456993200840F37 /* PBXContainerItemProxy */ = { 42 | isa = PBXContainerItemProxy; 43 | containerPortal = D0ABD73A2456993000840F37 /* Project object */; 44 | proxyType = 1; 45 | remoteGlobalIDString = D0ABD7412456993000840F37; 46 | remoteInfo = Weather; 47 | }; 48 | /* End PBXContainerItemProxy section */ 49 | 50 | /* Begin PBXFileReference section */ 51 | D07EF08A2456A0A4004189AC /* OneCallResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneCallResponse.swift; sourceTree = ""; }; 52 | D07EF08C2456A26B004189AC /* WeatherSummaryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherSummaryViewModel.swift; sourceTree = ""; }; 53 | D07EF0912456A2CA004189AC /* DataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataManager.swift; sourceTree = ""; }; 54 | D07EF0962456A6EE004189AC /* MockOneCallResponse.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MockOneCallResponse.json; sourceTree = ""; }; 55 | D07EF09824575F3F004189AC /* WeatherError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherError.swift; sourceTree = ""; }; 56 | D07EF09A24575FCB004189AC /* Secrets.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Secrets.plist; sourceTree = ""; }; 57 | D07EF09C24576267004189AC /* WeatherSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherSummary.swift; sourceTree = ""; }; 58 | D07EF09F24578E00004189AC /* MockWeatherFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockWeatherFetcher.swift; sourceTree = ""; }; 59 | D07EF0A32457C196004189AC /* CurrentSummaryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentSummaryViewModel.swift; sourceTree = ""; }; 60 | D07EF0A52457C1A7004189AC /* CurrentSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentSummaryView.swift; sourceTree = ""; }; 61 | D07EF0A82457C2E9004189AC /* MockWeatherSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockWeatherSummary.swift; sourceTree = ""; }; 62 | D07EF0AB2457D83F004189AC /* HourSummaryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HourSummaryViewModel.swift; sourceTree = ""; }; 63 | D07EF0AD2457D897004189AC /* HourSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HourSummaryView.swift; sourceTree = ""; }; 64 | D07EF0B02457E13B004189AC /* DaySummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaySummaryView.swift; sourceTree = ""; }; 65 | D07EF0B22457E152004189AC /* DaySummaryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaySummaryViewModel.swift; sourceTree = ""; }; 66 | D07EF0B52457ED40004189AC /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = ""; }; 67 | D07EF0B72457F203004189AC /* DismissKeyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissKeyboard.swift; sourceTree = ""; }; 68 | D07EF0B92457F5AD004189AC /* DaySummaryViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaySummaryViewModelTests.swift; sourceTree = ""; }; 69 | D07EF0BD2457F742004189AC /* HourSummaryViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HourSummaryViewModelTests.swift; sourceTree = ""; }; 70 | D07EF0BF2457F869004189AC /* CurrentSummaryViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentSummaryViewModelTests.swift; sourceTree = ""; }; 71 | D07EF0C12457FA12004189AC /* WeatherSummaryViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherSummaryViewModelTests.swift; sourceTree = ""; }; 72 | D0ABD7422456993000840F37 /* Weather.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Weather.app; sourceTree = BUILT_PRODUCTS_DIR; }; 73 | D0ABD7452456993000840F37 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 74 | D0ABD7472456993000840F37 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 75 | D0ABD7492456993000840F37 /* WeatherSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherSummaryView.swift; sourceTree = ""; }; 76 | D0ABD74B2456993200840F37 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 77 | D0ABD74E2456993200840F37 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 78 | D0ABD7512456993200840F37 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 79 | D0ABD7532456993200840F37 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 80 | D0ABD7582456993200840F37 /* WeatherTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = WeatherTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 81 | D0ABD75C2456993200840F37 /* WeatherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherTests.swift; sourceTree = ""; }; 82 | D0ABD75E2456993200840F37 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 83 | /* End PBXFileReference section */ 84 | 85 | /* Begin PBXFrameworksBuildPhase section */ 86 | D0ABD73F2456993000840F37 /* Frameworks */ = { 87 | isa = PBXFrameworksBuildPhase; 88 | buildActionMask = 2147483647; 89 | files = ( 90 | ); 91 | runOnlyForDeploymentPostprocessing = 0; 92 | }; 93 | D0ABD7552456993200840F37 /* Frameworks */ = { 94 | isa = PBXFrameworksBuildPhase; 95 | buildActionMask = 2147483647; 96 | files = ( 97 | ); 98 | runOnlyForDeploymentPostprocessing = 0; 99 | }; 100 | /* End PBXFrameworksBuildPhase section */ 101 | 102 | /* Begin PBXGroup section */ 103 | D07EF0892456A095004189AC /* Models */ = { 104 | isa = PBXGroup; 105 | children = ( 106 | D07EF08A2456A0A4004189AC /* OneCallResponse.swift */, 107 | D07EF09C24576267004189AC /* WeatherSummary.swift */, 108 | D07EF09824575F3F004189AC /* WeatherError.swift */, 109 | ); 110 | path = Models; 111 | sourceTree = ""; 112 | }; 113 | D07EF08E2456A29E004189AC /* Views */ = { 114 | isa = PBXGroup; 115 | children = ( 116 | D07EF0B42457ED2E004189AC /* SearchBar */, 117 | D07EF0AF2457E12F004189AC /* DaySummary */, 118 | D07EF0AA2457D835004189AC /* HourSummary */, 119 | D07EF0A22457C187004189AC /* CurrentSummary */, 120 | D07EF08F2456A2AE004189AC /* WeatherSummary */, 121 | ); 122 | path = Views; 123 | sourceTree = ""; 124 | }; 125 | D07EF08F2456A2AE004189AC /* WeatherSummary */ = { 126 | isa = PBXGroup; 127 | children = ( 128 | D07EF08C2456A26B004189AC /* WeatherSummaryViewModel.swift */, 129 | D0ABD7492456993000840F37 /* WeatherSummaryView.swift */, 130 | ); 131 | path = WeatherSummary; 132 | sourceTree = ""; 133 | }; 134 | D07EF0902456A2BC004189AC /* DataManager */ = { 135 | isa = PBXGroup; 136 | children = ( 137 | D07EF0912456A2CA004189AC /* DataManager.swift */, 138 | ); 139 | path = DataManager; 140 | sourceTree = ""; 141 | }; 142 | D07EF0932456A30F004189AC /* Utilities */ = { 143 | isa = PBXGroup; 144 | children = ( 145 | D07EF0B72457F203004189AC /* DismissKeyboard.swift */, 146 | ); 147 | path = Utilities; 148 | sourceTree = ""; 149 | }; 150 | D07EF0952456A6CC004189AC /* Mocks */ = { 151 | isa = PBXGroup; 152 | children = ( 153 | D07EF0962456A6EE004189AC /* MockOneCallResponse.json */, 154 | D07EF09F24578E00004189AC /* MockWeatherFetcher.swift */, 155 | D07EF0A82457C2E9004189AC /* MockWeatherSummary.swift */, 156 | ); 157 | path = Mocks; 158 | sourceTree = ""; 159 | }; 160 | D07EF0A22457C187004189AC /* CurrentSummary */ = { 161 | isa = PBXGroup; 162 | children = ( 163 | D07EF0A32457C196004189AC /* CurrentSummaryViewModel.swift */, 164 | D07EF0A52457C1A7004189AC /* CurrentSummaryView.swift */, 165 | ); 166 | path = CurrentSummary; 167 | sourceTree = ""; 168 | }; 169 | D07EF0AA2457D835004189AC /* HourSummary */ = { 170 | isa = PBXGroup; 171 | children = ( 172 | D07EF0AB2457D83F004189AC /* HourSummaryViewModel.swift */, 173 | D07EF0AD2457D897004189AC /* HourSummaryView.swift */, 174 | ); 175 | path = HourSummary; 176 | sourceTree = ""; 177 | }; 178 | D07EF0AF2457E12F004189AC /* DaySummary */ = { 179 | isa = PBXGroup; 180 | children = ( 181 | D07EF0B22457E152004189AC /* DaySummaryViewModel.swift */, 182 | D07EF0B02457E13B004189AC /* DaySummaryView.swift */, 183 | ); 184 | path = DaySummary; 185 | sourceTree = ""; 186 | }; 187 | D07EF0B42457ED2E004189AC /* SearchBar */ = { 188 | isa = PBXGroup; 189 | children = ( 190 | D07EF0B52457ED40004189AC /* SearchBar.swift */, 191 | ); 192 | path = SearchBar; 193 | sourceTree = ""; 194 | }; 195 | D0ABD7392456993000840F37 = { 196 | isa = PBXGroup; 197 | children = ( 198 | D0ABD7442456993000840F37 /* Weather */, 199 | D0ABD75B2456993200840F37 /* WeatherTests */, 200 | D0ABD7432456993000840F37 /* Products */, 201 | ); 202 | sourceTree = ""; 203 | }; 204 | D0ABD7432456993000840F37 /* Products */ = { 205 | isa = PBXGroup; 206 | children = ( 207 | D0ABD7422456993000840F37 /* Weather.app */, 208 | D0ABD7582456993200840F37 /* WeatherTests.xctest */, 209 | ); 210 | name = Products; 211 | sourceTree = ""; 212 | }; 213 | D0ABD7442456993000840F37 /* Weather */ = { 214 | isa = PBXGroup; 215 | children = ( 216 | D0ABD7692456994D00840F37 /* Support */, 217 | D0ABD7682456994A00840F37 /* Sources */, 218 | D0ABD7672456993D00840F37 /* Resources */, 219 | ); 220 | path = Weather; 221 | sourceTree = ""; 222 | }; 223 | D0ABD74D2456993200840F37 /* Preview Content */ = { 224 | isa = PBXGroup; 225 | children = ( 226 | D0ABD74E2456993200840F37 /* Preview Assets.xcassets */, 227 | ); 228 | path = "Preview Content"; 229 | sourceTree = ""; 230 | }; 231 | D0ABD75B2456993200840F37 /* WeatherTests */ = { 232 | isa = PBXGroup; 233 | children = ( 234 | D0ABD75C2456993200840F37 /* WeatherTests.swift */, 235 | D0ABD75E2456993200840F37 /* Info.plist */, 236 | D07EF0B92457F5AD004189AC /* DaySummaryViewModelTests.swift */, 237 | D07EF0BD2457F742004189AC /* HourSummaryViewModelTests.swift */, 238 | D07EF0BF2457F869004189AC /* CurrentSummaryViewModelTests.swift */, 239 | D07EF0C12457FA12004189AC /* WeatherSummaryViewModelTests.swift */, 240 | ); 241 | path = WeatherTests; 242 | sourceTree = ""; 243 | }; 244 | D0ABD7672456993D00840F37 /* Resources */ = { 245 | isa = PBXGroup; 246 | children = ( 247 | D07EF0952456A6CC004189AC /* Mocks */, 248 | D0ABD74B2456993200840F37 /* Assets.xcassets */, 249 | D0ABD7502456993200840F37 /* LaunchScreen.storyboard */, 250 | D0ABD74D2456993200840F37 /* Preview Content */, 251 | ); 252 | path = Resources; 253 | sourceTree = ""; 254 | }; 255 | D0ABD7682456994A00840F37 /* Sources */ = { 256 | isa = PBXGroup; 257 | children = ( 258 | D07EF0902456A2BC004189AC /* DataManager */, 259 | D07EF0892456A095004189AC /* Models */, 260 | D07EF08E2456A29E004189AC /* Views */, 261 | D07EF0932456A30F004189AC /* Utilities */, 262 | ); 263 | path = Sources; 264 | sourceTree = ""; 265 | }; 266 | D0ABD7692456994D00840F37 /* Support */ = { 267 | isa = PBXGroup; 268 | children = ( 269 | D0ABD7452456993000840F37 /* AppDelegate.swift */, 270 | D0ABD7472456993000840F37 /* SceneDelegate.swift */, 271 | D0ABD7532456993200840F37 /* Info.plist */, 272 | D07EF09A24575FCB004189AC /* Secrets.plist */, 273 | ); 274 | path = Support; 275 | sourceTree = ""; 276 | }; 277 | /* End PBXGroup section */ 278 | 279 | /* Begin PBXNativeTarget section */ 280 | D0ABD7412456993000840F37 /* Weather */ = { 281 | isa = PBXNativeTarget; 282 | buildConfigurationList = D0ABD7612456993200840F37 /* Build configuration list for PBXNativeTarget "Weather" */; 283 | buildPhases = ( 284 | D0ABD73E2456993000840F37 /* Sources */, 285 | D0ABD73F2456993000840F37 /* Frameworks */, 286 | D0ABD7402456993000840F37 /* Resources */, 287 | ); 288 | buildRules = ( 289 | ); 290 | dependencies = ( 291 | ); 292 | name = Weather; 293 | productName = Weather; 294 | productReference = D0ABD7422456993000840F37 /* Weather.app */; 295 | productType = "com.apple.product-type.application"; 296 | }; 297 | D0ABD7572456993200840F37 /* WeatherTests */ = { 298 | isa = PBXNativeTarget; 299 | buildConfigurationList = D0ABD7642456993200840F37 /* Build configuration list for PBXNativeTarget "WeatherTests" */; 300 | buildPhases = ( 301 | D0ABD7542456993200840F37 /* Sources */, 302 | D0ABD7552456993200840F37 /* Frameworks */, 303 | D0ABD7562456993200840F37 /* Resources */, 304 | ); 305 | buildRules = ( 306 | ); 307 | dependencies = ( 308 | D0ABD75A2456993200840F37 /* PBXTargetDependency */, 309 | ); 310 | name = WeatherTests; 311 | productName = WeatherTests; 312 | productReference = D0ABD7582456993200840F37 /* WeatherTests.xctest */; 313 | productType = "com.apple.product-type.bundle.unit-test"; 314 | }; 315 | /* End PBXNativeTarget section */ 316 | 317 | /* Begin PBXProject section */ 318 | D0ABD73A2456993000840F37 /* Project object */ = { 319 | isa = PBXProject; 320 | attributes = { 321 | LastSwiftUpdateCheck = 1140; 322 | LastUpgradeCheck = 1140; 323 | ORGANIZATIONNAME = "Alex Fargo"; 324 | TargetAttributes = { 325 | D0ABD7412456993000840F37 = { 326 | CreatedOnToolsVersion = 11.4.1; 327 | }; 328 | D0ABD7572456993200840F37 = { 329 | CreatedOnToolsVersion = 11.4.1; 330 | TestTargetID = D0ABD7412456993000840F37; 331 | }; 332 | }; 333 | }; 334 | buildConfigurationList = D0ABD73D2456993000840F37 /* Build configuration list for PBXProject "Weather" */; 335 | compatibilityVersion = "Xcode 9.3"; 336 | developmentRegion = en; 337 | hasScannedForEncodings = 0; 338 | knownRegions = ( 339 | en, 340 | Base, 341 | ); 342 | mainGroup = D0ABD7392456993000840F37; 343 | productRefGroup = D0ABD7432456993000840F37 /* Products */; 344 | projectDirPath = ""; 345 | projectRoot = ""; 346 | targets = ( 347 | D0ABD7412456993000840F37 /* Weather */, 348 | D0ABD7572456993200840F37 /* WeatherTests */, 349 | ); 350 | }; 351 | /* End PBXProject section */ 352 | 353 | /* Begin PBXResourcesBuildPhase section */ 354 | D0ABD7402456993000840F37 /* Resources */ = { 355 | isa = PBXResourcesBuildPhase; 356 | buildActionMask = 2147483647; 357 | files = ( 358 | D0ABD7522456993200840F37 /* LaunchScreen.storyboard in Resources */, 359 | D07EF09B24575FCB004189AC /* Secrets.plist in Resources */, 360 | D0ABD74F2456993200840F37 /* Preview Assets.xcassets in Resources */, 361 | D0ABD74C2456993200840F37 /* Assets.xcassets in Resources */, 362 | D07EF0972456A6EE004189AC /* MockOneCallResponse.json in Resources */, 363 | ); 364 | runOnlyForDeploymentPostprocessing = 0; 365 | }; 366 | D0ABD7562456993200840F37 /* Resources */ = { 367 | isa = PBXResourcesBuildPhase; 368 | buildActionMask = 2147483647; 369 | files = ( 370 | ); 371 | runOnlyForDeploymentPostprocessing = 0; 372 | }; 373 | /* End PBXResourcesBuildPhase section */ 374 | 375 | /* Begin PBXSourcesBuildPhase section */ 376 | D0ABD73E2456993000840F37 /* Sources */ = { 377 | isa = PBXSourcesBuildPhase; 378 | buildActionMask = 2147483647; 379 | files = ( 380 | D07EF08B2456A0A4004189AC /* OneCallResponse.swift in Sources */, 381 | D07EF0AC2457D83F004189AC /* HourSummaryViewModel.swift in Sources */, 382 | D07EF0922456A2CA004189AC /* DataManager.swift in Sources */, 383 | D07EF09E24578D6B004189AC /* WeatherSummaryViewModel.swift in Sources */, 384 | D07EF0A92457C2E9004189AC /* MockWeatherSummary.swift in Sources */, 385 | D07EF0AE2457D897004189AC /* HourSummaryView.swift in Sources */, 386 | D0ABD7462456993000840F37 /* AppDelegate.swift in Sources */, 387 | D0ABD7482456993000840F37 /* SceneDelegate.swift in Sources */, 388 | D07EF0942456A383004189AC /* WeatherSummaryView.swift in Sources */, 389 | D07EF0A024578E00004189AC /* MockWeatherFetcher.swift in Sources */, 390 | D07EF0B12457E13B004189AC /* DaySummaryView.swift in Sources */, 391 | D07EF0B82457F203004189AC /* DismissKeyboard.swift in Sources */, 392 | D07EF0A62457C1A7004189AC /* CurrentSummaryView.swift in Sources */, 393 | D07EF0B32457E152004189AC /* DaySummaryViewModel.swift in Sources */, 394 | D07EF09D24576267004189AC /* WeatherSummary.swift in Sources */, 395 | D07EF09924575F3F004189AC /* WeatherError.swift in Sources */, 396 | D07EF0B62457ED40004189AC /* SearchBar.swift in Sources */, 397 | D07EF0A42457C196004189AC /* CurrentSummaryViewModel.swift in Sources */, 398 | ); 399 | runOnlyForDeploymentPostprocessing = 0; 400 | }; 401 | D0ABD7542456993200840F37 /* Sources */ = { 402 | isa = PBXSourcesBuildPhase; 403 | buildActionMask = 2147483647; 404 | files = ( 405 | D07EF0C22457FA12004189AC /* WeatherSummaryViewModelTests.swift in Sources */, 406 | D07EF0BA2457F5AD004189AC /* DaySummaryViewModelTests.swift in Sources */, 407 | D07EF0C02457F869004189AC /* CurrentSummaryViewModelTests.swift in Sources */, 408 | D0ABD75D2456993200840F37 /* WeatherTests.swift in Sources */, 409 | D07EF0BE2457F742004189AC /* HourSummaryViewModelTests.swift in Sources */, 410 | ); 411 | runOnlyForDeploymentPostprocessing = 0; 412 | }; 413 | /* End PBXSourcesBuildPhase section */ 414 | 415 | /* Begin PBXTargetDependency section */ 416 | D0ABD75A2456993200840F37 /* PBXTargetDependency */ = { 417 | isa = PBXTargetDependency; 418 | target = D0ABD7412456993000840F37 /* Weather */; 419 | targetProxy = D0ABD7592456993200840F37 /* PBXContainerItemProxy */; 420 | }; 421 | /* End PBXTargetDependency section */ 422 | 423 | /* Begin PBXVariantGroup section */ 424 | D0ABD7502456993200840F37 /* LaunchScreen.storyboard */ = { 425 | isa = PBXVariantGroup; 426 | children = ( 427 | D0ABD7512456993200840F37 /* Base */, 428 | ); 429 | name = LaunchScreen.storyboard; 430 | sourceTree = ""; 431 | }; 432 | /* End PBXVariantGroup section */ 433 | 434 | /* Begin XCBuildConfiguration section */ 435 | D0ABD75F2456993200840F37 /* Debug */ = { 436 | isa = XCBuildConfiguration; 437 | buildSettings = { 438 | ALWAYS_SEARCH_USER_PATHS = NO; 439 | CLANG_ANALYZER_NONNULL = YES; 440 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 441 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 442 | CLANG_CXX_LIBRARY = "libc++"; 443 | CLANG_ENABLE_MODULES = YES; 444 | CLANG_ENABLE_OBJC_ARC = YES; 445 | CLANG_ENABLE_OBJC_WEAK = YES; 446 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 447 | CLANG_WARN_BOOL_CONVERSION = YES; 448 | CLANG_WARN_COMMA = YES; 449 | CLANG_WARN_CONSTANT_CONVERSION = YES; 450 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 451 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 452 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 453 | CLANG_WARN_EMPTY_BODY = YES; 454 | CLANG_WARN_ENUM_CONVERSION = YES; 455 | CLANG_WARN_INFINITE_RECURSION = YES; 456 | CLANG_WARN_INT_CONVERSION = YES; 457 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 458 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 459 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 460 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 461 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 462 | CLANG_WARN_STRICT_PROTOTYPES = YES; 463 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 464 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 465 | CLANG_WARN_UNREACHABLE_CODE = YES; 466 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 467 | COPY_PHASE_STRIP = NO; 468 | DEBUG_INFORMATION_FORMAT = dwarf; 469 | ENABLE_STRICT_OBJC_MSGSEND = YES; 470 | ENABLE_TESTABILITY = YES; 471 | GCC_C_LANGUAGE_STANDARD = gnu11; 472 | GCC_DYNAMIC_NO_PIC = NO; 473 | GCC_NO_COMMON_BLOCKS = YES; 474 | GCC_OPTIMIZATION_LEVEL = 0; 475 | GCC_PREPROCESSOR_DEFINITIONS = ( 476 | "DEBUG=1", 477 | "$(inherited)", 478 | ); 479 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 480 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 481 | GCC_WARN_UNDECLARED_SELECTOR = YES; 482 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 483 | GCC_WARN_UNUSED_FUNCTION = YES; 484 | GCC_WARN_UNUSED_VARIABLE = YES; 485 | IPHONEOS_DEPLOYMENT_TARGET = 13.4; 486 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 487 | MTL_FAST_MATH = YES; 488 | ONLY_ACTIVE_ARCH = YES; 489 | SDKROOT = iphoneos; 490 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 491 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 492 | }; 493 | name = Debug; 494 | }; 495 | D0ABD7602456993200840F37 /* Release */ = { 496 | isa = XCBuildConfiguration; 497 | buildSettings = { 498 | ALWAYS_SEARCH_USER_PATHS = NO; 499 | CLANG_ANALYZER_NONNULL = YES; 500 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 501 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 502 | CLANG_CXX_LIBRARY = "libc++"; 503 | CLANG_ENABLE_MODULES = YES; 504 | CLANG_ENABLE_OBJC_ARC = YES; 505 | CLANG_ENABLE_OBJC_WEAK = YES; 506 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 507 | CLANG_WARN_BOOL_CONVERSION = YES; 508 | CLANG_WARN_COMMA = YES; 509 | CLANG_WARN_CONSTANT_CONVERSION = YES; 510 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 511 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 512 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 513 | CLANG_WARN_EMPTY_BODY = YES; 514 | CLANG_WARN_ENUM_CONVERSION = YES; 515 | CLANG_WARN_INFINITE_RECURSION = YES; 516 | CLANG_WARN_INT_CONVERSION = YES; 517 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 518 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 519 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 520 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 521 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 522 | CLANG_WARN_STRICT_PROTOTYPES = YES; 523 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 524 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 525 | CLANG_WARN_UNREACHABLE_CODE = YES; 526 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 527 | COPY_PHASE_STRIP = NO; 528 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 529 | ENABLE_NS_ASSERTIONS = NO; 530 | ENABLE_STRICT_OBJC_MSGSEND = YES; 531 | GCC_C_LANGUAGE_STANDARD = gnu11; 532 | GCC_NO_COMMON_BLOCKS = YES; 533 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 534 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 535 | GCC_WARN_UNDECLARED_SELECTOR = YES; 536 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 537 | GCC_WARN_UNUSED_FUNCTION = YES; 538 | GCC_WARN_UNUSED_VARIABLE = YES; 539 | IPHONEOS_DEPLOYMENT_TARGET = 13.4; 540 | MTL_ENABLE_DEBUG_INFO = NO; 541 | MTL_FAST_MATH = YES; 542 | SDKROOT = iphoneos; 543 | SWIFT_COMPILATION_MODE = wholemodule; 544 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 545 | VALIDATE_PRODUCT = YES; 546 | }; 547 | name = Release; 548 | }; 549 | D0ABD7622456993200840F37 /* Debug */ = { 550 | isa = XCBuildConfiguration; 551 | buildSettings = { 552 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 553 | CODE_SIGN_STYLE = Automatic; 554 | DEVELOPMENT_ASSET_PATHS = "\"Weather/Resources/Preview Content\""; 555 | DEVELOPMENT_TEAM = AEXVC7BMBG; 556 | ENABLE_PREVIEWS = YES; 557 | INFOPLIST_FILE = Weather/Support/Info.plist; 558 | LD_RUNPATH_SEARCH_PATHS = ( 559 | "$(inherited)", 560 | "@executable_path/Frameworks", 561 | ); 562 | PRODUCT_BUNDLE_IDENTIFIER = xyz.alexfargo.Weather; 563 | PRODUCT_NAME = "$(TARGET_NAME)"; 564 | SWIFT_VERSION = 5.0; 565 | TARGETED_DEVICE_FAMILY = "1,2"; 566 | }; 567 | name = Debug; 568 | }; 569 | D0ABD7632456993200840F37 /* Release */ = { 570 | isa = XCBuildConfiguration; 571 | buildSettings = { 572 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 573 | CODE_SIGN_STYLE = Automatic; 574 | DEVELOPMENT_ASSET_PATHS = "\"Weather/Resources/Preview Content\""; 575 | DEVELOPMENT_TEAM = AEXVC7BMBG; 576 | ENABLE_PREVIEWS = YES; 577 | INFOPLIST_FILE = Weather/Support/Info.plist; 578 | LD_RUNPATH_SEARCH_PATHS = ( 579 | "$(inherited)", 580 | "@executable_path/Frameworks", 581 | ); 582 | PRODUCT_BUNDLE_IDENTIFIER = xyz.alexfargo.Weather; 583 | PRODUCT_NAME = "$(TARGET_NAME)"; 584 | SWIFT_VERSION = 5.0; 585 | TARGETED_DEVICE_FAMILY = "1,2"; 586 | }; 587 | name = Release; 588 | }; 589 | D0ABD7652456993200840F37 /* Debug */ = { 590 | isa = XCBuildConfiguration; 591 | buildSettings = { 592 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 593 | BUNDLE_LOADER = "$(TEST_HOST)"; 594 | CODE_SIGN_STYLE = Automatic; 595 | DEVELOPMENT_TEAM = AEXVC7BMBG; 596 | INFOPLIST_FILE = WeatherTests/Info.plist; 597 | IPHONEOS_DEPLOYMENT_TARGET = 13.4; 598 | LD_RUNPATH_SEARCH_PATHS = ( 599 | "$(inherited)", 600 | "@executable_path/Frameworks", 601 | "@loader_path/Frameworks", 602 | ); 603 | PRODUCT_BUNDLE_IDENTIFIER = xyz.alexfargo.WeatherTests; 604 | PRODUCT_NAME = "$(TARGET_NAME)"; 605 | SWIFT_VERSION = 5.0; 606 | TARGETED_DEVICE_FAMILY = "1,2"; 607 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Weather.app/Weather"; 608 | }; 609 | name = Debug; 610 | }; 611 | D0ABD7662456993200840F37 /* Release */ = { 612 | isa = XCBuildConfiguration; 613 | buildSettings = { 614 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 615 | BUNDLE_LOADER = "$(TEST_HOST)"; 616 | CODE_SIGN_STYLE = Automatic; 617 | DEVELOPMENT_TEAM = AEXVC7BMBG; 618 | INFOPLIST_FILE = WeatherTests/Info.plist; 619 | IPHONEOS_DEPLOYMENT_TARGET = 13.4; 620 | LD_RUNPATH_SEARCH_PATHS = ( 621 | "$(inherited)", 622 | "@executable_path/Frameworks", 623 | "@loader_path/Frameworks", 624 | ); 625 | PRODUCT_BUNDLE_IDENTIFIER = xyz.alexfargo.WeatherTests; 626 | PRODUCT_NAME = "$(TARGET_NAME)"; 627 | SWIFT_VERSION = 5.0; 628 | TARGETED_DEVICE_FAMILY = "1,2"; 629 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Weather.app/Weather"; 630 | }; 631 | name = Release; 632 | }; 633 | /* End XCBuildConfiguration section */ 634 | 635 | /* Begin XCConfigurationList section */ 636 | D0ABD73D2456993000840F37 /* Build configuration list for PBXProject "Weather" */ = { 637 | isa = XCConfigurationList; 638 | buildConfigurations = ( 639 | D0ABD75F2456993200840F37 /* Debug */, 640 | D0ABD7602456993200840F37 /* Release */, 641 | ); 642 | defaultConfigurationIsVisible = 0; 643 | defaultConfigurationName = Release; 644 | }; 645 | D0ABD7612456993200840F37 /* Build configuration list for PBXNativeTarget "Weather" */ = { 646 | isa = XCConfigurationList; 647 | buildConfigurations = ( 648 | D0ABD7622456993200840F37 /* Debug */, 649 | D0ABD7632456993200840F37 /* Release */, 650 | ); 651 | defaultConfigurationIsVisible = 0; 652 | defaultConfigurationName = Release; 653 | }; 654 | D0ABD7642456993200840F37 /* Build configuration list for PBXNativeTarget "WeatherTests" */ = { 655 | isa = XCConfigurationList; 656 | buildConfigurations = ( 657 | D0ABD7652456993200840F37 /* Debug */, 658 | D0ABD7662456993200840F37 /* Release */, 659 | ); 660 | defaultConfigurationIsVisible = 0; 661 | defaultConfigurationName = Release; 662 | }; 663 | /* End XCConfigurationList section */ 664 | }; 665 | rootObject = D0ABD73A2456993000840F37 /* Project object */; 666 | } 667 | -------------------------------------------------------------------------------- /Weather.xcodeproj/xcshareddata/xcschemes/Weather.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 34 | 40 | 41 | 42 | 43 | 44 | 54 | 56 | 62 | 63 | 64 | 65 | 71 | 73 | 79 | 80 | 81 | 82 | 84 | 85 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /Weather/Resources/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/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Weather/Resources/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/Resources/Mocks/MockOneCallResponse.json: -------------------------------------------------------------------------------- 1 | { 2 | "lat": 34.15, 3 | "lon": -118.14, 4 | "timezone": "America/Los_Angeles", 5 | "current": { 6 | "dt": 1588013546, 7 | "sunrise": 1587992793, 8 | "sunset": 1588041202, 9 | "temp": 299.04, 10 | "feels_like": 299.39, 11 | "pressure": 1015, 12 | "humidity": 53, 13 | "dew_point": 288.74, 14 | "uvi": 9.72, 15 | "clouds": 1, 16 | "visibility": 12874, 17 | "wind_speed": 2.1, 18 | "wind_deg": 200, 19 | "weather": [ 20 | { 21 | "id": 800, 22 | "main": "Clear", 23 | "description": "clear sky", 24 | "icon": "01d" 25 | } 26 | ] 27 | }, 28 | "hourly": [ 29 | { 30 | "dt": 1588010400, 31 | "temp": 299.04, 32 | "feels_like": 299, 33 | "pressure": 1015, 34 | "humidity": 53, 35 | "dew_point": 288.74, 36 | "clouds": 1, 37 | "wind_speed": 2.66, 38 | "wind_deg": 221, 39 | "weather": [ 40 | { 41 | "id": 800, 42 | "main": "Clear", 43 | "description": "clear sky", 44 | "icon": "01d" 45 | } 46 | ] 47 | }, 48 | { 49 | "dt": 1588014000, 50 | "temp": 299.94, 51 | "feels_like": 298.38, 52 | "pressure": 1015, 53 | "humidity": 40, 54 | "dew_point": 285.22, 55 | "clouds": 1, 56 | "wind_speed": 3.14, 57 | "wind_deg": 220, 58 | "weather": [ 59 | { 60 | "id": 800, 61 | "main": "Clear", 62 | "description": "clear sky", 63 | "icon": "01d" 64 | } 65 | ] 66 | }, 67 | { 68 | "dt": 1588017600, 69 | "temp": 301.16, 70 | "feels_like": 298.65, 71 | "pressure": 1014, 72 | "humidity": 31, 73 | "dew_point": 282.47, 74 | "clouds": 0, 75 | "wind_speed": 3.38, 76 | "wind_deg": 218, 77 | "weather": [ 78 | { 79 | "id": 800, 80 | "main": "Clear", 81 | "description": "clear sky", 82 | "icon": "01d" 83 | } 84 | ] 85 | }, 86 | { 87 | "dt": 1588021200, 88 | "temp": 302.23, 89 | "feels_like": 299.09, 90 | "pressure": 1014, 91 | "humidity": 25, 92 | "dew_point": 280.22, 93 | "clouds": 0, 94 | "wind_speed": 3.5, 95 | "wind_deg": 220, 96 | "weather": [ 97 | { 98 | "id": 800, 99 | "main": "Clear", 100 | "description": "clear sky", 101 | "icon": "01d" 102 | } 103 | ] 104 | }, 105 | { 106 | "dt": 1588024800, 107 | "temp": 302.58, 108 | "feels_like": 299.27, 109 | "pressure": 1014, 110 | "humidity": 24, 111 | "dew_point": 279.92, 112 | "clouds": 0, 113 | "wind_speed": 3.64, 114 | "wind_deg": 221, 115 | "weather": [ 116 | { 117 | "id": 800, 118 | "main": "Clear", 119 | "description": "clear sky", 120 | "icon": "01d" 121 | } 122 | ] 123 | }, 124 | { 125 | "dt": 1588028400, 126 | "temp": 302.11, 127 | "feels_like": 298.9, 128 | "pressure": 1013, 129 | "humidity": 26, 130 | "dew_point": 282, 131 | "clouds": 0, 132 | "wind_speed": 3.75, 133 | "wind_deg": 221, 134 | "weather": [ 135 | { 136 | "id": 800, 137 | "main": "Clear", 138 | "description": "clear sky", 139 | "icon": "01d" 140 | } 141 | ] 142 | }, 143 | { 144 | "dt": 1588032000, 145 | "temp": 301.21, 146 | "feels_like": 298.32, 147 | "pressure": 1013, 148 | "humidity": 29, 149 | "dew_point": 282.57, 150 | "clouds": 0, 151 | "wind_speed": 3.58, 152 | "wind_deg": 221, 153 | "weather": [ 154 | { 155 | "id": 800, 156 | "main": "Clear", 157 | "description": "clear sky", 158 | "icon": "01d" 159 | } 160 | ] 161 | }, 162 | { 163 | "dt": 1588035600, 164 | "temp": 299.97, 165 | "feels_like": 297.49, 166 | "pressure": 1013, 167 | "humidity": 32, 168 | "dew_point": 283.06, 169 | "clouds": 0, 170 | "wind_speed": 3.14, 171 | "wind_deg": 218, 172 | "weather": [ 173 | { 174 | "id": 800, 175 | "main": "Clear", 176 | "description": "clear sky", 177 | "icon": "01d" 178 | } 179 | ] 180 | }, 181 | { 182 | "dt": 1588039200, 183 | "temp": 298.27, 184 | "feels_like": 296.56, 185 | "pressure": 1013, 186 | "humidity": 37, 187 | "dew_point": 283.68, 188 | "clouds": 0, 189 | "wind_speed": 2.28, 190 | "wind_deg": 218, 191 | "weather": [ 192 | { 193 | "id": 800, 194 | "main": "Clear", 195 | "description": "clear sky", 196 | "icon": "01d" 197 | } 198 | ] 199 | }, 200 | { 201 | "dt": 1588042800, 202 | "temp": 296.58, 203 | "feels_like": 295.46, 204 | "pressure": 1014, 205 | "humidity": 39, 206 | "dew_point": 283.26, 207 | "clouds": 0, 208 | "wind_speed": 1.17, 209 | "wind_deg": 216, 210 | "weather": [ 211 | { 212 | "id": 800, 213 | "main": "Clear", 214 | "description": "clear sky", 215 | "icon": "01n" 216 | } 217 | ] 218 | }, 219 | { 220 | "dt": 1588046400, 221 | "temp": 296.19, 222 | "feels_like": 295.47, 223 | "pressure": 1015, 224 | "humidity": 39, 225 | "dew_point": 282.64, 226 | "clouds": 0, 227 | "wind_speed": 0.48, 228 | "wind_deg": 186, 229 | "weather": [ 230 | { 231 | "id": 800, 232 | "main": "Clear", 233 | "description": "clear sky", 234 | "icon": "01n" 235 | } 236 | ] 237 | }, 238 | { 239 | "dt": 1588050000, 240 | "temp": 295.83, 241 | "feels_like": 295.07, 242 | "pressure": 1015, 243 | "humidity": 38, 244 | "dew_point": 281.97, 245 | "clouds": 0, 246 | "wind_speed": 0.3, 247 | "wind_deg": 143, 248 | "weather": [ 249 | { 250 | "id": 800, 251 | "main": "Clear", 252 | "description": "clear sky", 253 | "icon": "01n" 254 | } 255 | ] 256 | }, 257 | { 258 | "dt": 1588053600, 259 | "temp": 295.38, 260 | "feels_like": 294.48, 261 | "pressure": 1015, 262 | "humidity": 38, 263 | "dew_point": 281.48, 264 | "clouds": 0, 265 | "wind_speed": 0.36, 266 | "wind_deg": 125, 267 | "weather": [ 268 | { 269 | "id": 800, 270 | "main": "Clear", 271 | "description": "clear sky", 272 | "icon": "01n" 273 | } 274 | ] 275 | }, 276 | { 277 | "dt": 1588057200, 278 | "temp": 295.05, 279 | "feels_like": 294.13, 280 | "pressure": 1015, 281 | "humidity": 37, 282 | "dew_point": 281, 283 | "clouds": 0, 284 | "wind_speed": 0.17, 285 | "wind_deg": 95, 286 | "weather": [ 287 | { 288 | "id": 800, 289 | "main": "Clear", 290 | "description": "clear sky", 291 | "icon": "01n" 292 | } 293 | ] 294 | }, 295 | { 296 | "dt": 1588060800, 297 | "temp": 294.71, 298 | "feels_like": 293.76, 299 | "pressure": 1015, 300 | "humidity": 37, 301 | "dew_point": 280.66, 302 | "clouds": 0, 303 | "wind_speed": 0.12, 304 | "wind_deg": 149, 305 | "weather": [ 306 | { 307 | "id": 800, 308 | "main": "Clear", 309 | "description": "clear sky", 310 | "icon": "01n" 311 | } 312 | ] 313 | }, 314 | { 315 | "dt": 1588064400, 316 | "temp": 294.29, 317 | "feels_like": 293.29, 318 | "pressure": 1015, 319 | "humidity": 38, 320 | "dew_point": 280.47, 321 | "clouds": 0, 322 | "wind_speed": 0.19, 323 | "wind_deg": 195, 324 | "weather": [ 325 | { 326 | "id": 800, 327 | "main": "Clear", 328 | "description": "clear sky", 329 | "icon": "01n" 330 | } 331 | ] 332 | }, 333 | { 334 | "dt": 1588068000, 335 | "temp": 293.92, 336 | "feels_like": 292.84, 337 | "pressure": 1014, 338 | "humidity": 38, 339 | "dew_point": 280.36, 340 | "clouds": 0, 341 | "wind_speed": 0.21, 342 | "wind_deg": 209, 343 | "weather": [ 344 | { 345 | "id": 800, 346 | "main": "Clear", 347 | "description": "clear sky", 348 | "icon": "01n" 349 | } 350 | ] 351 | }, 352 | { 353 | "dt": 1588071600, 354 | "temp": 293.7, 355 | "feels_like": 292.65, 356 | "pressure": 1014, 357 | "humidity": 38, 358 | "dew_point": 280.26, 359 | "clouds": 0, 360 | "wind_speed": 0.11, 361 | "wind_deg": 86, 362 | "weather": [ 363 | { 364 | "id": 800, 365 | "main": "Clear", 366 | "description": "clear sky", 367 | "icon": "01n" 368 | } 369 | ] 370 | }, 371 | { 372 | "dt": 1588075200, 373 | "temp": 293.57, 374 | "feels_like": 292.35, 375 | "pressure": 1014, 376 | "humidity": 38, 377 | "dew_point": 280.16, 378 | "clouds": 0, 379 | "wind_speed": 0.32, 380 | "wind_deg": 73, 381 | "weather": [ 382 | { 383 | "id": 800, 384 | "main": "Clear", 385 | "description": "clear sky", 386 | "icon": "01n" 387 | } 388 | ] 389 | }, 390 | { 391 | "dt": 1588078800, 392 | "temp": 293.48, 393 | "feels_like": 292.32, 394 | "pressure": 1014, 395 | "humidity": 38, 396 | "dew_point": 280.11, 397 | "clouds": 0, 398 | "wind_speed": 0.2, 399 | "wind_deg": 27, 400 | "weather": [ 401 | { 402 | "id": 800, 403 | "main": "Clear", 404 | "description": "clear sky", 405 | "icon": "01n" 406 | } 407 | ] 408 | }, 409 | { 410 | "dt": 1588082400, 411 | "temp": 294.26, 412 | "feels_like": 293.23, 413 | "pressure": 1015, 414 | "humidity": 38, 415 | "dew_point": 280.79, 416 | "clouds": 0, 417 | "wind_speed": 0.23, 418 | "wind_deg": 337, 419 | "weather": [ 420 | { 421 | "id": 800, 422 | "main": "Clear", 423 | "description": "clear sky", 424 | "icon": "01d" 425 | } 426 | ] 427 | }, 428 | { 429 | "dt": 1588086000, 430 | "temp": 296.45, 431 | "feels_like": 295.29, 432 | "pressure": 1015, 433 | "humidity": 34, 434 | "dew_point": 281.14, 435 | "clouds": 0, 436 | "wind_speed": 0.52, 437 | "wind_deg": 224, 438 | "weather": [ 439 | { 440 | "id": 800, 441 | "main": "Clear", 442 | "description": "clear sky", 443 | "icon": "01d" 444 | } 445 | ] 446 | }, 447 | { 448 | "dt": 1588089600, 449 | "temp": 298.42, 450 | "feels_like": 296.64, 451 | "pressure": 1015, 452 | "humidity": 30, 453 | "dew_point": 281.03, 454 | "clouds": 0, 455 | "wind_speed": 1.37, 456 | "wind_deg": 228, 457 | "weather": [ 458 | { 459 | "id": 800, 460 | "main": "Clear", 461 | "description": "clear sky", 462 | "icon": "01d" 463 | } 464 | ] 465 | }, 466 | { 467 | "dt": 1588093200, 468 | "temp": 300.03, 469 | "feels_like": 297.97, 470 | "pressure": 1015, 471 | "humidity": 28, 472 | "dew_point": 281.06, 473 | "clouds": 0, 474 | "wind_speed": 1.88, 475 | "wind_deg": 223, 476 | "weather": [ 477 | { 478 | "id": 800, 479 | "main": "Clear", 480 | "description": "clear sky", 481 | "icon": "01d" 482 | } 483 | ] 484 | }, 485 | { 486 | "dt": 1588096800, 487 | "temp": 301.57, 488 | "feels_like": 299.16, 489 | "pressure": 1015, 490 | "humidity": 25, 491 | "dew_point": 281.01, 492 | "clouds": 0, 493 | "wind_speed": 2.28, 494 | "wind_deg": 223, 495 | "weather": [ 496 | { 497 | "id": 800, 498 | "main": "Clear", 499 | "description": "clear sky", 500 | "icon": "01d" 501 | } 502 | ] 503 | }, 504 | { 505 | "dt": 1588100400, 506 | "temp": 303.01, 507 | "feels_like": 300.25, 508 | "pressure": 1015, 509 | "humidity": 23, 510 | "dew_point": 280.9, 511 | "clouds": 0, 512 | "wind_speed": 2.78, 513 | "wind_deg": 222, 514 | "weather": [ 515 | { 516 | "id": 800, 517 | "main": "Clear", 518 | "description": "clear sky", 519 | "icon": "01d" 520 | } 521 | ] 522 | }, 523 | { 524 | "dt": 1588104000, 525 | "temp": 303.9, 526 | "feels_like": 300.77, 527 | "pressure": 1014, 528 | "humidity": 22, 529 | "dew_point": 281.11, 530 | "clouds": 0, 531 | "wind_speed": 3.34, 532 | "wind_deg": 223, 533 | "weather": [ 534 | { 535 | "id": 800, 536 | "main": "Clear", 537 | "description": "clear sky", 538 | "icon": "01d" 539 | } 540 | ] 541 | }, 542 | { 543 | "dt": 1588107600, 544 | "temp": 304.31, 545 | "feels_like": 301.09, 546 | "pressure": 1014, 547 | "humidity": 22, 548 | "dew_point": 281.43, 549 | "clouds": 0, 550 | "wind_speed": 3.57, 551 | "wind_deg": 223, 552 | "weather": [ 553 | { 554 | "id": 800, 555 | "main": "Clear", 556 | "description": "clear sky", 557 | "icon": "01d" 558 | } 559 | ] 560 | }, 561 | { 562 | "dt": 1588111200, 563 | "temp": 304.36, 564 | "feels_like": 301.09, 565 | "pressure": 1014, 566 | "humidity": 22, 567 | "dew_point": 281.64, 568 | "clouds": 0, 569 | "wind_speed": 3.66, 570 | "wind_deg": 223, 571 | "weather": [ 572 | { 573 | "id": 800, 574 | "main": "Clear", 575 | "description": "clear sky", 576 | "icon": "01d" 577 | } 578 | ] 579 | }, 580 | { 581 | "dt": 1588114800, 582 | "temp": 304, 583 | "feels_like": 300.86, 584 | "pressure": 1013, 585 | "humidity": 23, 586 | "dew_point": 281.76, 587 | "clouds": 0, 588 | "wind_speed": 3.58, 589 | "wind_deg": 227, 590 | "weather": [ 591 | { 592 | "id": 800, 593 | "main": "Clear", 594 | "description": "clear sky", 595 | "icon": "01d" 596 | } 597 | ] 598 | }, 599 | { 600 | "dt": 1588118400, 601 | "temp": 303.34, 602 | "feels_like": 300.43, 603 | "pressure": 1013, 604 | "humidity": 24, 605 | "dew_point": 281.92, 606 | "clouds": 0, 607 | "wind_speed": 3.28, 608 | "wind_deg": 224, 609 | "weather": [ 610 | { 611 | "id": 800, 612 | "main": "Clear", 613 | "description": "clear sky", 614 | "icon": "01d" 615 | } 616 | ] 617 | }, 618 | { 619 | "dt": 1588122000, 620 | "temp": 302.12, 621 | "feels_like": 299.6, 622 | "pressure": 1012, 623 | "humidity": 27, 624 | "dew_point": 282.33, 625 | "clouds": 0, 626 | "wind_speed": 2.96, 627 | "wind_deg": 216, 628 | "weather": [ 629 | { 630 | "id": 800, 631 | "main": "Clear", 632 | "description": "clear sky", 633 | "icon": "01d" 634 | } 635 | ] 636 | }, 637 | { 638 | "dt": 1588125600, 639 | "temp": 299.96, 640 | "feels_like": 298.11, 641 | "pressure": 1013, 642 | "humidity": 32, 643 | "dew_point": 283.07, 644 | "clouds": 0, 645 | "wind_speed": 2.23, 646 | "wind_deg": 209, 647 | "weather": [ 648 | { 649 | "id": 800, 650 | "main": "Clear", 651 | "description": "clear sky", 652 | "icon": "01d" 653 | } 654 | ] 655 | }, 656 | { 657 | "dt": 1588129200, 658 | "temp": 298.14, 659 | "feels_like": 296.82, 660 | "pressure": 1013, 661 | "humidity": 35, 662 | "dew_point": 282.68, 663 | "clouds": 0, 664 | "wind_speed": 1.38, 665 | "wind_deg": 185, 666 | "weather": [ 667 | { 668 | "id": 800, 669 | "main": "Clear", 670 | "description": "clear sky", 671 | "icon": "01n" 672 | } 673 | ] 674 | }, 675 | { 676 | "dt": 1588132800, 677 | "temp": 297.5, 678 | "feels_like": 296.38, 679 | "pressure": 1014, 680 | "humidity": 36, 681 | "dew_point": 282.64, 682 | "clouds": 0, 683 | "wind_speed": 1.04, 684 | "wind_deg": 175, 685 | "weather": [ 686 | { 687 | "id": 800, 688 | "main": "Clear", 689 | "description": "clear sky", 690 | "icon": "01n" 691 | } 692 | ] 693 | }, 694 | { 695 | "dt": 1588136400, 696 | "temp": 297.09, 697 | "feels_like": 296.18, 698 | "pressure": 1015, 699 | "humidity": 37, 700 | "dew_point": 282.57, 701 | "clouds": 0, 702 | "wind_speed": 0.75, 703 | "wind_deg": 157, 704 | "weather": [ 705 | { 706 | "id": 800, 707 | "main": "Clear", 708 | "description": "clear sky", 709 | "icon": "01n" 710 | } 711 | ] 712 | }, 713 | { 714 | "dt": 1588140000, 715 | "temp": 296.84, 716 | "feels_like": 295.94, 717 | "pressure": 1015, 718 | "humidity": 37, 719 | "dew_point": 282.47, 720 | "clouds": 0, 721 | "wind_speed": 0.66, 722 | "wind_deg": 162, 723 | "weather": [ 724 | { 725 | "id": 800, 726 | "main": "Clear", 727 | "description": "clear sky", 728 | "icon": "01n" 729 | } 730 | ] 731 | }, 732 | { 733 | "dt": 1588143600, 734 | "temp": 296.31, 735 | "feels_like": 295.48, 736 | "pressure": 1015, 737 | "humidity": 38, 738 | "dew_point": 282.39, 739 | "clouds": 0, 740 | "wind_speed": 0.54, 741 | "wind_deg": 198, 742 | "weather": [ 743 | { 744 | "id": 800, 745 | "main": "Clear", 746 | "description": "clear sky", 747 | "icon": "01n" 748 | } 749 | ] 750 | }, 751 | { 752 | "dt": 1588147200, 753 | "temp": 295.97, 754 | "feels_like": 295.2, 755 | "pressure": 1014, 756 | "humidity": 38, 757 | "dew_point": 282.2, 758 | "clouds": 0, 759 | "wind_speed": 0.35, 760 | "wind_deg": 211, 761 | "weather": [ 762 | { 763 | "id": 800, 764 | "main": "Clear", 765 | "description": "clear sky", 766 | "icon": "01n" 767 | } 768 | ] 769 | }, 770 | { 771 | "dt": 1588150800, 772 | "temp": 295.69, 773 | "feels_like": 294.62, 774 | "pressure": 1013, 775 | "humidity": 38, 776 | "dew_point": 281.89, 777 | "clouds": 0, 778 | "wind_speed": 0.7, 779 | "wind_deg": 244, 780 | "weather": [ 781 | { 782 | "id": 800, 783 | "main": "Clear", 784 | "description": "clear sky", 785 | "icon": "01n" 786 | } 787 | ] 788 | }, 789 | { 790 | "dt": 1588154400, 791 | "temp": 295.36, 792 | "feels_like": 294.53, 793 | "pressure": 1013, 794 | "humidity": 38, 795 | "dew_point": 281.62, 796 | "clouds": 0, 797 | "wind_speed": 0.25, 798 | "wind_deg": 235, 799 | "weather": [ 800 | { 801 | "id": 800, 802 | "main": "Clear", 803 | "description": "clear sky", 804 | "icon": "01n" 805 | } 806 | ] 807 | }, 808 | { 809 | "dt": 1588158000, 810 | "temp": 295, 811 | "feels_like": 293.74, 812 | "pressure": 1013, 813 | "humidity": 38, 814 | "dew_point": 281.41, 815 | "clouds": 0, 816 | "wind_speed": 0.76, 817 | "wind_deg": 246, 818 | "weather": [ 819 | { 820 | "id": 800, 821 | "main": "Clear", 822 | "description": "clear sky", 823 | "icon": "01n" 824 | } 825 | ] 826 | }, 827 | { 828 | "dt": 1588161600, 829 | "temp": 295.01, 830 | "feels_like": 294.1, 831 | "pressure": 1013, 832 | "humidity": 37, 833 | "dew_point": 281.05, 834 | "clouds": 1, 835 | "wind_speed": 0.14, 836 | "wind_deg": 291, 837 | "weather": [ 838 | { 839 | "id": 800, 840 | "main": "Clear", 841 | "description": "clear sky", 842 | "icon": "01n" 843 | } 844 | ] 845 | }, 846 | { 847 | "dt": 1588165200, 848 | "temp": 294.76, 849 | "feels_like": 293.76, 850 | "pressure": 1013, 851 | "humidity": 37, 852 | "dew_point": 280.86, 853 | "clouds": 32, 854 | "wind_speed": 0.2, 855 | "wind_deg": 253, 856 | "weather": [ 857 | { 858 | "id": 802, 859 | "main": "Clouds", 860 | "description": "scattered clouds", 861 | "icon": "03n" 862 | } 863 | ] 864 | }, 865 | { 866 | "dt": 1588168800, 867 | "temp": 295.53, 868 | "feels_like": 294.54, 869 | "pressure": 1014, 870 | "humidity": 37, 871 | "dew_point": 281.56, 872 | "clouds": 66, 873 | "wind_speed": 0.4, 874 | "wind_deg": 193, 875 | "weather": [ 876 | { 877 | "id": 803, 878 | "main": "Clouds", 879 | "description": "broken clouds", 880 | "icon": "04d" 881 | } 882 | ] 883 | }, 884 | { 885 | "dt": 1588172400, 886 | "temp": 297.4, 887 | "feels_like": 296.15, 888 | "pressure": 1014, 889 | "humidity": 34, 890 | "dew_point": 281.81, 891 | "clouds": 76, 892 | "wind_speed": 0.91, 893 | "wind_deg": 142, 894 | "weather": [ 895 | { 896 | "id": 803, 897 | "main": "Clouds", 898 | "description": "broken clouds", 899 | "icon": "04d" 900 | } 901 | ] 902 | }, 903 | { 904 | "dt": 1588176000, 905 | "temp": 299.11, 906 | "feels_like": 297.33, 907 | "pressure": 1014, 908 | "humidity": 31, 909 | "dew_point": 281.96, 910 | "clouds": 77, 911 | "wind_speed": 1.71, 912 | "wind_deg": 210, 913 | "weather": [ 914 | { 915 | "id": 803, 916 | "main": "Clouds", 917 | "description": "broken clouds", 918 | "icon": "04d" 919 | } 920 | ] 921 | }, 922 | { 923 | "dt": 1588179600, 924 | "temp": 300.46, 925 | "feels_like": 298.49, 926 | "pressure": 1014, 927 | "humidity": 29, 928 | "dew_point": 282.19, 929 | "clouds": 75, 930 | "wind_speed": 2.05, 931 | "wind_deg": 208, 932 | "weather": [ 933 | { 934 | "id": 803, 935 | "main": "Clouds", 936 | "description": "broken clouds", 937 | "icon": "04d" 938 | } 939 | ] 940 | } 941 | ], 942 | "daily": [ 943 | { 944 | "dt": 1588014000, 945 | "sunrise": 1587992793, 946 | "sunset": 1588041202, 947 | "temp": { 948 | "day": 299.04, 949 | "min": 295.41, 950 | "max": 300.74, 951 | "night": 295.41, 952 | "eve": 300.74, 953 | "morn": 299.04 954 | }, 955 | "feels_like": { 956 | "day": 299, 957 | "night": 294.52, 958 | "eve": 298.36, 959 | "morn": 299 960 | }, 961 | "pressure": 1015, 962 | "humidity": 53, 963 | "dew_point": 288.74, 964 | "wind_speed": 2.66, 965 | "wind_deg": 221, 966 | "weather": [ 967 | { 968 | "id": 800, 969 | "main": "Clear", 970 | "description": "clear sky", 971 | "icon": "01d" 972 | } 973 | ], 974 | "clouds": 1, 975 | "uvi": 9.72 976 | }, 977 | { 978 | "dt": 1588100400, 979 | "sunrise": 1588079130, 980 | "sunset": 1588127650, 981 | "temp": { 982 | "day": 301.57, 983 | "min": 293.57, 984 | "max": 304.31, 985 | "night": 296.84, 986 | "eve": 303.34, 987 | "morn": 293.57 988 | }, 989 | "feels_like": { 990 | "day": 299.16, 991 | "night": 295.94, 992 | "eve": 300.43, 993 | "morn": 292.35 994 | }, 995 | "pressure": 1015, 996 | "humidity": 25, 997 | "dew_point": 281.01, 998 | "wind_speed": 2.28, 999 | "wind_deg": 223, 1000 | "weather": [ 1001 | { 1002 | "id": 800, 1003 | "main": "Clear", 1004 | "description": "clear sky", 1005 | "icon": "01d" 1006 | } 1007 | ], 1008 | "clouds": 0, 1009 | "uvi": 9.72 1010 | }, 1011 | { 1012 | "dt": 1588186800, 1013 | "sunrise": 1588165467, 1014 | "sunset": 1588214098, 1015 | "temp": { 1016 | "day": 301.97, 1017 | "min": 295.01, 1018 | "max": 303.77, 1019 | "night": 295.08, 1020 | "eve": 301.26, 1021 | "morn": 295.01 1022 | }, 1023 | "feels_like": { 1024 | "day": 299.52, 1025 | "night": 294.1, 1026 | "eve": 298.13, 1027 | "morn": 294.1 1028 | }, 1029 | "pressure": 1014, 1030 | "humidity": 26, 1031 | "dew_point": 281.94, 1032 | "wind_speed": 2.63, 1033 | "wind_deg": 204, 1034 | "weather": [ 1035 | { 1036 | "id": 803, 1037 | "main": "Clouds", 1038 | "description": "broken clouds", 1039 | "icon": "04d" 1040 | } 1041 | ], 1042 | "clouds": 67, 1043 | "uvi": 9.26 1044 | }, 1045 | { 1046 | "dt": 1588273200, 1047 | "sunrise": 1588251806, 1048 | "sunset": 1588300545, 1049 | "temp": { 1050 | "day": 300.14, 1051 | "min": 293.96, 1052 | "max": 302.83, 1053 | "night": 295.15, 1054 | "eve": 301.88, 1055 | "morn": 293.96 1056 | }, 1057 | "feels_like": { 1058 | "day": 298.15, 1059 | "night": 293.08, 1060 | "eve": 298.83, 1061 | "morn": 293.33 1062 | }, 1063 | "pressure": 1013, 1064 | "humidity": 30, 1065 | "dew_point": 282.58, 1066 | "wind_speed": 2.15, 1067 | "wind_deg": 251, 1068 | "weather": [ 1069 | { 1070 | "id": 804, 1071 | "main": "Clouds", 1072 | "description": "overcast clouds", 1073 | "icon": "04d" 1074 | } 1075 | ], 1076 | "clouds": 100, 1077 | "uvi": 9.1 1078 | }, 1079 | { 1080 | "dt": 1588359600, 1081 | "sunrise": 1588338146, 1082 | "sunset": 1588386993, 1083 | "temp": { 1084 | "day": 299.4, 1085 | "min": 292.52, 1086 | "max": 301.62, 1087 | "night": 293.77, 1088 | "eve": 300.44, 1089 | "morn": 292.52 1090 | }, 1091 | "feels_like": { 1092 | "day": 296.46, 1093 | "night": 291.51, 1094 | "eve": 296.33, 1095 | "morn": 291.11 1096 | }, 1097 | "pressure": 1012, 1098 | "humidity": 25, 1099 | "dew_point": 279.02, 1100 | "wind_speed": 2.49, 1101 | "wind_deg": 227, 1102 | "weather": [ 1103 | { 1104 | "id": 803, 1105 | "main": "Clouds", 1106 | "description": "broken clouds", 1107 | "icon": "04d" 1108 | } 1109 | ], 1110 | "clouds": 59, 1111 | "uvi": 9.1 1112 | }, 1113 | { 1114 | "dt": 1588446000, 1115 | "sunrise": 1588424486, 1116 | "sunset": 1588473441, 1117 | "temp": { 1118 | "day": 297.05, 1119 | "min": 290.78, 1120 | "max": 299.17, 1121 | "night": 291.49, 1122 | "eve": 297.65, 1123 | "morn": 290.78 1124 | }, 1125 | "feels_like": { 1126 | "day": 293.21, 1127 | "night": 289.17, 1128 | "eve": 293.46, 1129 | "morn": 288.87 1130 | }, 1131 | "pressure": 1014, 1132 | "humidity": 25, 1133 | "dew_point": 277.16, 1134 | "wind_speed": 3.26, 1135 | "wind_deg": 225, 1136 | "weather": [ 1137 | { 1138 | "id": 800, 1139 | "main": "Clear", 1140 | "description": "clear sky", 1141 | "icon": "01d" 1142 | } 1143 | ], 1144 | "clouds": 8, 1145 | "uvi": 9.59 1146 | }, 1147 | { 1148 | "dt": 1588532400, 1149 | "sunrise": 1588510828, 1150 | "sunset": 1588559889, 1151 | "temp": { 1152 | "day": 295.83, 1153 | "min": 289.12, 1154 | "max": 298.75, 1155 | "night": 291.11, 1156 | "eve": 298.75, 1157 | "morn": 289.12 1158 | }, 1159 | "feels_like": { 1160 | "day": 292.73, 1161 | "night": 285.34, 1162 | "eve": 293.86, 1163 | "morn": 287.98 1164 | }, 1165 | "pressure": 1015, 1166 | "humidity": 31, 1167 | "dew_point": 279.14, 1168 | "wind_speed": 2.73, 1169 | "wind_deg": 267, 1170 | "weather": [ 1171 | { 1172 | "id": 800, 1173 | "main": "Clear", 1174 | "description": "clear sky", 1175 | "icon": "01d" 1176 | } 1177 | ], 1178 | "clouds": 0, 1179 | "uvi": 9.54 1180 | }, 1181 | { 1182 | "dt": 1588618800, 1183 | "sunrise": 1588597171, 1184 | "sunset": 1588646337, 1185 | "temp": { 1186 | "day": 297.61, 1187 | "min": 289.28, 1188 | "max": 300.36, 1189 | "night": 293.13, 1190 | "eve": 299.49, 1191 | "morn": 289.28 1192 | }, 1193 | "feels_like": { 1194 | "day": 294.35, 1195 | "night": 289.71, 1196 | "eve": 295.15, 1197 | "morn": 285.31 1198 | }, 1199 | "pressure": 1014, 1200 | "humidity": 18, 1201 | "dew_point": 273.03, 1202 | "wind_speed": 1.54, 1203 | "wind_deg": 240, 1204 | "weather": [ 1205 | { 1206 | "id": 800, 1207 | "main": "Clear", 1208 | "description": "clear sky", 1209 | "icon": "01d" 1210 | } 1211 | ], 1212 | "clouds": 0, 1213 | "uvi": 9.82 1214 | } 1215 | ] 1216 | } 1217 | -------------------------------------------------------------------------------- /Weather/Resources/Mocks/MockWeatherFetcher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockWeatherFetcher.swift 3 | // Weather 4 | // 5 | // Created by Alex Fargo on 4/27/20. 6 | // Copyright © 2020 Alex Fargo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | 12 | class MockWeatherFetcher: WeatherFetcher { 13 | func weatherSummary(forCity city: String) -> AnyPublisher { 14 | return Just(WeatherSummary.createMock()) 15 | .setFailureType(to: WeatherError.self) 16 | .eraseToAnyPublisher() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Weather/Resources/Mocks/MockWeatherSummary.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockWeatherSummary.swift 3 | // Weather 4 | // 5 | // Created by Alex Fargo on 4/27/20. 6 | // Copyright © 2020 Alex Fargo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | #if DEBUG 12 | extension WeatherSummary { 13 | static func createMock() -> WeatherSummary { 14 | let path = Bundle.main.path(forResource: "MockOneCallResponse", ofType: "json")! 15 | let url = URL(fileURLWithPath: path) 16 | let data = try! Data(contentsOf: url) 17 | let response = try! JSONDecoder().decode(OneCallResponse.self, from: data) 18 | return WeatherSummary.convert(fromResponse: response) 19 | } 20 | } 21 | #endif 22 | -------------------------------------------------------------------------------- /Weather/Resources/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Weather/Sources/DataManager/DataManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataManager.swift 3 | // Weather 4 | // 5 | // Created by Alex Fargo on 4/26/20. 6 | // Copyright © 2020 Alex Fargo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreLocation 11 | import Combine 12 | 13 | protocol WeatherFetcher { 14 | func weatherSummary(forCity city: String) -> AnyPublisher 15 | } 16 | 17 | class DataManager { 18 | let session: URLSession 19 | 20 | init(session: URLSession = .init(configuration: .default)) { 21 | self.session = session 22 | } 23 | } 24 | 25 | extension DataManager: WeatherFetcher { 26 | func weatherSummary(forCity city: String) -> AnyPublisher { 27 | getCoordinates(forAddressString: city) 28 | .flatMap { [weak self] coordinates -> AnyPublisher in 29 | guard let self = self, 30 | let url = self.makeWeatherSummaryComponents(withCoordinates: coordinates).url else { 31 | return Fail(error: WeatherError.network(message: "Could not create URL")) 32 | .eraseToAnyPublisher() 33 | } 34 | return self.session.dataTaskPublisher(for: URLRequest(url: url)) 35 | .mapError { WeatherError.network(message: $0.localizedDescription) } 36 | .eraseToAnyPublisher() 37 | }.flatMap { pair in 38 | Just(pair.data) 39 | .decode(type: OneCallResponse.self, decoder: JSONDecoder()) 40 | .mapError { error in 41 | WeatherError.parsing(message: error.localizedDescription) 42 | }.map { response in 43 | WeatherSummary.convert(fromResponse: response) 44 | }.eraseToAnyPublisher() 45 | }.eraseToAnyPublisher() 46 | } 47 | } 48 | 49 | private extension DataManager { 50 | struct OpenWeatherAPI { 51 | static let scheme: String = "https" 52 | static let host: String = "api.openweathermap.org" 53 | static let path: String = "/data/2.5" 54 | static var key: String? { 55 | if let path = Bundle.main.path(forResource: "Secrets", ofType: "plist"), 56 | let dict = NSDictionary(contentsOfFile: path), 57 | let key = dict["openWeatherAPIKey"] as? String { 58 | return key 59 | } 60 | return nil 61 | } 62 | } 63 | 64 | func makeWeatherSummaryComponents(withCoordinates coordinates: CLLocationCoordinate2D) -> URLComponents { 65 | var components = URLComponents() 66 | components.scheme = OpenWeatherAPI.scheme 67 | components.host = OpenWeatherAPI.host 68 | components.path = OpenWeatherAPI.path + "/onecall" 69 | 70 | components.queryItems = [ 71 | .init(name: "lat", value: "\(coordinates.latitude)"), 72 | .init(name: "lon", value: "\(coordinates.longitude)"), 73 | .init(name: "appid", value: OpenWeatherAPI.key)] 74 | 75 | return components 76 | } 77 | 78 | func getCoordinates(forAddressString addressString: String) -> AnyPublisher { 79 | let geocoder = CLGeocoder() 80 | return Future { promise in 81 | geocoder.geocodeAddressString(addressString) { placemarks, error in 82 | if let error = error { 83 | print(error) 84 | promise(.failure(WeatherError.location(message: error.localizedDescription))) 85 | return 86 | } 87 | guard let location = placemarks?.first?.location else { 88 | promise(.failure(WeatherError.location(message: "Could not find location for \(addressString)"))) 89 | return 90 | } 91 | promise(.success(location.coordinate)) 92 | } 93 | }.eraseToAnyPublisher() 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Weather/Sources/Models/OneCallResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OneCallResponse.swift 3 | // Weather 4 | // 5 | // Created by Alex Fargo on 4/26/20. 6 | // Copyright © 2020 Alex Fargo. All rights reserved. 7 | // 8 | // This file was generated from JSON Schema using quicktype, do not modify it directly. 9 | // To parse the JSON, add this file to your project and do: 10 | // 11 | // let oneCallResponse = try? newJSONDecoder().decode(OneCallResponse.self, from: jsonData) 12 | 13 | import Foundation 14 | 15 | // MARK: - OneCallResponse 16 | struct OneCallResponse: Codable { 17 | let lat, lon: Double 18 | let timezone: String 19 | let current: CurrentResponse 20 | let hourly: [HourlyResponse] 21 | let daily: [DailyResponse] 22 | } 23 | 24 | // MARK: - CurrentResponse 25 | struct CurrentResponse: Codable { 26 | let dt, sunrise, sunset: Int 27 | let temp, feelsLike: Double 28 | let pressure, humidity: Int 29 | let uvi: Double 30 | let clouds, visibility: Int? 31 | let windSpeed: Double 32 | let windDeg: Int 33 | let weather: [WeatherResponse] 34 | let rain: RainResponse? 35 | 36 | enum CodingKeys: String, CodingKey { 37 | case dt, sunrise, sunset, temp 38 | case feelsLike = "feels_like" 39 | case pressure, humidity, uvi, clouds, visibility 40 | case windSpeed = "wind_speed" 41 | case windDeg = "wind_deg" 42 | case weather, rain 43 | } 44 | } 45 | 46 | // MARK: - RainResponse 47 | struct RainResponse: Codable { 48 | let the1H: Double? 49 | 50 | enum CodingKeys: String, CodingKey { 51 | case the1H = "1h" 52 | } 53 | } 54 | 55 | // MARK: - WeatherResponse 56 | struct WeatherResponse: Codable { 57 | let id: Int 58 | let main, weatherDescription, icon: String 59 | 60 | enum CodingKeys: String, CodingKey { 61 | case id, main 62 | case weatherDescription = "description" 63 | case icon 64 | } 65 | } 66 | 67 | // MARK: - DailyResponse 68 | struct DailyResponse: Codable { 69 | let dt, sunrise, sunset: Int 70 | let temp: TempResponse 71 | let feelsLike: FeelsLikeResponse 72 | let pressure, humidity: Int 73 | let windSpeed: Double 74 | let windDeg: Int 75 | let weather: [WeatherResponse] 76 | let clouds: Int 77 | let rain: Double? 78 | let uvi: Double 79 | 80 | enum CodingKeys: String, CodingKey { 81 | case dt, sunrise, sunset, temp 82 | case feelsLike = "feels_like" 83 | case pressure, humidity 84 | case windSpeed = "wind_speed" 85 | case windDeg = "wind_deg" 86 | case weather, clouds, rain, uvi 87 | } 88 | } 89 | 90 | // MARK: - FeelsLikeResponse 91 | struct FeelsLikeResponse: Codable { 92 | let day, night, eve, morn: Double 93 | } 94 | 95 | // MARK: - TempResponse 96 | struct TempResponse: Codable { 97 | let day, min, max, night: Double 98 | let eve, morn: Double 99 | } 100 | 101 | // MARK: - HourlyResponse 102 | struct HourlyResponse: Codable { 103 | let dt: Int 104 | let temp, feelsLike: Double 105 | let pressure, humidity, clouds: Int 106 | let windSpeed: Double 107 | let windDeg: Int 108 | let weather: [WeatherResponse] 109 | let rain: RainResponse? 110 | 111 | enum CodingKeys: String, CodingKey { 112 | case dt, temp 113 | case feelsLike = "feels_like" 114 | case pressure, humidity, clouds 115 | case windSpeed = "wind_speed" 116 | case windDeg = "wind_deg" 117 | case weather, rain 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /Weather/Sources/Models/WeatherError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherError.swift 3 | // Weather 4 | // 5 | // Created by Alex Fargo on 4/27/20. 6 | // Copyright © 2020 Alex Fargo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum WeatherError: Error { 12 | case parsing(message: String) 13 | case network(message: String) 14 | case location(message: String) 15 | 16 | var localizedDescription: String { 17 | switch self { 18 | case .parsing(let message): 19 | return message 20 | case .network(let message): 21 | return message 22 | case .location(let message): 23 | return message 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Weather/Sources/Models/WeatherSummary.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherSummary.swift 3 | // Weather 4 | // 5 | // Created by Alex Fargo on 4/27/20. 6 | // Copyright © 2020 Alex Fargo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | struct WeatherSummary { 13 | let latitude, longitude: Double 14 | let timezone: String 15 | let current: CurrentWeatherSummary 16 | let daily: [DailyWeatherSummary] 17 | let hourly: [HourlyWeatherSummary] 18 | 19 | static func convert(fromResponse response: OneCallResponse) -> WeatherSummary { 20 | WeatherSummary(latitude: response.lat, 21 | longitude: response.lon, 22 | timezone: response.timezone, 23 | current: .convert(fromResponse: response.current), 24 | daily: response.daily.map { .convert(fromResponse: $0) }, 25 | hourly: response.hourly.map { .convert(fromResponse: $0) }) 26 | } 27 | } 28 | 29 | struct CurrentWeatherSummary { 30 | let time, sunriseTime, sunsetTime: Date 31 | let actualTemp, feelsLikeTemp: Temperature 32 | let pressure, humidity: Int 33 | let uvIndex: Double 34 | let windSpeed: Double 35 | let windAngle: Int 36 | var windDirection: String { 37 | if windAngle >= 337 || windAngle < 23 { 38 | return "N" 39 | } 40 | if windAngle < 67 { 41 | return "NE" 42 | } 43 | if windAngle < 113 { 44 | return "E" 45 | } 46 | if windAngle < 157 { 47 | return "SE" 48 | } 49 | if windAngle < 203 { 50 | return "S" 51 | } 52 | if windAngle < 247 { 53 | return "SW" 54 | } 55 | if windAngle < 293 { 56 | return "W" 57 | } 58 | return "NW" 59 | } 60 | 61 | let weatherDetails: [WeatherDetails] 62 | 63 | static func convert(fromResponse response: CurrentResponse) -> CurrentWeatherSummary { 64 | CurrentWeatherSummary(time: Date(timeIntervalSince1970: TimeInterval(response.dt)), 65 | sunriseTime: Date(timeIntervalSince1970: TimeInterval(response.sunrise)), 66 | sunsetTime: Date(timeIntervalSince1970: TimeInterval(response.sunset)), 67 | actualTemp: .init(kelvin: response.temp), 68 | feelsLikeTemp: .init(kelvin: response.feelsLike), 69 | pressure: response.pressure, 70 | humidity: response.humidity, 71 | uvIndex: response.uvi, 72 | windSpeed: response.windSpeed, 73 | windAngle: response.windDeg, 74 | weatherDetails: response.weather.map { .convert(fromResponse: $0) }) 75 | } 76 | } 77 | 78 | struct DailyWeatherSummary { 79 | let time, sunriseTime, sunsetTime: Date 80 | let dayTemp, nightTemp: Temperature 81 | let minTemp, maxTemp: Temperature 82 | let eveTemp, mornTemp: Temperature 83 | 84 | let weatherDetails: [WeatherDetails] 85 | 86 | static func convert(fromResponse response: DailyResponse) -> DailyWeatherSummary { 87 | DailyWeatherSummary(time: Date(timeIntervalSince1970: TimeInterval(response.dt)), 88 | sunriseTime: Date(timeIntervalSince1970: TimeInterval(response.sunrise)), 89 | sunsetTime: Date(timeIntervalSince1970: TimeInterval(response.sunset)), 90 | dayTemp: .init(kelvin: response.temp.day), 91 | nightTemp: .init(kelvin: response.temp.night), 92 | minTemp: .init(kelvin: response.temp.min), 93 | maxTemp: .init(kelvin: response.temp.max), 94 | eveTemp: .init(kelvin: response.temp.eve), 95 | mornTemp: .init(kelvin: response.temp.morn), 96 | weatherDetails: response.weather.map { .convert(fromResponse: $0) }) 97 | } 98 | } 99 | 100 | struct HourlyWeatherSummary { 101 | let time: Date 102 | let actualTemp, feelsLikeTemp: Temperature 103 | 104 | let weatherDetails: [WeatherDetails] 105 | 106 | static func convert(fromResponse response: HourlyResponse) -> HourlyWeatherSummary { 107 | HourlyWeatherSummary(time: Date(timeIntervalSince1970: TimeInterval(response.dt)), 108 | actualTemp: .init(kelvin: response.temp), 109 | feelsLikeTemp: .init(kelvin: response.feelsLike), 110 | weatherDetails: response.weather.map { .convert(fromResponse: $0) }) 111 | } 112 | } 113 | 114 | struct WeatherDetails { 115 | let weatherID: Int 116 | let weatherCondition: String 117 | let weatherDescription: String 118 | let weatherIconID: String 119 | var weatherIcon: Image? { 120 | switch weatherIconID { 121 | case "01d": return Image(systemName: "sun.max") 122 | case "01n": return Image(systemName: "moon") 123 | case "02d": return Image(systemName: "cloud.sun") 124 | case "02n": return Image(systemName: "cloud.moon") 125 | case "03d", "03n", "04d", "04n": return Image(systemName: "cloud") 126 | case "09d", "09n": return Image(systemName: "cloud.rain") 127 | case "10d": return Image(systemName: "cloud.sun.rain") 128 | case "10n": return Image(systemName: "cloud.moon.rain") 129 | case "11d", "11n": return Image(systemName: "cloud.bolt.rain") 130 | case "13d", "13n": return Image(systemName: "cloud.snow") 131 | case "50d", "50n": return Image(systemName: "cloud.fog") 132 | default: return Image(systemName: "sun.max") 133 | } 134 | } 135 | 136 | static func convert(fromResponse response: WeatherResponse) -> WeatherDetails { 137 | WeatherDetails(weatherID: response.id, 138 | weatherCondition: response.main, 139 | weatherDescription: response.weatherDescription, 140 | weatherIconID: response.icon) 141 | } 142 | } 143 | 144 | struct Temperature { 145 | var kelvin: Double 146 | 147 | var celsius: Double { 148 | kelvin - 273.15 149 | } 150 | 151 | var fahrenheight: Double { 152 | (kelvin - 273.15) * (9 / 5) + 32 153 | } 154 | } 155 | 156 | extension Temperature: ExpressibleByFloatLiteral { 157 | init(floatLiteral value: FloatLiteralType) { 158 | kelvin = value 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /Weather/Sources/Utilities/DismissKeyboard.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DismissKeyboard.swift 3 | // Weather 4 | // 5 | // Created by Alex Fargo on 4/27/20. 6 | // Copyright © 2020 Alex Fargo. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIApplication { 12 | func endEditing() { 13 | sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Weather/Sources/Views/CurrentSummary/CurrentSummaryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CurrentSummaryView.swift 3 | // Weather 4 | // 5 | // Created by Alex Fargo on 4/27/20. 6 | // Copyright © 2020 Alex Fargo. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct CurrentSummaryView: View { 12 | private let viewModel: CurrentSummaryViewModel 13 | 14 | init(viewModel: CurrentSummaryViewModel) { 15 | self.viewModel = viewModel 16 | } 17 | 18 | var body: some View { 19 | VStack { 20 | ZStack { 21 | HStack { 22 | VStack(alignment: .leading) { 23 | detailView(text: viewModel.sunriseTimeFmt, 24 | image: .init(systemName: "sunrise"), 25 | offset: .init(width: 0, height: -2)) 26 | 27 | detailView(text: viewModel.sunsetTimeFmt, 28 | image: .init(systemName: "sunset"), 29 | offset: .init(width: 0, height: -2)) 30 | } 31 | Spacer() 32 | } 33 | VStack(alignment: .leading) { 34 | detailView(text: "UV: \(viewModel.uvIndex)", 35 | image: .init(systemName: "sun.max")) 36 | 37 | detailView(text: viewModel.humidity, 38 | image: .init(systemName: "thermometer.sun")) 39 | } 40 | 41 | HStack { 42 | Spacer() 43 | VStack(alignment: .leading) { 44 | detailView(text: viewModel.windSpeed, 45 | image: .init(systemName: "wind")) 46 | 47 | detailView(text: viewModel.windDirection, 48 | image: .init(systemName: "arrow.up.right.circle")) 49 | } 50 | } 51 | } 52 | } 53 | } 54 | 55 | private func detailView(text: String, image: Image, offset: CGSize = .zero) -> some View { 56 | HStack { 57 | image 58 | .imageScale(.medium) 59 | .foregroundColor(.blue) 60 | .offset(offset) 61 | Text(text) 62 | } 63 | } 64 | } 65 | 66 | 67 | struct TodayInformationView_Previews: PreviewProvider { 68 | static var previews: some View { 69 | CurrentSummaryView(viewModel: .init(weatherSummary: WeatherSummary.createMock().current)) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Weather/Sources/Views/CurrentSummary/CurrentSummaryViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CurrentSummaryViewModel.swift 3 | // Weather 4 | // 5 | // Created by Alex Fargo on 4/27/20. 6 | // Copyright © 2020 Alex Fargo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class CurrentSummaryViewModel { 12 | private let currentSummary: CurrentWeatherSummary 13 | 14 | var sunriseTimeFmt: String { 15 | let dateFormatter = DateFormatter() 16 | dateFormatter.timeStyle = .short 17 | return dateFormatter.string(from: currentSummary.sunriseTime) 18 | } 19 | 20 | var sunsetTimeFmt: String { 21 | let dateFormatter = DateFormatter() 22 | dateFormatter.timeStyle = .short 23 | return dateFormatter.string(from: currentSummary.sunsetTime) 24 | } 25 | 26 | var windSpeed: String { 27 | let kmh = currentSummary.windSpeed 28 | let mph = kmh / 1.609 29 | return String(format: "%.0f mi/h", mph) 30 | } 31 | 32 | var windDirection: String { 33 | currentSummary.windDirection 34 | } 35 | 36 | var uvIndex: String { 37 | String(format: "%.0f", currentSummary.uvIndex) 38 | } 39 | 40 | var humidity: String { 41 | "\(currentSummary.humidity)%" 42 | } 43 | 44 | init(weatherSummary: CurrentWeatherSummary) { 45 | self.currentSummary = weatherSummary 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Weather/Sources/Views/DaySummary/DaySummaryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DaySummaryView.swift 3 | // Weather 4 | // 5 | // Created by Alex Fargo on 4/27/20. 6 | // Copyright © 2020 Alex Fargo. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct DaySummaryView: View { 12 | private let viewModel: DaySummaryViewModel 13 | 14 | init(viewModel: DaySummaryViewModel) { 15 | self.viewModel = viewModel 16 | } 17 | 18 | var body: some View { 19 | HStack { 20 | Text(viewModel.day) 21 | .fontWeight(.medium) 22 | Spacer() 23 | Text("\(viewModel.highTempFmt) / \(viewModel.lowTempFmt)") 24 | .fontWeight(.light) 25 | viewModel.icon 26 | .imageScale(.large) 27 | .foregroundColor(.secondary) 28 | }.padding(.horizontal) 29 | .padding(.vertical, 22) 30 | .background(Color.init(.secondarySystemBackground)) 31 | .cornerRadius(10) 32 | } 33 | } 34 | 35 | struct DaySummaryView_Previews: PreviewProvider { 36 | static var previews: some View { 37 | DaySummaryView(viewModel: DaySummaryViewModel(daySummary: WeatherSummary.createMock().daily.first!)) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Weather/Sources/Views/DaySummary/DaySummaryViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DaySummaryViewModel.swift 3 | // Weather 4 | // 5 | // Created by Alex Fargo on 4/27/20. 6 | // Copyright © 2020 Alex Fargo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | class DaySummaryViewModel: Identifiable { 13 | private let daySummary: DailyWeatherSummary 14 | 15 | var id = UUID() 16 | 17 | var day: String { 18 | let dateFormatter = DateFormatter() 19 | dateFormatter.dateFormat = "EEEE" 20 | return dateFormatter.string(from: daySummary.time) 21 | } 22 | 23 | var highTempFmt: String { 24 | String(format: "%.0fº", daySummary.maxTemp.fahrenheight) 25 | } 26 | 27 | var lowTempFmt: String { 28 | String(format: "%.0fº", daySummary.minTemp.fahrenheight) 29 | } 30 | 31 | var icon: Image? { 32 | daySummary.weatherDetails.first?.weatherIcon 33 | } 34 | 35 | init(daySummary: DailyWeatherSummary) { 36 | self.daySummary = daySummary 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Weather/Sources/Views/HourSummary/HourInformationViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HourSummaryViewModel.swift 3 | // Weather 4 | // 5 | // Created by Alex Fargo on 4/27/20. 6 | // Copyright © 2020 Alex Fargo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class HourSummaryViewModel: Identifiable { 12 | private let hourSummary: HourlyWeatherSummary 13 | 14 | var id = UUID() 15 | 16 | init(hourSummary: HourlyWeatherSummary) { 17 | self.hourSummary = hourSummary 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Weather/Sources/Views/HourSummary/HourSummaryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HourSummaryView.swift 3 | // Weather 4 | // 5 | // Created by Alex Fargo on 4/27/20. 6 | // Copyright © 2020 Alex Fargo. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct HourSummaryView: View { 12 | private let viewModel: HourSummaryViewModel 13 | 14 | init(viewModel: HourSummaryViewModel) { 15 | self.viewModel = viewModel 16 | } 17 | 18 | var body: some View { 19 | VStack(spacing: 16) { 20 | Text(viewModel.tempFmt) 21 | .font(.caption) 22 | .fontWeight(.medium) 23 | viewModel.icon 24 | .imageScale(.large) 25 | Text(viewModel.timeFmt) 26 | .font(.caption) 27 | .fontWeight(.medium) 28 | .layoutPriority(1) 29 | .fixedSize() 30 | }.padding(10) 31 | .background(Color.init(.secondarySystemBackground)) 32 | .cornerRadius(10) 33 | } 34 | } 35 | 36 | struct HourSummaryView_Previews: PreviewProvider { 37 | static var previews: some View { 38 | HourSummaryView(viewModel: HourSummaryViewModel(hourSummary: WeatherSummary.createMock().hourly.first!)) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Weather/Sources/Views/HourSummary/HourSummaryViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HourSummaryViewModel.swift 3 | // Weather 4 | // 5 | // Created by Alex Fargo on 4/27/20. 6 | // Copyright © 2020 Alex Fargo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | class HourSummaryViewModel: Identifiable { 13 | private let hourSummary: HourlyWeatherSummary 14 | 15 | var id = UUID() 16 | 17 | var tempFmt: String { 18 | String(format: "%.0fº", hourSummary.actualTemp.fahrenheight) 19 | } 20 | 21 | var icon: Image? { 22 | hourSummary.weatherDetails.first?.weatherIcon 23 | } 24 | 25 | var timeFmt: String { 26 | let dateFormatter = DateFormatter() 27 | dateFormatter.dateFormat = "h:00 a" 28 | return dateFormatter.string(from: hourSummary.time) 29 | } 30 | 31 | init(hourSummary: HourlyWeatherSummary) { 32 | self.hourSummary = hourSummary 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Weather/Sources/Views/SearchBar/SearchBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchBar.swift 3 | // Weather 4 | // 5 | // Created by Alex Fargo on 4/27/20. 6 | // Copyright © 2020 Alex Fargo. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct SearchBar: View { 12 | @Binding var searchText: String 13 | @State var showClearButton: Bool = false 14 | var placeholder = "Search" 15 | 16 | var body: some View { 17 | TextField(placeholder, text: $searchText, onEditingChanged: { editing in 18 | self.showClearButton = editing 19 | }, onCommit: { 20 | self.showClearButton = false 21 | }).modifier(ClearButton(text: $searchText, isVisible: $showClearButton)) 22 | .padding(.horizontal) 23 | .padding(.vertical, 10) 24 | .background(Color(.secondarySystemBackground)) 25 | .cornerRadius(12) 26 | } 27 | } 28 | 29 | struct ClearButton: ViewModifier { 30 | @Binding var text: String 31 | @Binding var isVisible: Bool 32 | 33 | func body(content: Content) -> some View { 34 | HStack { 35 | content 36 | Spacer() 37 | Image(systemName: "xmark.circle.fill") 38 | .foregroundColor(Color.init(.placeholderText)) 39 | .opacity(!text.isEmpty ? 1 : 0) 40 | .opacity(isVisible ? 1 : 0) 41 | .onTapGesture { self.text = "" } 42 | } 43 | } 44 | } 45 | 46 | struct SearchBar_Previews: PreviewProvider { 47 | @State static var searchText: String = "" 48 | static var previews: some View { 49 | SearchBar(searchText: $searchText) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Weather/Sources/Views/WeatherSummary/WeatherSummaryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherSummaryView.swift 3 | // Weather 4 | // 5 | // Created by Alex Fargo on 4/26/20. 6 | // Copyright © 2020 Alex Fargo. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct WeatherSummaryView: View { 12 | @ObservedObject var viewModel: WeatherSummaryViewModel 13 | 14 | init(viewModel: WeatherSummaryViewModel) { 15 | self.viewModel = viewModel 16 | } 17 | 18 | var body: some View { 19 | ZStack { 20 | Color(.systemBackground) 21 | .edgesIgnoringSafeArea(.all) 22 | VStack(spacing: 32) { 23 | SearchBar(searchText: $viewModel.searchText, placeholder: "Search for a location") 24 | currentWeatherView 25 | viewModel.todayInformationViewModel() != nil ? 26 | CurrentSummaryView(viewModel: viewModel.todayInformationViewModel()!) : nil 27 | ScrollView(.horizontal, showsIndicators: false) { 28 | HStack { 29 | ForEach(viewModel.hourSummaries) { viewModel in 30 | HourSummaryView(viewModel: viewModel) 31 | } 32 | } 33 | } 34 | ScrollView(.vertical, showsIndicators: false) { 35 | VStack { 36 | ForEach(viewModel.daySummaries) { viewModel in 37 | DaySummaryView(viewModel: viewModel) 38 | } 39 | } 40 | } 41 | }.padding([.horizontal]) 42 | .padding(.top) 43 | }.onTapGesture { 44 | UIApplication.shared.endEditing() 45 | } 46 | } 47 | 48 | private var currentWeatherView: some View { 49 | if let _ = viewModel.weatherSummary { 50 | return AnyView(HStack { 51 | VStack(spacing: 4) { 52 | Text(viewModel.currentTempDescription) 53 | .font(.headline) 54 | .fontWeight(.medium) 55 | HStack { 56 | viewModel.currentWeatherIcon 57 | .imageScale(.small) 58 | Text("\(viewModel.currentTempFmt)") 59 | .fontWeight(.semibold) 60 | }.font(.system(size: 64)) 61 | .frame(maxWidth: .infinity) 62 | HStack(spacing: 16) { 63 | Text("Feels like \(viewModel.feelsLikeTempFmt)") 64 | .foregroundColor(.secondary) 65 | } 66 | } 67 | }) 68 | } else { 69 | return AnyView(EmptyView()) 70 | } 71 | } 72 | } 73 | 74 | struct WeatherSummaryView_Previews: PreviewProvider { 75 | static var previews: some View { 76 | WeatherSummaryView(viewModel: WeatherSummaryViewModel(weatherFetcher: MockWeatherFetcher())) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Weather/Sources/Views/WeatherSummary/WeatherSummaryViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherSummaryViewModel.swift 3 | // Weather 4 | // 5 | // Created by Alex Fargo on 4/26/20. 6 | // Copyright © 2020 Alex Fargo. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Combine 11 | 12 | class WeatherSummaryViewModel: ObservableObject { 13 | private var weatherFetcher: WeatherFetcher 14 | private var disposable = Set() 15 | 16 | @Published var searchText: String = "Cupertino" 17 | @Published var weatherSummary: WeatherSummary? 18 | 19 | var currentTempFmt: String { 20 | guard let temp = weatherSummary?.current.actualTemp.fahrenheight else { return "--º" } 21 | return String(format: "%.0fº", temp) 22 | } 23 | 24 | var feelsLikeTempFmt: String { 25 | guard let temp = weatherSummary?.current.feelsLikeTemp.fahrenheight else { return "--º" } 26 | return String(format: "%.0fº", temp) 27 | } 28 | 29 | var currentTempDescription: String { 30 | guard let description = weatherSummary?.current.weatherDetails.first?.weatherDescription else { 31 | return "" 32 | } 33 | return description.localizedCapitalized 34 | } 35 | 36 | var currentWeatherIcon: Image? { 37 | weatherSummary?.current.weatherDetails.first?.weatherIcon 38 | } 39 | 40 | var hourSummaries: [HourSummaryViewModel] = [] 41 | var daySummaries: [DaySummaryViewModel] = [] 42 | 43 | init(weatherFetcher: WeatherFetcher) { 44 | self.weatherFetcher = weatherFetcher 45 | 46 | let searchTextScheduler: DispatchQueue = .init(label: "weatherSearch", qos: .userInteractive) 47 | $searchText 48 | .dropFirst() 49 | .debounce(for: .seconds(0.5), 50 | scheduler: searchTextScheduler) 51 | .sink(receiveValue: fetchWeatherSummary(forLocation:)) 52 | .store(in: &disposable) 53 | 54 | fetchWeatherSummary(forLocation: searchText) 55 | } 56 | 57 | func fetchWeatherSummary(forLocation location: String) { 58 | weatherFetcher.weatherSummary(forCity: location) 59 | .receive(on: DispatchQueue.main) 60 | .sink(receiveCompletion: { [weak self] (value) in 61 | switch value { 62 | case .failure(let error): 63 | self?.weatherSummary = nil 64 | self?.hourSummaries = [] 65 | self?.daySummaries = [] 66 | print(error) 67 | case .finished: 68 | break 69 | } 70 | }) { [weak self] weatherSummary in 71 | self?.weatherSummary = weatherSummary 72 | self?.hourSummaries = weatherSummary.hourly.map { HourSummaryViewModel(hourSummary: $0) } 73 | self?.daySummaries = weatherSummary.daily.map { DaySummaryViewModel(daySummary: $0) } 74 | }.store(in: &disposable) 75 | } 76 | 77 | func todayInformationViewModel() -> CurrentSummaryViewModel? { 78 | guard let weatherSummary = weatherSummary else { return nil } 79 | return .init(weatherSummary: weatherSummary.current) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Weather/Support/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Weather 4 | // 5 | // Created by Alex Fargo on 4/26/20. 6 | // Copyright © 2020 Alex Fargo. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @UIApplicationMain 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | 15 | 16 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 17 | // Override point for customization after application launch. 18 | return true 19 | } 20 | 21 | // MARK: UISceneSession Lifecycle 22 | 23 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 24 | // Called when a new scene session is being created. 25 | // Use this method to select a configuration to create the new scene with. 26 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 27 | } 28 | 29 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 30 | // Called when the user discards a scene session. 31 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 32 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 33 | } 34 | 35 | 36 | } 37 | 38 | -------------------------------------------------------------------------------- /Weather/Support/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 | UILaunchStoryboardName 41 | LaunchScreen 42 | UIRequiredDeviceCapabilities 43 | 44 | armv7 45 | 46 | UISupportedInterfaceOrientations 47 | 48 | UIInterfaceOrientationPortrait 49 | UIInterfaceOrientationLandscapeLeft 50 | UIInterfaceOrientationLandscapeRight 51 | 52 | UISupportedInterfaceOrientations~ipad 53 | 54 | UIInterfaceOrientationPortrait 55 | UIInterfaceOrientationPortraitUpsideDown 56 | UIInterfaceOrientationLandscapeLeft 57 | UIInterfaceOrientationLandscapeRight 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /Weather/Support/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // Weather 4 | // 5 | // Created by Alex Fargo on 4/26/20. 6 | // Copyright © 2020 Alex Fargo. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SwiftUI 11 | 12 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 13 | 14 | var window: UIWindow? 15 | 16 | 17 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 18 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 19 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 20 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 21 | 22 | // Create the SwiftUI view that provides the window contents. 23 | let dataManager = DataManager() 24 | let viewModel = WeatherSummaryViewModel(weatherFetcher: dataManager) 25 | let contentView = WeatherSummaryView(viewModel: viewModel) 26 | 27 | // Use a UIHostingController as window root view controller. 28 | if let windowScene = scene as? UIWindowScene { 29 | let window = UIWindow(windowScene: windowScene) 30 | window.rootViewController = UIHostingController(rootView: contentView) 31 | self.window = window 32 | window.makeKeyAndVisible() 33 | } 34 | } 35 | 36 | func sceneDidDisconnect(_ scene: UIScene) { 37 | // Called as the scene is being released by the system. 38 | // This occurs shortly after the scene enters the background, or when its session is discarded. 39 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 40 | // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). 41 | } 42 | 43 | func sceneDidBecomeActive(_ scene: UIScene) { 44 | // Called when the scene has moved from an inactive state to an active state. 45 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 46 | } 47 | 48 | func sceneWillResignActive(_ scene: UIScene) { 49 | // Called when the scene will move from an active state to an inactive state. 50 | // This may occur due to temporary interruptions (ex. an incoming phone call). 51 | } 52 | 53 | func sceneWillEnterForeground(_ scene: UIScene) { 54 | // Called as the scene transitions from the background to the foreground. 55 | // Use this method to undo the changes made on entering the background. 56 | } 57 | 58 | func sceneDidEnterBackground(_ scene: UIScene) { 59 | // Called as the scene transitions from the foreground to the background. 60 | // Use this method to save data, release shared resources, and store enough scene-specific state information 61 | // to restore the scene back to its current state. 62 | } 63 | 64 | 65 | } 66 | 67 | -------------------------------------------------------------------------------- /WeatherTests/CurrentSummaryViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CurrentSummaryViewModelTests.swift 3 | // WeatherTests 4 | // 5 | // Created by Alex Fargo on 4/27/20. 6 | // Copyright © 2020 Alex Fargo. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import SwiftUI 11 | @testable import Weather 12 | 13 | class CurrentSummaryViewModelTests: XCTestCase { 14 | var viewModel: CurrentSummaryViewModel! 15 | 16 | override func setUp() { 17 | viewModel = CurrentSummaryViewModel(weatherSummary: WeatherSummary.createMock().current) 18 | } 19 | 20 | func test_currentSummaryViewModel() { 21 | XCTAssertEqual(viewModel.sunriseTimeFmt, "6:06 AM") 22 | XCTAssertEqual(viewModel.sunsetTimeFmt, "7:33 PM") 23 | XCTAssertEqual(viewModel.windSpeed, "1 mi/h") 24 | XCTAssertEqual(viewModel.windDirection, "S") 25 | XCTAssertEqual(viewModel.uvIndex, "10") 26 | XCTAssertEqual(viewModel.humidity, "53%") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /WeatherTests/DaySummaryViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DaySummaryViewModelTests.swift 3 | // WeatherTests 4 | // 5 | // Created by Alex Fargo on 4/27/20. 6 | // Copyright © 2020 Alex Fargo. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import SwiftUI 11 | @testable import Weather 12 | 13 | class DaySummaryViewModelTests: XCTestCase { 14 | var viewModel: DaySummaryViewModel! 15 | 16 | override func setUp() { 17 | viewModel = DaySummaryViewModel(daySummary: WeatherSummary.createMock().daily.first!) 18 | } 19 | 20 | func test_daySummaryViewModel() { 21 | XCTAssertEqual(viewModel.day, "Monday") 22 | XCTAssertEqual(viewModel.lowTempFmt, "72º") 23 | XCTAssertEqual(viewModel.highTempFmt, "82º") 24 | XCTAssertEqual(viewModel.icon, Image(systemName: "sun.max")) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /WeatherTests/HourSummaryViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HourSummaryViewModelTests.swift 3 | // WeatherTests 4 | // 5 | // Created by Alex Fargo on 4/27/20. 6 | // Copyright © 2020 Alex Fargo. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import SwiftUI 11 | @testable import Weather 12 | 13 | class HourSummaryViewModelTests: XCTestCase { 14 | var viewModel: HourSummaryViewModel! 15 | 16 | override func setUp() { 17 | self.viewModel = HourSummaryViewModel(hourSummary: WeatherSummary.createMock().hourly.first!) 18 | } 19 | 20 | func test_hourSummaryViewModel() { 21 | XCTAssertEqual(viewModel.tempFmt, "79º") 22 | XCTAssertEqual(viewModel.timeFmt, "11:00 AM") 23 | XCTAssertEqual(viewModel.icon, Image(systemName: "sun.max")) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /WeatherTests/WeatherSummaryViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherSummaryViewModelTests.swift 3 | // WeatherTests 4 | // 5 | // Created by Alex Fargo on 4/27/20. 6 | // Copyright © 2020 Alex Fargo. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import SwiftUI 11 | @testable import Weather 12 | 13 | class WeatherSummaryViewModelTests: XCTestCase { 14 | var viewModel: WeatherSummaryViewModel! 15 | 16 | override func setUp() { 17 | viewModel = WeatherSummaryViewModel(weatherFetcher: MockWeatherFetcher()) 18 | } 19 | 20 | func test_WeatherSummaryViewModel() { 21 | viewModel.searchText = "test" 22 | RunLoop.main.run(mode: .default, before: .distantPast) 23 | XCTAssertEqual(viewModel.currentTempFmt, "79º") 24 | XCTAssertEqual(viewModel.feelsLikeTempFmt, "79º") 25 | XCTAssertEqual(viewModel.currentTempDescription, "Clear Sky") 26 | XCTAssertEqual(viewModel.currentWeatherIcon, Image(systemName: "sun.max")) 27 | 28 | XCTAssertEqual(viewModel.hourSummaries.count, 48) 29 | XCTAssertEqual(viewModel.daySummaries.count, 8) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /WeatherTests/WeatherTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherTests.swift 3 | // WeatherTests 4 | // 5 | // Created by Alex Fargo on 4/26/20. 6 | // Copyright © 2020 Alex Fargo. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Weather 11 | 12 | class WeatherTests: XCTestCase { 13 | 14 | override func setUpWithError() throws { 15 | // Put setup code here. This method is called before the invocation of each test method in the class. 16 | } 17 | 18 | override func tearDownWithError() throws { 19 | // Put teardown code here. This method is called after the invocation of each test method in the class. 20 | } 21 | 22 | func testExample() throws { 23 | // This is an example of a functional test case. 24 | // Use XCTAssert and related functions to verify your tests produce the correct results. 25 | } 26 | 27 | func testPerformanceExample() throws { 28 | // This is an example of a performance test case. 29 | self.measure { 30 | // Put the code you want to measure the time of here. 31 | } 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /screenshots/screencap1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flexaargo/SwiftUI-Weather/3a1df8c8270efbd7a3f170afb99e1f23c16c7983/screenshots/screencap1.gif -------------------------------------------------------------------------------- /screenshots/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flexaargo/SwiftUI-Weather/3a1df8c8270efbd7a3f170afb99e1f23c16c7983/screenshots/screenshot1.png --------------------------------------------------------------------------------