├── .gitignore ├── README.md ├── Star Wars Contacts.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ └── Star Wars Contacts.xcscheme └── Star Wars Contacts ├── AppDelegate.swift ├── Assets.xcassets ├── AppIcon.appiconset │ └── Contents.json ├── Contents.json └── user.imageset │ ├── Contents.json │ └── user-alt.pdf ├── Base.lproj └── LaunchScreen.storyboard ├── Coordinators ├── AppCoordinator.swift └── Coordinator.swift ├── Info.plist ├── Models ├── AffiliationEnum.swift ├── IndividualModel.swift └── ResponseWrapper.swift ├── Preview Content ├── Preview Assets.xcassets │ ├── 07.imageset │ │ ├── 07.png │ │ └── Contents.json │ └── Contents.json ├── PreviewDatabase.swift └── individuals.json ├── SceneDelegate.swift ├── Services ├── DirectoryService.swift └── ImageStore.swift ├── Statics ├── Coders.swift ├── DateFormatters.swift ├── FileManagerExtensions.swift ├── Injector.swift ├── NetworkLogger.swift └── Theme.swift ├── ViewModels ├── IndividualDetailViewModel.swift └── IndividualListViewModel.swift └── Views ├── IndividualDetailView.swift ├── IndividualListView.swift ├── IndividualRow.swift └── LabelDetailRow.swift /.gitignore: -------------------------------------------------------------------------------- 1 | derivedData 2 | xcuserdata 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Example of MVVM-Coordinator pattern with SwiftUI 2 | 3 | ## Explain 4 | SwiftUI seems to lend itself well with the MVVM design pattern, but not necessarily to the MVVM-Coordinator pattern. I have spent some time and collaboration to figure out one way it can be done, altough it is somewhat hacky. This is my example project demonstrating the method I have come up with. 5 | 6 | It all comes down to how to transfer control from the NavigationButton to the Coordinator so that the Coordinator can be in charge of creating the next View and displaying it. Normally the NavigationButton requires the next View to be provided right there in the constructor and takes care of all this for you. The bad thing about this is that it couples that next View to the current View and now must follow no matter where the current View is used in the app. This breaks modularity and reusability of both the current View and the next View. So, curcumventing the normal behavior of a NavigationButton (or NavigationLink) I use a `Button` instead and then use the `action` closure parameter to send a signal that the Coordinator can listen to. Then the coordinator creates and pushes the next View onto a traditional UINavigationController. I told you it was hacky. 7 | 8 | ## Show 9 | 10 | ### CurrentView 11 | ```swift 12 | Button(action: { 13 | // tell the viewModel to publish a signal 14 | self.viewModel.userSelectedNext() 15 | }, label: { Text("Next") }) 16 | ``` 17 | 18 | ### CurrentViewModel 19 | ```swift 20 | let didSelectNext = PassthroughSubject() 21 | 22 | func userSelectedNext() { 23 | didSelectNext.send() 24 | } 25 | ``` 26 | 27 | ### Coordinator 28 | ```swift 29 | // subscribe/listen to the signal from the viewModel 30 | _ = viewModel.didSelectNext 31 | .sink { [weak self] in 32 | self?.showNextScreen() 33 | } 34 | 35 | private func showNextScreen() { 36 | // create the new view 37 | let view = NextView() 38 | // hook up subscribers/listeners 39 | ... 40 | // wrap it in a HostingController 41 | let controller = UIHostingController(rootView: view) 42 | // push it onto the navigation stack 43 | navigationController.pushViewController(controller, animated: true) 44 | } 45 | ``` 46 | 47 | ## Downsides 48 | The biggest downside, in my opinion, is that we lose the fancy new simplistic and beautiful, built-in transitions that come with letting a NavigationButton just do its thing and we are stuck depending on the old UINavigationController from UIKit to do its thing. I hope that in the future, perhaps the next version of SwiftUI will provide a more straight forward and clean way to code this pattern and no longer require the use of the UINavigationController. 49 | -------------------------------------------------------------------------------- /Star Wars Contacts.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 52; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 273F1EDD22A84F5D00237A82 /* IndividualRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 273F1EDC22A84F5D00237A82 /* IndividualRow.swift */; }; 11 | 273F1EDF22A862E900237A82 /* ImageStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 273F1EDE22A862E900237A82 /* ImageStore.swift */; }; 12 | 273F1EE822A8876F00237A82 /* DirectoryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 273F1EE722A8876F00237A82 /* DirectoryService.swift */; }; 13 | 275A571922A8C93300BEB608 /* Injector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 275A571822A8C93300BEB608 /* Injector.swift */; }; 14 | 27C397F722A7286500590E12 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C397F622A7286500590E12 /* AppDelegate.swift */; }; 15 | 27C397F922A7286500590E12 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C397F822A7286500590E12 /* SceneDelegate.swift */; }; 16 | 27C397FD22A7286700590E12 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 27C397FC22A7286700590E12 /* Assets.xcassets */; }; 17 | 27C3980022A7286700590E12 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 27C397FF22A7286700590E12 /* Preview Assets.xcassets */; }; 18 | 27C3980322A7286700590E12 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 27C3980122A7286700590E12 /* LaunchScreen.storyboard */; }; 19 | 27C3980D22A72E5200590E12 /* IndividualModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C3980C22A72E5200590E12 /* IndividualModel.swift */; }; 20 | 27C3980F22A7400600590E12 /* Coders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C3980E22A7400500590E12 /* Coders.swift */; }; 21 | 27C3981222A820EB00590E12 /* IndividualDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C3981122A820EB00590E12 /* IndividualDetailView.swift */; }; 22 | 27C3981422A8211500590E12 /* IndividualListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C3981322A8211500590E12 /* IndividualListView.swift */; }; 23 | 27C3981722A82D7900590E12 /* IndividualDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C3981622A82D7900590E12 /* IndividualDetailViewModel.swift */; }; 24 | 27C3981922A82D8B00590E12 /* IndividualListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C3981822A82D8B00590E12 /* IndividualListViewModel.swift */; }; 25 | 27C3981D22A83A5400590E12 /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C3981C22A83A5400590E12 /* AppCoordinator.swift */; }; 26 | 27C3981F22A83B1800590E12 /* ResponseWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C3981E22A83B1800590E12 /* ResponseWrapper.swift */; }; 27 | 27C3982122A83B4B00590E12 /* AffiliationEnum.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C3982022A83B4B00590E12 /* AffiliationEnum.swift */; }; 28 | 27C3982322A83B7800590E12 /* Coordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C3982222A83B7800590E12 /* Coordinator.swift */; }; 29 | 27C3982622A846D300590E12 /* DateFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C3982522A846D300590E12 /* DateFormatters.swift */; }; 30 | 27C3982822A846EC00590E12 /* FileManagerExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C3982722A846EC00590E12 /* FileManagerExtensions.swift */; }; 31 | 27C3982A22A8476500590E12 /* individuals.json in Resources */ = {isa = PBXBuildFile; fileRef = 27C3982922A8476500590E12 /* individuals.json */; }; 32 | 27C3982C22A8496B00590E12 /* PreviewDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C3982B22A8496B00590E12 /* PreviewDatabase.swift */; }; 33 | 27FE714922B9A11200D852AE /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27FE714822B9A11200D852AE /* Theme.swift */; }; 34 | 27FE714B22B9A3B400D852AE /* LabelDetailRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27FE714A22B9A3B400D852AE /* LabelDetailRow.swift */; }; 35 | 27FE714D22B9B42A00D852AE /* NetworkLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27FE714C22B9B42A00D852AE /* NetworkLogger.swift */; }; 36 | FB7B2C352421770F003805B9 /* Swinject in Frameworks */ = {isa = PBXBuildFile; productRef = FB7B2C342421770F003805B9 /* Swinject */; }; 37 | FB7B2C3824217827003805B9 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = FB7B2C3724217827003805B9 /* Alamofire */; }; 38 | /* End PBXBuildFile section */ 39 | 40 | /* Begin PBXFileReference section */ 41 | 2727105F22CD319600A90426 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 42 | 273F1EDC22A84F5D00237A82 /* IndividualRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndividualRow.swift; sourceTree = ""; }; 43 | 273F1EDE22A862E900237A82 /* ImageStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageStore.swift; sourceTree = ""; }; 44 | 273F1EE722A8876F00237A82 /* DirectoryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectoryService.swift; sourceTree = ""; }; 45 | 275A571822A8C93300BEB608 /* Injector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Injector.swift; sourceTree = ""; }; 46 | 27C397F322A7286500590E12 /* Star Wars Contacts.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Star Wars Contacts.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 47 | 27C397F622A7286500590E12 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 48 | 27C397F822A7286500590E12 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 49 | 27C397FC22A7286700590E12 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 50 | 27C397FF22A7286700590E12 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 51 | 27C3980222A7286700590E12 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 52 | 27C3980422A7286700590E12 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 53 | 27C3980C22A72E5200590E12 /* IndividualModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndividualModel.swift; sourceTree = ""; }; 54 | 27C3980E22A7400500590E12 /* Coders.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Coders.swift; sourceTree = ""; }; 55 | 27C3981122A820EB00590E12 /* IndividualDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndividualDetailView.swift; sourceTree = ""; }; 56 | 27C3981322A8211500590E12 /* IndividualListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndividualListView.swift; sourceTree = ""; }; 57 | 27C3981622A82D7900590E12 /* IndividualDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndividualDetailViewModel.swift; sourceTree = ""; }; 58 | 27C3981822A82D8B00590E12 /* IndividualListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndividualListViewModel.swift; sourceTree = ""; }; 59 | 27C3981C22A83A5400590E12 /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; 60 | 27C3981E22A83B1800590E12 /* ResponseWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponseWrapper.swift; sourceTree = ""; }; 61 | 27C3982022A83B4B00590E12 /* AffiliationEnum.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AffiliationEnum.swift; sourceTree = ""; }; 62 | 27C3982222A83B7800590E12 /* Coordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Coordinator.swift; sourceTree = ""; }; 63 | 27C3982522A846D300590E12 /* DateFormatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatters.swift; sourceTree = ""; }; 64 | 27C3982722A846EC00590E12 /* FileManagerExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManagerExtensions.swift; sourceTree = ""; }; 65 | 27C3982922A8476500590E12 /* individuals.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = individuals.json; sourceTree = ""; }; 66 | 27C3982B22A8496B00590E12 /* PreviewDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewDatabase.swift; sourceTree = ""; }; 67 | 27FE714822B9A11200D852AE /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; 68 | 27FE714A22B9A3B400D852AE /* LabelDetailRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelDetailRow.swift; sourceTree = ""; }; 69 | 27FE714C22B9B42A00D852AE /* NetworkLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkLogger.swift; sourceTree = ""; }; 70 | /* End PBXFileReference section */ 71 | 72 | /* Begin PBXFrameworksBuildPhase section */ 73 | 27C397F022A7286500590E12 /* Frameworks */ = { 74 | isa = PBXFrameworksBuildPhase; 75 | buildActionMask = 2147483647; 76 | files = ( 77 | FB7B2C352421770F003805B9 /* Swinject in Frameworks */, 78 | FB7B2C3824217827003805B9 /* Alamofire in Frameworks */, 79 | ); 80 | runOnlyForDeploymentPostprocessing = 0; 81 | }; 82 | /* End PBXFrameworksBuildPhase section */ 83 | 84 | /* Begin PBXGroup section */ 85 | 273F1EE622A8876300237A82 /* Services */ = { 86 | isa = PBXGroup; 87 | children = ( 88 | 273F1EE722A8876F00237A82 /* DirectoryService.swift */, 89 | 273F1EDE22A862E900237A82 /* ImageStore.swift */, 90 | ); 91 | path = Services; 92 | sourceTree = ""; 93 | }; 94 | 27C397EA22A7286500590E12 = { 95 | isa = PBXGroup; 96 | children = ( 97 | 2727105F22CD319600A90426 /* README.md */, 98 | 27C397F522A7286500590E12 /* Star Wars Contacts */, 99 | 27C397F422A7286500590E12 /* Products */, 100 | ); 101 | sourceTree = ""; 102 | }; 103 | 27C397F422A7286500590E12 /* Products */ = { 104 | isa = PBXGroup; 105 | children = ( 106 | 27C397F322A7286500590E12 /* Star Wars Contacts.app */, 107 | ); 108 | name = Products; 109 | sourceTree = ""; 110 | }; 111 | 27C397F522A7286500590E12 /* Star Wars Contacts */ = { 112 | isa = PBXGroup; 113 | children = ( 114 | 27C3980422A7286700590E12 /* Info.plist */, 115 | 27C397F622A7286500590E12 /* AppDelegate.swift */, 116 | 27C397F822A7286500590E12 /* SceneDelegate.swift */, 117 | 27C3980122A7286700590E12 /* LaunchScreen.storyboard */, 118 | 27C397FC22A7286700590E12 /* Assets.xcassets */, 119 | 27C3981B22A83A4600590E12 /* Coordinators */, 120 | 27C3981A22A82DBC00590E12 /* Models */, 121 | 27C397FE22A7286700590E12 /* Preview Content */, 122 | 273F1EE622A8876300237A82 /* Services */, 123 | 27C3982422A8469F00590E12 /* Statics */, 124 | 27C3981522A82D5F00590E12 /* ViewModels */, 125 | 27C3981022A820C700590E12 /* Views */, 126 | ); 127 | path = "Star Wars Contacts"; 128 | sourceTree = ""; 129 | }; 130 | 27C397FE22A7286700590E12 /* Preview Content */ = { 131 | isa = PBXGroup; 132 | children = ( 133 | 27C3982922A8476500590E12 /* individuals.json */, 134 | 27C3982B22A8496B00590E12 /* PreviewDatabase.swift */, 135 | 27C397FF22A7286700590E12 /* Preview Assets.xcassets */, 136 | ); 137 | path = "Preview Content"; 138 | sourceTree = ""; 139 | }; 140 | 27C3981022A820C700590E12 /* Views */ = { 141 | isa = PBXGroup; 142 | children = ( 143 | 27C3981122A820EB00590E12 /* IndividualDetailView.swift */, 144 | 27C3981322A8211500590E12 /* IndividualListView.swift */, 145 | 273F1EDC22A84F5D00237A82 /* IndividualRow.swift */, 146 | 27FE714A22B9A3B400D852AE /* LabelDetailRow.swift */, 147 | ); 148 | path = Views; 149 | sourceTree = ""; 150 | }; 151 | 27C3981522A82D5F00590E12 /* ViewModels */ = { 152 | isa = PBXGroup; 153 | children = ( 154 | 27C3981622A82D7900590E12 /* IndividualDetailViewModel.swift */, 155 | 27C3981822A82D8B00590E12 /* IndividualListViewModel.swift */, 156 | ); 157 | path = ViewModels; 158 | sourceTree = ""; 159 | }; 160 | 27C3981A22A82DBC00590E12 /* Models */ = { 161 | isa = PBXGroup; 162 | children = ( 163 | 27C3982022A83B4B00590E12 /* AffiliationEnum.swift */, 164 | 27C3980C22A72E5200590E12 /* IndividualModel.swift */, 165 | 27C3981E22A83B1800590E12 /* ResponseWrapper.swift */, 166 | ); 167 | path = Models; 168 | sourceTree = ""; 169 | }; 170 | 27C3981B22A83A4600590E12 /* Coordinators */ = { 171 | isa = PBXGroup; 172 | children = ( 173 | 27C3981C22A83A5400590E12 /* AppCoordinator.swift */, 174 | 27C3982222A83B7800590E12 /* Coordinator.swift */, 175 | ); 176 | path = Coordinators; 177 | sourceTree = ""; 178 | }; 179 | 27C3982422A8469F00590E12 /* Statics */ = { 180 | isa = PBXGroup; 181 | children = ( 182 | 27C3980E22A7400500590E12 /* Coders.swift */, 183 | 27C3982522A846D300590E12 /* DateFormatters.swift */, 184 | 27C3982722A846EC00590E12 /* FileManagerExtensions.swift */, 185 | 275A571822A8C93300BEB608 /* Injector.swift */, 186 | 27FE714822B9A11200D852AE /* Theme.swift */, 187 | 27FE714C22B9B42A00D852AE /* NetworkLogger.swift */, 188 | ); 189 | path = Statics; 190 | sourceTree = ""; 191 | }; 192 | /* End PBXGroup section */ 193 | 194 | /* Begin PBXNativeTarget section */ 195 | 27C397F222A7286500590E12 /* Star Wars Contacts */ = { 196 | isa = PBXNativeTarget; 197 | buildConfigurationList = 27C3980722A7286700590E12 /* Build configuration list for PBXNativeTarget "Star Wars Contacts" */; 198 | buildPhases = ( 199 | 27C397EF22A7286500590E12 /* Sources */, 200 | 27C397F022A7286500590E12 /* Frameworks */, 201 | 27C397F122A7286500590E12 /* Resources */, 202 | ); 203 | buildRules = ( 204 | ); 205 | dependencies = ( 206 | ); 207 | name = "Star Wars Contacts"; 208 | packageProductDependencies = ( 209 | FB7B2C342421770F003805B9 /* Swinject */, 210 | FB7B2C3724217827003805B9 /* Alamofire */, 211 | ); 212 | productName = "Star Wars Contacts"; 213 | productReference = 27C397F322A7286500590E12 /* Star Wars Contacts.app */; 214 | productType = "com.apple.product-type.application"; 215 | }; 216 | /* End PBXNativeTarget section */ 217 | 218 | /* Begin PBXProject section */ 219 | 27C397EB22A7286500590E12 /* Project object */ = { 220 | isa = PBXProject; 221 | attributes = { 222 | LastSwiftUpdateCheck = 1100; 223 | LastUpgradeCheck = 1100; 224 | ORGANIZATIONNAME = "Michael Holt"; 225 | TargetAttributes = { 226 | 27C397F222A7286500590E12 = { 227 | CreatedOnToolsVersion = 11.0; 228 | }; 229 | }; 230 | }; 231 | buildConfigurationList = 27C397EE22A7286500590E12 /* Build configuration list for PBXProject "Star Wars Contacts" */; 232 | compatibilityVersion = "Xcode 9.3"; 233 | developmentRegion = en; 234 | hasScannedForEncodings = 0; 235 | knownRegions = ( 236 | en, 237 | Base, 238 | ); 239 | mainGroup = 27C397EA22A7286500590E12; 240 | packageReferences = ( 241 | FB7B2C332421770F003805B9 /* XCRemoteSwiftPackageReference "Swinject" */, 242 | FB7B2C3624217827003805B9 /* XCRemoteSwiftPackageReference "Alamofire" */, 243 | ); 244 | productRefGroup = 27C397F422A7286500590E12 /* Products */; 245 | projectDirPath = ""; 246 | projectRoot = ""; 247 | targets = ( 248 | 27C397F222A7286500590E12 /* Star Wars Contacts */, 249 | ); 250 | }; 251 | /* End PBXProject section */ 252 | 253 | /* Begin PBXResourcesBuildPhase section */ 254 | 27C397F122A7286500590E12 /* Resources */ = { 255 | isa = PBXResourcesBuildPhase; 256 | buildActionMask = 2147483647; 257 | files = ( 258 | 27C3982A22A8476500590E12 /* individuals.json in Resources */, 259 | 27C3980322A7286700590E12 /* LaunchScreen.storyboard in Resources */, 260 | 27C3980022A7286700590E12 /* Preview Assets.xcassets in Resources */, 261 | 27C397FD22A7286700590E12 /* Assets.xcassets in Resources */, 262 | ); 263 | runOnlyForDeploymentPostprocessing = 0; 264 | }; 265 | /* End PBXResourcesBuildPhase section */ 266 | 267 | /* Begin PBXSourcesBuildPhase section */ 268 | 27C397EF22A7286500590E12 /* Sources */ = { 269 | isa = PBXSourcesBuildPhase; 270 | buildActionMask = 2147483647; 271 | files = ( 272 | 27C3981F22A83B1800590E12 /* ResponseWrapper.swift in Sources */, 273 | 27C3982822A846EC00590E12 /* FileManagerExtensions.swift in Sources */, 274 | 273F1EDF22A862E900237A82 /* ImageStore.swift in Sources */, 275 | 27C397F722A7286500590E12 /* AppDelegate.swift in Sources */, 276 | 27C3982122A83B4B00590E12 /* AffiliationEnum.swift in Sources */, 277 | 27C3982622A846D300590E12 /* DateFormatters.swift in Sources */, 278 | 27C3981422A8211500590E12 /* IndividualListView.swift in Sources */, 279 | 27C3981722A82D7900590E12 /* IndividualDetailViewModel.swift in Sources */, 280 | 275A571922A8C93300BEB608 /* Injector.swift in Sources */, 281 | 27C3980F22A7400600590E12 /* Coders.swift in Sources */, 282 | 27FE714D22B9B42A00D852AE /* NetworkLogger.swift in Sources */, 283 | 27C3981222A820EB00590E12 /* IndividualDetailView.swift in Sources */, 284 | 27C3982C22A8496B00590E12 /* PreviewDatabase.swift in Sources */, 285 | 27C397F922A7286500590E12 /* SceneDelegate.swift in Sources */, 286 | 27C3982322A83B7800590E12 /* Coordinator.swift in Sources */, 287 | 27FE714B22B9A3B400D852AE /* LabelDetailRow.swift in Sources */, 288 | 27C3981D22A83A5400590E12 /* AppCoordinator.swift in Sources */, 289 | 27C3980D22A72E5200590E12 /* IndividualModel.swift in Sources */, 290 | 273F1EE822A8876F00237A82 /* DirectoryService.swift in Sources */, 291 | 27FE714922B9A11200D852AE /* Theme.swift in Sources */, 292 | 27C3981922A82D8B00590E12 /* IndividualListViewModel.swift in Sources */, 293 | 273F1EDD22A84F5D00237A82 /* IndividualRow.swift in Sources */, 294 | ); 295 | runOnlyForDeploymentPostprocessing = 0; 296 | }; 297 | /* End PBXSourcesBuildPhase section */ 298 | 299 | /* Begin PBXVariantGroup section */ 300 | 27C3980122A7286700590E12 /* LaunchScreen.storyboard */ = { 301 | isa = PBXVariantGroup; 302 | children = ( 303 | 27C3980222A7286700590E12 /* Base */, 304 | ); 305 | name = LaunchScreen.storyboard; 306 | sourceTree = ""; 307 | }; 308 | /* End PBXVariantGroup section */ 309 | 310 | /* Begin XCBuildConfiguration section */ 311 | 27C3980522A7286700590E12 /* Debug */ = { 312 | isa = XCBuildConfiguration; 313 | buildSettings = { 314 | ALWAYS_SEARCH_USER_PATHS = NO; 315 | CLANG_ANALYZER_NONNULL = YES; 316 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 317 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 318 | CLANG_CXX_LIBRARY = "libc++"; 319 | CLANG_ENABLE_MODULES = YES; 320 | CLANG_ENABLE_OBJC_ARC = YES; 321 | CLANG_ENABLE_OBJC_WEAK = YES; 322 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 323 | CLANG_WARN_BOOL_CONVERSION = YES; 324 | CLANG_WARN_COMMA = YES; 325 | CLANG_WARN_CONSTANT_CONVERSION = YES; 326 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 327 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 328 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 329 | CLANG_WARN_EMPTY_BODY = YES; 330 | CLANG_WARN_ENUM_CONVERSION = YES; 331 | CLANG_WARN_INFINITE_RECURSION = YES; 332 | CLANG_WARN_INT_CONVERSION = YES; 333 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 334 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 335 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 336 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 337 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 338 | CLANG_WARN_STRICT_PROTOTYPES = YES; 339 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 340 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 341 | CLANG_WARN_UNREACHABLE_CODE = YES; 342 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 343 | COPY_PHASE_STRIP = NO; 344 | DEBUG_INFORMATION_FORMAT = dwarf; 345 | ENABLE_STRICT_OBJC_MSGSEND = YES; 346 | ENABLE_TESTABILITY = YES; 347 | GCC_C_LANGUAGE_STANDARD = gnu11; 348 | GCC_DYNAMIC_NO_PIC = NO; 349 | GCC_NO_COMMON_BLOCKS = YES; 350 | GCC_OPTIMIZATION_LEVEL = 0; 351 | GCC_PREPROCESSOR_DEFINITIONS = ( 352 | "DEBUG=1", 353 | "$(inherited)", 354 | ); 355 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 356 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 357 | GCC_WARN_UNDECLARED_SELECTOR = YES; 358 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 359 | GCC_WARN_UNUSED_FUNCTION = YES; 360 | GCC_WARN_UNUSED_VARIABLE = YES; 361 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 362 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 363 | MTL_FAST_MATH = YES; 364 | ONLY_ACTIVE_ARCH = YES; 365 | SDKROOT = iphoneos; 366 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 367 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 368 | SWIFT_VERSION = 5.0; 369 | }; 370 | name = Debug; 371 | }; 372 | 27C3980622A7286700590E12 /* Release */ = { 373 | isa = XCBuildConfiguration; 374 | buildSettings = { 375 | ALWAYS_SEARCH_USER_PATHS = NO; 376 | CLANG_ANALYZER_NONNULL = YES; 377 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 378 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 379 | CLANG_CXX_LIBRARY = "libc++"; 380 | CLANG_ENABLE_MODULES = YES; 381 | CLANG_ENABLE_OBJC_ARC = YES; 382 | CLANG_ENABLE_OBJC_WEAK = YES; 383 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 384 | CLANG_WARN_BOOL_CONVERSION = YES; 385 | CLANG_WARN_COMMA = YES; 386 | CLANG_WARN_CONSTANT_CONVERSION = YES; 387 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 388 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 389 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 390 | CLANG_WARN_EMPTY_BODY = YES; 391 | CLANG_WARN_ENUM_CONVERSION = YES; 392 | CLANG_WARN_INFINITE_RECURSION = YES; 393 | CLANG_WARN_INT_CONVERSION = YES; 394 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 395 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 396 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 397 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 398 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 399 | CLANG_WARN_STRICT_PROTOTYPES = YES; 400 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 401 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 402 | CLANG_WARN_UNREACHABLE_CODE = YES; 403 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 404 | COPY_PHASE_STRIP = NO; 405 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 406 | ENABLE_NS_ASSERTIONS = NO; 407 | ENABLE_STRICT_OBJC_MSGSEND = YES; 408 | GCC_C_LANGUAGE_STANDARD = gnu11; 409 | GCC_NO_COMMON_BLOCKS = YES; 410 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 411 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 412 | GCC_WARN_UNDECLARED_SELECTOR = YES; 413 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 414 | GCC_WARN_UNUSED_FUNCTION = YES; 415 | GCC_WARN_UNUSED_VARIABLE = YES; 416 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 417 | MTL_ENABLE_DEBUG_INFO = NO; 418 | MTL_FAST_MATH = YES; 419 | SDKROOT = iphoneos; 420 | SWIFT_COMPILATION_MODE = wholemodule; 421 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 422 | SWIFT_VERSION = 5.0; 423 | VALIDATE_PRODUCT = YES; 424 | }; 425 | name = Release; 426 | }; 427 | 27C3980822A7286700590E12 /* Debug */ = { 428 | isa = XCBuildConfiguration; 429 | buildSettings = { 430 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 431 | CODE_SIGN_STYLE = Automatic; 432 | DEVELOPMENT_ASSET_PATHS = "\"Star Wars Contacts\"/Preview\\ Content"; 433 | ENABLE_PREVIEWS = YES; 434 | INFOPLIST_FILE = "Star Wars Contacts/Info.plist"; 435 | LD_RUNPATH_SEARCH_PATHS = ( 436 | "$(inherited)", 437 | "@executable_path/Frameworks", 438 | ); 439 | PRODUCT_BUNDLE_IDENTIFIER = "com.Star-Wars-Contacts"; 440 | PRODUCT_NAME = "$(TARGET_NAME)"; 441 | TARGETED_DEVICE_FAMILY = "1,2"; 442 | }; 443 | name = Debug; 444 | }; 445 | 27C3980922A7286700590E12 /* Release */ = { 446 | isa = XCBuildConfiguration; 447 | buildSettings = { 448 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 449 | CODE_SIGN_STYLE = Automatic; 450 | DEVELOPMENT_ASSET_PATHS = "\"Star Wars Contacts\"/Preview\\ Content"; 451 | ENABLE_PREVIEWS = YES; 452 | INFOPLIST_FILE = "Star Wars Contacts/Info.plist"; 453 | LD_RUNPATH_SEARCH_PATHS = ( 454 | "$(inherited)", 455 | "@executable_path/Frameworks", 456 | ); 457 | PRODUCT_BUNDLE_IDENTIFIER = "com.Star-Wars-Contacts"; 458 | PRODUCT_NAME = "$(TARGET_NAME)"; 459 | TARGETED_DEVICE_FAMILY = "1,2"; 460 | }; 461 | name = Release; 462 | }; 463 | /* End XCBuildConfiguration section */ 464 | 465 | /* Begin XCConfigurationList section */ 466 | 27C397EE22A7286500590E12 /* Build configuration list for PBXProject "Star Wars Contacts" */ = { 467 | isa = XCConfigurationList; 468 | buildConfigurations = ( 469 | 27C3980522A7286700590E12 /* Debug */, 470 | 27C3980622A7286700590E12 /* Release */, 471 | ); 472 | defaultConfigurationIsVisible = 0; 473 | defaultConfigurationName = Release; 474 | }; 475 | 27C3980722A7286700590E12 /* Build configuration list for PBXNativeTarget "Star Wars Contacts" */ = { 476 | isa = XCConfigurationList; 477 | buildConfigurations = ( 478 | 27C3980822A7286700590E12 /* Debug */, 479 | 27C3980922A7286700590E12 /* Release */, 480 | ); 481 | defaultConfigurationIsVisible = 0; 482 | defaultConfigurationName = Release; 483 | }; 484 | /* End XCConfigurationList section */ 485 | 486 | /* Begin XCRemoteSwiftPackageReference section */ 487 | FB7B2C332421770F003805B9 /* XCRemoteSwiftPackageReference "Swinject" */ = { 488 | isa = XCRemoteSwiftPackageReference; 489 | repositoryURL = "https://github.com/Swinject/Swinject.git"; 490 | requirement = { 491 | kind = upToNextMajorVersion; 492 | minimumVersion = 2.7.1; 493 | }; 494 | }; 495 | FB7B2C3624217827003805B9 /* XCRemoteSwiftPackageReference "Alamofire" */ = { 496 | isa = XCRemoteSwiftPackageReference; 497 | repositoryURL = "https://github.com/Alamofire/Alamofire.git"; 498 | requirement = { 499 | kind = upToNextMajorVersion; 500 | minimumVersion = 5.0.4; 501 | }; 502 | }; 503 | /* End XCRemoteSwiftPackageReference section */ 504 | 505 | /* Begin XCSwiftPackageProductDependency section */ 506 | FB7B2C342421770F003805B9 /* Swinject */ = { 507 | isa = XCSwiftPackageProductDependency; 508 | package = FB7B2C332421770F003805B9 /* XCRemoteSwiftPackageReference "Swinject" */; 509 | productName = Swinject; 510 | }; 511 | FB7B2C3724217827003805B9 /* Alamofire */ = { 512 | isa = XCSwiftPackageProductDependency; 513 | package = FB7B2C3624217827003805B9 /* XCRemoteSwiftPackageReference "Alamofire" */; 514 | productName = Alamofire; 515 | }; 516 | /* End XCSwiftPackageProductDependency section */ 517 | }; 518 | rootObject = 27C397EB22A7286500590E12 /* Project object */; 519 | } 520 | -------------------------------------------------------------------------------- /Star Wars Contacts.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Star Wars Contacts.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Star Wars Contacts.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Alamofire", 6 | "repositoryURL": "https://github.com/Alamofire/Alamofire.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "11928850d7273a8cd41bb766f2fc93b4d724b79b", 10 | "version": "5.0.4" 11 | } 12 | }, 13 | { 14 | "package": "Swinject", 15 | "repositoryURL": "https://github.com/Swinject/Swinject.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "8a76d2c74bafbb455763487cc6a08e91bad1f78b", 19 | "version": "2.7.1" 20 | } 21 | } 22 | ] 23 | }, 24 | "version": 1 25 | } 26 | -------------------------------------------------------------------------------- /Star Wars Contacts.xcodeproj/xcshareddata/xcschemes/Star Wars Contacts.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Star Wars Contacts/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Star Wars Contacts 4 | // 5 | // Created by Michael Holt on 6/4/19. 6 | // Copyright © 2019 Michael Holt. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @UIApplicationMain 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 15 | // Override point for customization after application launch. 16 | return true 17 | } 18 | 19 | func applicationWillTerminate(_ application: UIApplication) { 20 | // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. 21 | } 22 | 23 | // MARK: UISceneSession Lifecycle 24 | 25 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 26 | // Called when a new scene session is being created. 27 | // Use this method to select a configuration to create the new scene with. 28 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 29 | } 30 | 31 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 32 | // Called when the user discards a scene session. 33 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 34 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 35 | } 36 | 37 | 38 | } 39 | 40 | -------------------------------------------------------------------------------- /Star Wars Contacts/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /Star Wars Contacts/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Star Wars Contacts/Assets.xcassets/user.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "user-alt.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template" 14 | } 15 | } -------------------------------------------------------------------------------- /Star Wars Contacts/Assets.xcassets/user.imageset/user-alt.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CutFlame/StarWarsContacts/4970cc2c7960c09cbbb7e91380710cd28ca64ea4/Star Wars Contacts/Assets.xcassets/user.imageset/user-alt.pdf -------------------------------------------------------------------------------- /Star Wars Contacts/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 | -------------------------------------------------------------------------------- /Star Wars Contacts/Coordinators/AppCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppCoordinator.swift 3 | // Star Wars Contacts 4 | // 5 | // Created by Michael Holt on 6/5/19. 6 | // Copyright © 2019 Michael Holt. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SwiftUI 11 | import Combine 12 | 13 | class AppCoordinator: Coordinator, CoordinatorProtocol { 14 | let window: UIWindow 15 | 16 | var cancellables = [String: AnyCancellable]() 17 | 18 | init(window: UIWindow) { 19 | self.window = window 20 | super.init() 21 | } 22 | 23 | var navigationController: UINavigationController! { 24 | window.rootViewController as? UINavigationController 25 | } 26 | 27 | func start() { 28 | showLaunchScreen() 29 | 30 | DispatchQueue.main.async { [weak self] in 31 | self?.showListScreen() 32 | } 33 | } 34 | 35 | private func showLaunchScreen() { 36 | let storyboard = UIStoryboard(name: "LaunchScreen", bundle: nil) 37 | guard let viewController = storyboard.instantiateInitialViewController() else { 38 | fatalError("Could not instantiate initial view controller from storyboard") 39 | } 40 | window.rootViewController = viewController 41 | } 42 | 43 | private func showListScreen() { 44 | let viewModel = IndividualListViewModel() 45 | viewModel.fetchItems() 46 | cancellables["showList"] = viewModel.didSelectedIndividual 47 | .sink { [weak self] (item) in 48 | self?.showDetailScreen(item) 49 | } 50 | 51 | // Use a UIHostingController as window root view controller 52 | let view = IndividualListView().environmentObject(viewModel) 53 | let controller = UIHostingController(rootView: view) 54 | let nav = UINavigationController(rootViewController: controller) 55 | nav.navigationBar.isHidden = true 56 | window.rootViewController = nav 57 | } 58 | 59 | private func showDetailScreen(_ item:IndividualModel) { 60 | let viewModel = IndividualDetailViewModel(model: item) 61 | cancellables["detailBack"] = viewModel.didNavigateBack 62 | .sink { [weak self] in 63 | self?.navigationController.popViewController(animated: true) 64 | } 65 | let view = IndividualDetailView().environmentObject(viewModel) 66 | let controller = UIHostingController(rootView: view) 67 | navigationController.pushViewController(controller, animated: true) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Star Wars Contacts/Coordinators/Coordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Coordinator.swift 3 | // Star Wars Contacts 4 | // 5 | // Created by Michael Holt on 6/5/19. 6 | // Copyright © 2019 Michael Holt. All rights reserved. 7 | // 8 | 9 | protocol CoordinatorProtocol { 10 | var child: CoordinatorProtocol? { get } 11 | func start() 12 | } 13 | 14 | class Coordinator { 15 | var child: CoordinatorProtocol? 16 | } 17 | -------------------------------------------------------------------------------- /Star Wars Contacts/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 | UILaunchStoryboardName 33 | LaunchScreen 34 | UISceneConfigurationName 35 | Default Configuration 36 | UISceneDelegateClassName 37 | $(PRODUCT_MODULE_NAME).SceneDelegate 38 | 39 | 40 | 41 | 42 | UILaunchStoryboardName 43 | LaunchScreen 44 | UIRequiredDeviceCapabilities 45 | 46 | armv7 47 | 48 | UISupportedInterfaceOrientations 49 | 50 | UIInterfaceOrientationPortrait 51 | UIInterfaceOrientationLandscapeLeft 52 | UIInterfaceOrientationLandscapeRight 53 | 54 | UISupportedInterfaceOrientations~ipad 55 | 56 | UIInterfaceOrientationPortrait 57 | UIInterfaceOrientationPortraitUpsideDown 58 | UIInterfaceOrientationLandscapeLeft 59 | UIInterfaceOrientationLandscapeRight 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /Star Wars Contacts/Models/AffiliationEnum.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AffiliationEnum.swift 3 | // Star Wars Contacts 4 | // 5 | // Created by Michael Holt on 6/5/19. 6 | // Copyright © 2019 Michael Holt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum AffiliationEnum: String, Codable { 12 | case jedi = "JEDI" 13 | case resistance = "RESISTANCE" 14 | case firstOrder = "FIRST_ORDER" 15 | case sith = "SITH" 16 | } 17 | -------------------------------------------------------------------------------- /Star Wars Contacts/Models/IndividualModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IndividualModel.swift 3 | // Star Wars Contacts 4 | // 5 | // Created by Michael Holt on 6/5/19. 6 | // Copyright © 2019 Michael Holt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | typealias ID = Int 12 | typealias ImageID = String 13 | 14 | struct IndividualModel: Codable { 15 | let id: ID 16 | let firstName: String 17 | let lastName: String 18 | let birthdate: Date 19 | let profilePictureURL: URL 20 | let isForceSensitive: Bool 21 | let affiliation: AffiliationEnum 22 | 23 | enum CodingKeys: String, CodingKey { 24 | case id = "id" 25 | case firstName = "firstName" 26 | case lastName = "lastName" 27 | case birthdate = "birthdate" 28 | case profilePictureURL = "profilePicture" 29 | case isForceSensitive = "forceSensitive" 30 | case affiliation = "affiliation" 31 | } 32 | } 33 | 34 | extension IndividualModel { 35 | var fullName: String { 36 | var names = [String]() 37 | if !firstName.isEmpty { 38 | names.append(firstName) 39 | } 40 | if !lastName.isEmpty { 41 | names.append(lastName) 42 | } 43 | return names.joined(separator: " ") 44 | } 45 | var profilePictureLookupKey: ImageID { 46 | return profilePictureURL.lastPathComponent 47 | } 48 | } 49 | 50 | extension IndividualModel { 51 | init(data: Data) throws { 52 | self = try Decoders.json.decode(IndividualModel.self, from: data) 53 | } 54 | 55 | init(_ json: String, using encoding: String.Encoding = .utf8) throws { 56 | guard let data = json.data(using: encoding) else { 57 | throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil) 58 | } 59 | try self.init(data: data) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Star Wars Contacts/Models/ResponseWrapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResponseWrapper.swift 3 | // Star Wars Contacts 4 | // 5 | // Created by Michael Holt on 6/5/19. 6 | // Copyright © 2019 Michael Holt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct ResponseWrapper: Codable { 12 | let individuals: [IndividualModel] 13 | } 14 | 15 | extension ResponseWrapper { 16 | init(data: Data) throws { 17 | self = try Decoders.json.decode(ResponseWrapper.self, from: data) 18 | } 19 | 20 | init(_ json: String, using encoding: String.Encoding = .utf8) throws { 21 | guard let data = json.data(using: encoding) else { 22 | throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil) 23 | } 24 | try self.init(data: data) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Star Wars Contacts/Preview Content/Preview Assets.xcassets/07.imageset/07.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CutFlame/StarWarsContacts/4970cc2c7960c09cbbb7e91380710cd28ca64ea4/Star Wars Contacts/Preview Content/Preview Assets.xcassets/07.imageset/07.png -------------------------------------------------------------------------------- /Star Wars Contacts/Preview Content/Preview Assets.xcassets/07.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "07.png" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /Star Wars Contacts/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Star Wars Contacts/Preview Content/PreviewDatabase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewDatabase.swift 3 | // Star Wars Contacts 4 | // 5 | // Created by Michael Holt on 6/5/19. 6 | // Copyright © 2019 Michael Holt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | #if DEBUG 12 | enum PreviewDatabase { 13 | static let individuals:[IndividualModel] = FileManager.load("individuals.json", as: ResponseWrapper.self).individuals 14 | } 15 | #endif 16 | -------------------------------------------------------------------------------- /Star Wars Contacts/Preview Content/individuals.json: -------------------------------------------------------------------------------- 1 | { 2 | "individuals": [ 3 | { 4 | "id":1, 5 | "firstName":"Luke", 6 | "lastName":"Skywalker", 7 | "birthdate":"1963-05-05", 8 | "profilePicture":"07", 9 | "forceSensitive":true, 10 | "affiliation":"JEDI" 11 | }, 12 | { 13 | "id":2, 14 | "firstName":"Leia", 15 | "lastName":"Organa", 16 | "birthdate":"1963-05-05", 17 | "profilePicture":"06", 18 | "forceSensitive":true, 19 | "affiliation":"RESISTANCE" 20 | }, 21 | { 22 | "id":3, 23 | "firstName":"Han", 24 | "lastName":"Solo", 25 | "birthdate":"1956-08-22", 26 | "profilePicture":"04", 27 | "forceSensitive":false, 28 | "affiliation":"RESISTANCE" 29 | }, 30 | { 31 | "id":4, 32 | "firstName":"Chewbacca", 33 | "lastName":"", 34 | "birthdate":"1782-11-15", 35 | "profilePicture":"01", 36 | "forceSensitive":false, 37 | "affiliation":"RESISTANCE" 38 | }, 39 | { 40 | "id":5, 41 | "firstName":"Kylo", 42 | "lastName":"Ren", 43 | "birthdate":"1987-10-31", 44 | "profilePicture":"05", 45 | "forceSensitive":true, 46 | "affiliation":"FIRST_ORDER" 47 | }, 48 | { 49 | "id":6, 50 | "firstName":"Supreme Leader", 51 | "lastName":"Snoke", 52 | "birthdate":"1947-01-01", 53 | "profilePicture":"08", 54 | "forceSensitive":true, 55 | "affiliation":"FIRST_ORDER" 56 | }, 57 | { 58 | "id":7, 59 | "firstName":"General", 60 | "lastName":"Hux", 61 | "birthdate":"1982-07-04", 62 | "profilePicture":"03", 63 | "forceSensitive":false, 64 | "affiliation":"FIRST_ORDER" 65 | }, 66 | { 67 | "id":8, 68 | "firstName":"Darth", 69 | "lastName":"Vader", 70 | "birthdate":"1947-07-13", 71 | "profilePicture":"02", 72 | "forceSensitive":true, 73 | "affiliation":"SITH" 74 | } 75 | ] 76 | } 77 | -------------------------------------------------------------------------------- /Star Wars Contacts/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // Star Wars Contacts 4 | // 5 | // Created by Michael Holt on 6/4/19. 6 | // Copyright © 2019 Michael Holt. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 12 | 13 | var window: UIWindow? 14 | var coordinator: CoordinatorProtocol! 15 | 16 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 17 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 18 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 19 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 20 | 21 | // Use a UIHostingController as window root view controller 22 | if let windowScene = scene as? UIWindowScene { 23 | let window = UIWindow(windowScene: windowScene) 24 | self.window = window 25 | coordinator = AppCoordinator(window: window) 26 | coordinator.start() 27 | window.makeKeyAndVisible() 28 | } 29 | 30 | } 31 | 32 | func sceneDidDisconnect(_ scene: UIScene) { 33 | // Called as the scene is being released by the system. 34 | // This occurs shortly after the scene enters the background, or when its session is discarded. 35 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 36 | // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). 37 | } 38 | 39 | func sceneDidBecomeActive(_ scene: UIScene) { 40 | // Called when the scene has moved from an inactive state to an active state. 41 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 42 | } 43 | 44 | func sceneWillResignActive(_ scene: UIScene) { 45 | // Called when the scene will move from an active state to an inactive state. 46 | // This may occur due to temporary interruptions (ex. an incoming phone call). 47 | } 48 | 49 | func sceneWillEnterForeground(_ scene: UIScene) { 50 | // Called as the scene transitions from the background to the foreground. 51 | // Use this method to undo the changes made on entering the background. 52 | } 53 | 54 | func sceneDidEnterBackground(_ scene: UIScene) { 55 | // Called as the scene transitions from the foreground to the background. 56 | // Use this method to save data, release shared resources, and store enough scene-specific state information 57 | // to restore the scene back to its current state. 58 | } 59 | 60 | 61 | } 62 | 63 | -------------------------------------------------------------------------------- /Star Wars Contacts/Services/DirectoryService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DirectoryService.swift 3 | // Star Wars Contacts 4 | // 5 | // Created by Michael Holt on 6/5/19. 6 | // Copyright © 2019 Michael Holt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import enum Alamofire.AFError 11 | import class Alamofire.Session 12 | import struct Alamofire.DataResponse 13 | 14 | typealias ResultHandler = (Result) -> () 15 | 16 | typealias DirectoryResult = Result<[IndividualModel], Error> 17 | typealias DirectoryResultHandler = ResultHandler<[IndividualModel]> 18 | typealias DataResultHandler = ResultHandler 19 | 20 | protocol DirectoryServiceProtocol { 21 | func fetchDirectory(_ handler: @escaping DirectoryResultHandler) 22 | func fetchData(_ url: URL, _ handler: @escaping DataResultHandler) 23 | } 24 | 25 | class DirectoryService: DirectoryServiceProtocol { 26 | private let session: Session 27 | init(resolver: DependencyResolver = DependencyContainer.resolver) { 28 | self.session = resolver.resolve() 29 | } 30 | 31 | func fetchDirectory(_ handler: @escaping DirectoryResultHandler) { 32 | let directoryURL = URL(string: "https://edge.ldscdn.org/mobile/interview/directory")! 33 | session.request(directoryURL) 34 | .responseData { [weak self] response in 35 | let urlRequest = response.request 36 | print("Network Response: \(urlRequest?.httpMethod ?? "") \(urlRequest?.url?.absoluteString ?? "")") 37 | 38 | self?.handleDirectoryResponse(response, handler) 39 | } 40 | } 41 | 42 | func handleDirectoryResponse(_ response:DataResponse, _ handler: DirectoryResultHandler) { 43 | switch response.result { 44 | case .success(let data): 45 | do { 46 | let wrapper = try ResponseWrapper(data: data) 47 | handler(.success(wrapper.individuals)) 48 | } 49 | catch { 50 | handler(.failure(error)) 51 | } 52 | case .failure(let error): 53 | handler(.failure(error)) 54 | } 55 | } 56 | 57 | func fetchData(_ url: URL, _ handler: @escaping DataResultHandler) { 58 | session.request(url) 59 | .responseData { response in 60 | let urlRequest = response.request 61 | print("Network Response: \(urlRequest?.httpMethod ?? "") \(urlRequest?.url?.absoluteString ?? "")") 62 | // why do I have to do this? AFError is an error. Weird... 63 | let theResult: Result 64 | switch response.result { 65 | case .failure(let err): 66 | theResult = .failure(err) 67 | case .success(let data): 68 | theResult = .success(data) 69 | } 70 | 71 | handler(theResult) 72 | } 73 | } 74 | } 75 | 76 | -------------------------------------------------------------------------------- /Star Wars Contacts/Services/ImageStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageStore.swift 3 | // Star Wars Contacts 4 | // 5 | // Created by Michael Holt on 6/5/19. 6 | // Copyright © 2019 Michael Holt. All rights reserved. 7 | // 8 | 9 | import CoreGraphics 10 | import SwiftUI 11 | import MobileCoreServices 12 | 13 | protocol ImageStoreProtocol { 14 | func addImage(for key:String, data: Data) 15 | func addImage(for key:String, image: CGImage) 16 | func getImage(for key:String) -> CGImage? 17 | func hasImage(for key:String) -> Bool 18 | func deleteImages() 19 | } 20 | 21 | class ImageStore: ImageStoreProtocol { 22 | private var imageCache = [ImageID:CGImage]() 23 | 24 | init() { 25 | loadCache() 26 | } 27 | 28 | private(set) var imageCacheDirectoryURL: URL = { 29 | let urls = FileManager.default.urls(for: FileManager.SearchPathDirectory.cachesDirectory, in: FileManager.SearchPathDomainMask.userDomainMask) 30 | guard let cacheDirectory = urls.first else { 31 | fatalError("Could not get cachesDirectory") 32 | } 33 | let cacheDirectoryURL = cacheDirectory.appendingPathComponent("Images", isDirectory: true) 34 | try? FileManager.default.createDirectory(at: cacheDirectoryURL, withIntermediateDirectories: true, attributes: nil) 35 | return cacheDirectoryURL 36 | }() 37 | 38 | private var imageCacheContents: [String] { 39 | let contents = (try? FileManager.default.contentsOfDirectory(atPath: imageCacheDirectoryURL.path)) ?? [] 40 | return contents 41 | } 42 | 43 | private func removeCache() { 44 | for fileName in imageCacheContents { 45 | let url = imageCacheDirectoryURL.appendingPathComponent(fileName) 46 | do { 47 | try FileManager.default.removeItem(at: url) 48 | } catch { 49 | print(error) 50 | } 51 | } 52 | } 53 | 54 | private func loadCache() { 55 | for fileName in imageCacheContents { 56 | loadCacheImage(fileName: fileName) 57 | } 58 | } 59 | 60 | private func loadCacheImage(fileName: String) { 61 | let url = imageCacheDirectoryURL.appendingPathComponent(fileName) 62 | loadCacheImage(url: url) 63 | } 64 | 65 | let validFileExtensions = ["png", "jpg", "gif"] 66 | 67 | private func loadCacheImage(url: URL) { 68 | if !validFileExtensions.contains(url.pathExtension) { 69 | //print("Not a valid file extension: '\(url.pathExtension)'") 70 | return 71 | } 72 | let key = url.lastPathComponent 73 | let data: Data 74 | do { 75 | data = try Data(contentsOf: url) 76 | } catch { 77 | print(error) 78 | return 79 | } 80 | guard let image = convertToImage(data) else { 81 | print("ERROR: failed to convert data to image: \(url)") 82 | return 83 | } 84 | imageCache[key] = image 85 | } 86 | 87 | private func saveCacheImage(key: ImageID, image: CGImage) { 88 | let url = imageCacheDirectoryURL.appendingPathComponent(key) 89 | saveCacheImage(url: url, image: image) 90 | } 91 | private func saveCacheImage(url: URL, image: CGImage) { 92 | writeCGImage(image, to: url) 93 | } 94 | 95 | @discardableResult 96 | func writeCGImage(_ image: CGImage, to destinationURL: URL) -> Bool { 97 | guard let destination = CGImageDestinationCreateWithURL(destinationURL as CFURL, kUTTypePNG, 1, nil) else { return false } 98 | CGImageDestinationAddImage(destination, image, nil) 99 | return CGImageDestinationFinalize(destination) 100 | } 101 | 102 | func deleteImages() { 103 | imageCache.removeAll() 104 | removeCache() 105 | } 106 | 107 | func addImage(for key:ImageID, data: Data) { 108 | if let image = convertToImage(data) { 109 | addImage(for: key, image: image) 110 | } else { 111 | handleError(POSIXError.init(.EFAULT)) 112 | } 113 | } 114 | 115 | func addImage(for key:ImageID, image: CGImage) { 116 | self.imageCache[key] = image 117 | saveCacheImage(key: key, image: image) 118 | } 119 | 120 | private func convertToImage(_ data:Data) -> CGImage? { 121 | guard 122 | let imageSource = CGImageSourceCreateWithData(data as CFData, nil), 123 | let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) 124 | else { 125 | return nil 126 | } 127 | return image 128 | } 129 | 130 | private func convertToData(_ image:CGImage) -> Data? { 131 | guard let data = image.dataProvider?.data else { 132 | return nil 133 | } 134 | return data as Data 135 | } 136 | 137 | func getImage(for key: ImageID) -> CGImage? { 138 | return imageCache[key] 139 | } 140 | 141 | func hasImage(for key: ImageID) -> Bool { 142 | return imageCache.keys.contains(key) 143 | } 144 | 145 | func handleError(_ error:Error) { 146 | print("ERROR: \(error)") 147 | } 148 | 149 | } 150 | -------------------------------------------------------------------------------- /Star Wars Contacts/Statics/Coders.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Coders.swift 3 | // Star Wars Contacts 4 | // 5 | // Created by Michael Holt on 6/4/19. 6 | // Copyright © 2019 Michael Holt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum Decoders { 12 | static let json: JSONDecoder = newJSONDecoder() 13 | 14 | private static func newJSONDecoder() -> JSONDecoder { 15 | let decoder = JSONDecoder() 16 | decoder.dateDecodingStrategy = dateDecodingStrategy 17 | return decoder 18 | } 19 | static let dateDecodingStrategy: JSONDecoder.DateDecodingStrategy = .custom(decodeDate) 20 | 21 | static func decodeDate(from decoder: Decoder) throws -> Date { 22 | let container = try decoder.singleValueContainer() 23 | let dateStr = try container.decode(String.self) 24 | guard let date = decodeDate(from: dateStr) else { 25 | throw DecodingError.typeMismatch(Date.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Could not decode date")) 26 | } 27 | return date 28 | } 29 | 30 | static func decodeDate(from dateStr:String) -> Date? { 31 | if let date = DateFormatters.simpleDate.date(from: dateStr) { 32 | return date 33 | } 34 | if let date = DateFormatters.timestampWithDecimalMiliseconds.date(from: dateStr) { 35 | return date 36 | } 37 | if let date = DateFormatters.timestampWithMiliseconds.date(from: dateStr) { 38 | return date 39 | } 40 | return nil 41 | } 42 | 43 | } 44 | 45 | enum Encoders { 46 | static let json: JSONEncoder = newJSONEncoder() 47 | 48 | private static func newJSONEncoder() -> JSONEncoder { 49 | let encoder = JSONEncoder() 50 | encoder.dateEncodingStrategy = .formatted(DateFormatters.simpleDate) 51 | return encoder 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Star Wars Contacts/Statics/DateFormatters.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DateFormatters.swift 3 | // Star Wars Contacts 4 | // 5 | // Created by Michael Holt on 6/5/19. 6 | // Copyright © 2019 Michael Holt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum DateFormatters { 12 | 13 | static var displayDate: DateFormatter = { 14 | let formatter = DateFormatter() 15 | formatter.calendar = Calendar(identifier: .iso8601) 16 | formatter.locale = Locale(identifier: "en_US_POSIX") 17 | formatter.timeZone = TimeZone(secondsFromGMT: 0) 18 | formatter.dateFormat = "MMM d, yyyy" 19 | return formatter 20 | }() 21 | 22 | static var simpleDate: DateFormatter = { 23 | let formatter = DateFormatter() 24 | formatter.calendar = Calendar(identifier: .iso8601) 25 | formatter.locale = Locale(identifier: "en_US_POSIX") 26 | formatter.timeZone = TimeZone(secondsFromGMT: 0) 27 | formatter.dateFormat = "yyyy-MM-dd" 28 | return formatter 29 | }() 30 | static var timestampWithDecimalMiliseconds: DateFormatter = { 31 | let formatter = DateFormatter() 32 | formatter.calendar = Calendar(identifier: .iso8601) 33 | formatter.locale = Locale(identifier: "en_US_POSIX") 34 | formatter.timeZone = TimeZone(secondsFromGMT: 0) 35 | formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX" 36 | return formatter 37 | }() 38 | static var timestampWithMiliseconds: DateFormatter = { 39 | let formatter = DateFormatter() 40 | formatter.calendar = Calendar(identifier: .iso8601) 41 | formatter.locale = Locale(identifier: "en_US_POSIX") 42 | formatter.timeZone = TimeZone(secondsFromGMT: 0) 43 | formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssXXXXX" 44 | return formatter 45 | }() 46 | } 47 | -------------------------------------------------------------------------------- /Star Wars Contacts/Statics/FileManagerExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileManagerExtensions.swift 3 | // Star Wars Contacts 4 | // 5 | // Created by Michael Holt on 6/5/19. 6 | // Copyright © 2019 Michael Holt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension FileManager { 12 | 13 | /// Load object from JSON file 14 | /// - Example usage: `let landmarkData: [Landmark] = load("landmarkData.json")` 15 | static func load(_ filename: String, as type: T.Type = T.self) -> T { 16 | let data: Data 17 | 18 | guard let file = Bundle.main.url(forResource: filename, withExtension: nil) 19 | else { 20 | fatalError("Couldn't find \(filename) in main bundle.") 21 | } 22 | 23 | do { 24 | data = try Data(contentsOf: file) 25 | } catch { 26 | fatalError("Couldn't load \(filename) from main bundle:\n\(error)") 27 | } 28 | 29 | do { 30 | let decoder = Decoders.json 31 | return try decoder.decode(T.self, from: data) 32 | } catch { 33 | fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)") 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Star Wars Contacts/Statics/Injector.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Injector.swift 3 | // Star Wars Contacts 4 | // 5 | // Created by Michael Holt on 6/5/19. 6 | // Copyright © 2019 Michael Holt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Swinject 11 | import Alamofire 12 | 13 | typealias DependencyResolver = Swinject.Resolver 14 | typealias DependencyContainer = Container 15 | 16 | extension DependencyContainer { 17 | static let resolver: DependencyResolver = Container() { container in 18 | container.register(RequestInterceptor.self, factory: { r in NetworkLogger() }) 19 | container.register(Session.self, factory: { r in Session(configuration: URLSessionConfiguration.default, interceptor: r.resolve()) }) 20 | container.register(ImageStoreProtocol.self, factory: { r in ImageStore() }) 21 | container.register(DirectoryServiceProtocol.self, factory: { r in DirectoryService(resolver: r) }) 22 | } 23 | static func resolve() -> T { 24 | return resolver.resolve() 25 | } 26 | static func resolve(_: T.Type) -> T { 27 | return resolver.resolve() 28 | } 29 | } 30 | 31 | extension DependencyResolver { 32 | func resolve() -> T { 33 | guard let result = self.resolve(T.self) else { 34 | fatalError("Could not resolve type: \(T.self). Did you forget to register it?") 35 | } 36 | return result 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Star Wars Contacts/Statics/NetworkLogger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkLogger.swift 3 | // Star Wars Contacts 4 | // 5 | // Created by Michael Holt on 6/18/19. 6 | // Copyright © 2019 Michael Holt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Alamofire 11 | 12 | class NetworkLogger: RequestInterceptor { 13 | func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (AFResult) -> Void) { 14 | print("Network Request: \(urlRequest.httpMethod ?? "") \(urlRequest.url?.absoluteString ?? "")") 15 | completion(.success(urlRequest)) 16 | } 17 | 18 | func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) { 19 | let urlRequest = request.request 20 | print("Network Response: \(urlRequest?.httpMethod ?? "") \(urlRequest?.url?.absoluteString ?? "")") 21 | completion(.doNotRetry) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Star Wars Contacts/Statics/Theme.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Theme.swift 3 | // Star Wars Contacts 4 | // 5 | // Created by Michael Holt on 6/18/19. 6 | // Copyright © 2019 Michael Holt. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | class Theme { 12 | static let defaultImage = UIImage(named: "user")!.cgImage! 13 | } 14 | -------------------------------------------------------------------------------- /Star Wars Contacts/ViewModels/IndividualDetailViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IndividualDetailViewModel.swift 3 | // Star Wars Contacts 4 | // 5 | // Created by Michael Holt on 6/5/19. 6 | // Copyright © 2019 Michael Holt. All rights reserved. 7 | // 8 | 9 | import protocol SwiftUI.ObservableObject 10 | import Foundation 11 | import Combine 12 | import CoreGraphics 13 | 14 | class IndividualDetailViewModel: ObservableObject { 15 | let didNavigateBack = PassthroughSubject() 16 | 17 | let imageStore: ImageStoreProtocol 18 | let directoryService: DirectoryServiceProtocol 19 | 20 | private let model: IndividualModel 21 | init(model:IndividualModel, resolver: DependencyResolver = DependencyContainer.resolver) { 22 | self.model = model 23 | self.imageStore = resolver.resolve() 24 | self.directoryService = resolver.resolve() 25 | } 26 | 27 | @Published private(set) var error: Error? = nil 28 | var id: ID { model.id } 29 | var birthdate: Date { model.birthdate } 30 | var isForceSensitive: Bool { model.isForceSensitive } 31 | var affiliation: AffiliationEnum { model.affiliation } 32 | var fullName: String { model.fullName } 33 | var imageID: ImageID { model.profilePictureLookupKey } 34 | 35 | func backAction() { 36 | didNavigateBack.send(()) 37 | } 38 | 39 | func fetchImage() { 40 | let key = model.profilePictureLookupKey 41 | if imageStore.hasImage(for: key) { return } 42 | directoryService.fetchData(model.profilePictureURL) { [weak self] result in 43 | self?.handleImageDataResult(key, result) 44 | } 45 | } 46 | 47 | func getImage() -> CGImage { 48 | return imageStore.getImage(for: imageID) ?? Theme.defaultImage 49 | } 50 | 51 | func handleImageDataResult(_ key:String, _ result:Result) { 52 | switch result { 53 | case .success(let data): 54 | self.objectWillChange.send() 55 | self.imageStore.addImage(for: key, data: data) 56 | case .failure(let error): 57 | self.error = error 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Star Wars Contacts/ViewModels/IndividualListViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IndividualListViewModel.swift 3 | // Star Wars Contacts 4 | // 5 | // Created by Michael Holt on 6/5/19. 6 | // Copyright © 2019 Michael Holt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import protocol SwiftUI.ObservableObject 11 | import Combine 12 | import CoreGraphics 13 | 14 | class IndividualListViewModel: ObservableObject { 15 | let didSelectedIndividual = PassthroughSubject() 16 | 17 | let directoryService: DirectoryServiceProtocol 18 | let imageStore: ImageStoreProtocol 19 | 20 | @Published private(set) var items = [IndividualModel]() 21 | @Published private(set) var error: Error? = nil 22 | 23 | init(items: [IndividualModel] = [], resolver: DependencyResolver = DependencyContainer.resolver) { 24 | self.items = items 25 | self.directoryService = resolver.resolve() 26 | self.imageStore = resolver.resolve() 27 | } 28 | 29 | func fetchItems() { 30 | directoryService.fetchDirectory(handleDirectoryResult) 31 | } 32 | func selectItem(item: IndividualModel) { 33 | didSelectedIndividual.send(item) 34 | } 35 | func fetchImageIfNeeded(item: IndividualModel) { 36 | let key = item.profilePictureLookupKey 37 | if imageStore.hasImage(for: key) { return } 38 | directoryService.fetchData(item.profilePictureURL) { [weak self] result in 39 | self?.handleImageDataResult(key, result) 40 | } 41 | } 42 | 43 | func getImage(for item: IndividualModel) -> CGImage { 44 | let key = item.profilePictureLookupKey 45 | return imageStore.getImage(for: key) ?? Theme.defaultImage 46 | } 47 | 48 | func handleImageDataResult(_ key:ImageID, _ result:Result) { 49 | switch result { 50 | case .success(let data): 51 | self.objectWillChange.send() 52 | self.imageStore.addImage(for: key, data: data) 53 | case .failure(let error): 54 | self.error = error 55 | } 56 | } 57 | 58 | func handleDirectoryResult(result: DirectoryResult) { 59 | switch result { 60 | case .success(let items): 61 | self.items = items 62 | case .failure(let error): 63 | print("ERROR: \(error)") 64 | } 65 | } 66 | 67 | 68 | } 69 | -------------------------------------------------------------------------------- /Star Wars Contacts/Views/IndividualDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IndividualDetailView.swift 3 | // Star Wars Contacts 4 | // 5 | // Created by Michael Holt on 6/5/19. 6 | // Copyright © 2019 Michael Holt. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct IndividualDetailView: View { 12 | @EnvironmentObject var viewModel: IndividualDetailViewModel 13 | 14 | var birthDate: String { 15 | DateFormatters.displayDate.string(from: self.viewModel.birthdate) 16 | } 17 | var isForceSensitive: String { 18 | self.viewModel.isForceSensitive ? "YES" : "NO" 19 | } 20 | var affiliation: String { 21 | self.viewModel.affiliation.rawValue 22 | } 23 | 24 | var body: some View { 25 | NavigationView { 26 | VStack { 27 | Image(decorative: viewModel.getImage(), scale: 1) 28 | .resizable() 29 | .aspectRatio(contentMode: .fill) 30 | .frame(width: 200, height: 200, alignment: .center) 31 | .clipShape(Circle()) 32 | .overlay(Circle().stroke(Color.white, lineWidth: 4)) 33 | .shadow(radius: 10) 34 | 35 | VStack(alignment: .leading) { 36 | Text(viewModel.fullName) 37 | .font(.title) 38 | .padding(10) 39 | 40 | LabelDetailRow(title: "Birthdate", value: birthDate) 41 | LabelDetailRow(title: "Force Sensitive", value: isForceSensitive) 42 | LabelDetailRow(title: "Affiliation", value: affiliation) 43 | } 44 | Spacer() 45 | } 46 | .padding() 47 | .navigationBarItems(leading: Button(action: viewModel.backAction) { 48 | Text("Back") 49 | }) 50 | } 51 | } 52 | 53 | } 54 | 55 | #if DEBUG 56 | struct IndividualDetailView_Previews : PreviewProvider { 57 | static var model = PreviewDatabase.individuals[0] 58 | static var viewModel = IndividualDetailViewModel(model: model) 59 | static var previews: some View { 60 | return IndividualDetailView() 61 | .environmentObject(viewModel) 62 | } 63 | } 64 | #endif 65 | -------------------------------------------------------------------------------- /Star Wars Contacts/Views/IndividualListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IndividualListView.swift 3 | // Star Wars Contacts 4 | // 5 | // Created by Michael Holt on 6/5/19. 6 | // Copyright © 2019 Michael Holt. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct IndividualListView: View { 12 | @EnvironmentObject var viewModel: IndividualListViewModel 13 | var body: some View { 14 | NavigationView { 15 | List(viewModel.items, id: \.id) { item in 16 | Button(action: { 17 | self.viewModel.selectItem(item: item) 18 | }, label: { 19 | IndividualRow(image: self.viewModel.getImage(for: item), name: item.fullName) 20 | }) 21 | .onAppear(perform: { 22 | self.viewModel.fetchImageIfNeeded(item: item) 23 | }) 24 | } 25 | .navigationBarTitle(Text("Individuals")) 26 | } 27 | } 28 | } 29 | 30 | #if DEBUG 31 | struct IndividualListView_Previews : PreviewProvider { 32 | static var models = PreviewDatabase.individuals 33 | static var viewModel = IndividualListViewModel(items: models) 34 | static var previews: some View { 35 | IndividualListView() 36 | .environmentObject(viewModel) 37 | } 38 | } 39 | #endif 40 | -------------------------------------------------------------------------------- /Star Wars Contacts/Views/IndividualRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IndividualRow.swift 3 | // Star Wars Contacts 4 | // 5 | // Created by Michael Holt on 6/5/19. 6 | // Copyright © 2019 Michael Holt. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct IndividualRow: View { 12 | @State var image: CGImage? = nil 13 | @State var name: String = "" 14 | 15 | var body: some View { 16 | HStack() { 17 | Image(decorative: image ?? Theme.defaultImage, scale: 20) 18 | .resizable() 19 | .aspectRatio(contentMode: .fill) 20 | .frame(width: 50, height: 50) 21 | .clipShape(Circle()) 22 | .overlay(Circle().stroke(Color.white, lineWidth: 2)) 23 | .shadow(radius: 10) 24 | 25 | Text(name) 26 | } 27 | } 28 | } 29 | 30 | #if DEBUG 31 | struct IndividualRow_Previews : PreviewProvider { 32 | static var model = PreviewDatabase.individuals[0] 33 | static var previews: some View { 34 | Group { 35 | IndividualRow(image: nil, name: "No Name") 36 | .previewLayout(.fixed(width: 300, height: 70)) 37 | IndividualRow(image: #imageLiteral(resourceName: "07").cgImage, name: "Luke S") 38 | .previewLayout(.fixed(width: 300, height: 70)) 39 | } 40 | } 41 | } 42 | #endif 43 | -------------------------------------------------------------------------------- /Star Wars Contacts/Views/LabelDetailRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LabelDetailRow.swift 3 | // Star Wars Contacts 4 | // 5 | // Created by Michael Holt on 6/18/19. 6 | // Copyright © 2019 Michael Holt. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct LabelDetailRow: View { 12 | @State var title: String 13 | @State var value: String 14 | 15 | var body: some View { 16 | HStack() { 17 | Text(title) 18 | .font(.subheadline) 19 | Spacer() 20 | Text(value) 21 | .font(.subheadline) 22 | } 23 | } 24 | } 25 | --------------------------------------------------------------------------------