├── EmployeeList.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── xcshareddata │ └── xcschemes │ │ └── EmployeeList.xcscheme └── xcuserdata │ └── siman.xcuserdatad │ ├── xcdebugger │ └── Breakpoints_v2.xcbkptlist │ └── xcschemes │ └── xcschememanagement.plist ├── EmployeeList ├── AppDelegate.swift ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── Contents.json │ └── iu.imageset │ │ ├── Contents.json │ │ └── iu.png ├── Base.lproj │ └── LaunchScreen.storyboard ├── Info.plist ├── Models │ ├── Employee.swift │ ├── EmployeeListResponse.swift │ └── Team.swift ├── Persistence │ └── OnDiskCache.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Resources │ ├── achitecture.png │ ├── default-avatar.jpg │ ├── employessListDataFlow.png │ ├── imageDataFlow.png │ └── iu.png ├── SceneDelegate.swift ├── Services │ ├── EmployeesAPIService.swift │ ├── ImageDiskService.swift │ └── ImageService.swift ├── Utils │ ├── APIRequest.swift │ ├── APIService.swift │ ├── APIServiceError.swift │ └── ImageServiceError.swift ├── ViewModels │ ├── CustomImageViewModel.swift │ ├── EmployeeListViewModel.swift │ └── postTest.swift └── Views │ ├── CustomImageView.swift │ ├── EmployeeDetailView.swift │ ├── EmployeeListRow.swift │ ├── EmployeeListView.swift │ ├── EmployeeProfile.swift │ └── EmployeeProfileEditor.swift ├── EmployeeListTests ├── EmployeeListTests.swift ├── Info.plist ├── Mocks │ ├── MockEmployeesService.swift │ ├── MockImageService.swift │ └── MockResponses.swift └── ViewModels │ ├── CustomImageViewModelTests.swift │ └── EmployeeListViewModelTests.swift └── README.md /EmployeeList.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 50; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | F34349FA25CB2BA500312F72 /* iu.png in Resources */ = {isa = PBXBuildFile; fileRef = F34349F925CB2BA500312F72 /* iu.png */; }; 11 | F34349FB25CB2BA500312F72 /* iu.png in Resources */ = {isa = PBXBuildFile; fileRef = F34349F925CB2BA500312F72 /* iu.png */; }; 12 | F34349FD25CB2E9600312F72 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = F34349FC25CB2E9600312F72 /* README.md */; }; 13 | F3434A0725CBA80800312F72 /* CustomImageViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3434A0625CBA80800312F72 /* CustomImageViewModelTests.swift */; }; 14 | F3434A0925CBB05800312F72 /* MockImageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3434A0825CBB05800312F72 /* MockImageService.swift */; }; 15 | F382495F25C4F16C00E6B92E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F382495E25C4F16C00E6B92E /* AppDelegate.swift */; }; 16 | F382496125C4F16C00E6B92E /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F382496025C4F16C00E6B92E /* SceneDelegate.swift */; }; 17 | F382496525C4F16F00E6B92E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F382496425C4F16F00E6B92E /* Assets.xcassets */; }; 18 | F382496825C4F16F00E6B92E /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F382496725C4F16F00E6B92E /* Preview Assets.xcassets */; }; 19 | F382496B25C4F16F00E6B92E /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F382496925C4F16F00E6B92E /* LaunchScreen.storyboard */; }; 20 | F382497625C4F16F00E6B92E /* EmployeeListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F382497525C4F16F00E6B92E /* EmployeeListTests.swift */; }; 21 | F382498C25C4FD7B00E6B92E /* APIRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = F382498B25C4FD7B00E6B92E /* APIRequest.swift */; }; 22 | F382498D25C4FD7B00E6B92E /* APIRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = F382498B25C4FD7B00E6B92E /* APIRequest.swift */; }; 23 | F382498F25C4FE3900E6B92E /* APIServiceError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F382498E25C4FE3900E6B92E /* APIServiceError.swift */; }; 24 | F382499025C4FE3900E6B92E /* APIServiceError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F382498E25C4FE3900E6B92E /* APIServiceError.swift */; }; 25 | F382499825C511DD00E6B92E /* EmployeesAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F382499725C511DD00E6B92E /* EmployeesAPIService.swift */; }; 26 | F382499925C511DD00E6B92E /* EmployeesAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F382499725C511DD00E6B92E /* EmployeesAPIService.swift */; }; 27 | F382499B25C5182900E6B92E /* ImageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F382499A25C5182900E6B92E /* ImageService.swift */; }; 28 | F382499C25C5182900E6B92E /* ImageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F382499A25C5182900E6B92E /* ImageService.swift */; }; 29 | F382499F25C51DA000E6B92E /* EmployeeListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F382499E25C51DA000E6B92E /* EmployeeListViewModel.swift */; }; 30 | F38249A025C51DA000E6B92E /* EmployeeListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F382499E25C51DA000E6B92E /* EmployeeListViewModel.swift */; }; 31 | F38249A325C5257200E6B92E /* EmployeeListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38249A225C5257200E6B92E /* EmployeeListView.swift */; }; 32 | F38249A425C5257200E6B92E /* EmployeeListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38249A225C5257200E6B92E /* EmployeeListView.swift */; }; 33 | F38249A625C526CE00E6B92E /* EmployeeListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38249A525C526CE00E6B92E /* EmployeeListRow.swift */; }; 34 | F38249A725C526CE00E6B92E /* EmployeeListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38249A525C526CE00E6B92E /* EmployeeListRow.swift */; }; 35 | F38249A925C5425E00E6B92E /* OnDiskCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38249A825C5425E00E6B92E /* OnDiskCache.swift */; }; 36 | F38249AA25C5425E00E6B92E /* OnDiskCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38249A825C5425E00E6B92E /* OnDiskCache.swift */; }; 37 | F38249AC25C79BF500E6B92E /* CustomImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38249AB25C79BF500E6B92E /* CustomImageView.swift */; }; 38 | F38249AD25C79BF500E6B92E /* CustomImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38249AB25C79BF500E6B92E /* CustomImageView.swift */; }; 39 | F38249B125C79D0200E6B92E /* CustomImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38249B025C79D0200E6B92E /* CustomImageViewModel.swift */; }; 40 | F38249B225C79D0200E6B92E /* CustomImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38249B025C79D0200E6B92E /* CustomImageViewModel.swift */; }; 41 | F38249B425C7F73600E6B92E /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38249B325C7F73600E6B92E /* APIService.swift */; }; 42 | F38249B525C7F73600E6B92E /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38249B325C7F73600E6B92E /* APIService.swift */; }; 43 | F38249B725CA235300E6B92E /* ImageServiceError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38249B625CA235300E6B92E /* ImageServiceError.swift */; }; 44 | F38249B825CA235300E6B92E /* ImageServiceError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38249B625CA235300E6B92E /* ImageServiceError.swift */; }; 45 | F38249BD25CA9A5200E6B92E /* EmployeeListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38249BC25CA9A5200E6B92E /* EmployeeListViewModelTests.swift */; }; 46 | F38249BF25CA9AC800E6B92E /* MockEmployeesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38249BE25CA9AC800E6B92E /* MockEmployeesService.swift */; }; 47 | F39657BC25F40D7A00EA0C00 /* EmployeeDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39657BB25F40D7A00EA0C00 /* EmployeeDetailView.swift */; }; 48 | F39657C025F45B1900EA0C00 /* EmployeeProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39657BF25F45B1900EA0C00 /* EmployeeProfileEditor.swift */; }; 49 | F39657C425F45B2800EA0C00 /* EmployeeProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39657C325F45B2800EA0C00 /* EmployeeProfile.swift */; }; 50 | F3E8FA9125CC00E300010FFF /* imageDataFlow.png in Resources */ = {isa = PBXBuildFile; fileRef = F3E8FA8E25CC00E300010FFF /* imageDataFlow.png */; }; 51 | F3E8FA9225CC00E300010FFF /* achitecture.png in Resources */ = {isa = PBXBuildFile; fileRef = F3E8FA8F25CC00E300010FFF /* achitecture.png */; }; 52 | F3E8FA9325CC00E300010FFF /* employessListDataFlow.png in Resources */ = {isa = PBXBuildFile; fileRef = F3E8FA9025CC00E300010FFF /* employessListDataFlow.png */; }; 53 | F3E8FAA125CC6A3F00010FFF /* EmployeeListResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3E8FA9E25CC6A3F00010FFF /* EmployeeListResponse.swift */; }; 54 | F3E8FAA225CC6A3F00010FFF /* EmployeeListResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3E8FA9E25CC6A3F00010FFF /* EmployeeListResponse.swift */; }; 55 | F3E8FAA325CC6A3F00010FFF /* Team.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3E8FA9F25CC6A3F00010FFF /* Team.swift */; }; 56 | F3E8FAA425CC6A3F00010FFF /* Team.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3E8FA9F25CC6A3F00010FFF /* Team.swift */; }; 57 | F3E8FAA525CC6A3F00010FFF /* Employee.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3E8FAA025CC6A3F00010FFF /* Employee.swift */; }; 58 | F3E8FAA625CC6A3F00010FFF /* Employee.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3E8FAA025CC6A3F00010FFF /* Employee.swift */; }; 59 | /* End PBXBuildFile section */ 60 | 61 | /* Begin PBXContainerItemProxy section */ 62 | F382497225C4F16F00E6B92E /* PBXContainerItemProxy */ = { 63 | isa = PBXContainerItemProxy; 64 | containerPortal = F382495325C4F16C00E6B92E /* Project object */; 65 | proxyType = 1; 66 | remoteGlobalIDString = F382495A25C4F16C00E6B92E; 67 | remoteInfo = EmployeeList; 68 | }; 69 | /* End PBXContainerItemProxy section */ 70 | 71 | /* Begin PBXFileReference section */ 72 | F34349F925CB2BA500312F72 /* iu.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = iu.png; sourceTree = ""; }; 73 | F34349FC25CB2E9600312F72 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 74 | F3434A0625CBA80800312F72 /* CustomImageViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomImageViewModelTests.swift; sourceTree = ""; }; 75 | F3434A0825CBB05800312F72 /* MockImageService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockImageService.swift; sourceTree = ""; }; 76 | F382495B25C4F16C00E6B92E /* EmployeeList.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EmployeeList.app; sourceTree = BUILT_PRODUCTS_DIR; }; 77 | F382495E25C4F16C00E6B92E /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 78 | F382496025C4F16C00E6B92E /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 79 | F382496425C4F16F00E6B92E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 80 | F382496725C4F16F00E6B92E /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 81 | F382496A25C4F16F00E6B92E /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 82 | F382496C25C4F16F00E6B92E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 83 | F382497125C4F16F00E6B92E /* EmployeeListTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EmployeeListTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 84 | F382497525C4F16F00E6B92E /* EmployeeListTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmployeeListTests.swift; sourceTree = ""; }; 85 | F382497725C4F16F00E6B92E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 86 | F382498B25C4FD7B00E6B92E /* APIRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIRequest.swift; sourceTree = ""; }; 87 | F382498E25C4FE3900E6B92E /* APIServiceError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIServiceError.swift; sourceTree = ""; }; 88 | F382499725C511DD00E6B92E /* EmployeesAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmployeesAPIService.swift; sourceTree = ""; }; 89 | F382499A25C5182900E6B92E /* ImageService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageService.swift; sourceTree = ""; }; 90 | F382499E25C51DA000E6B92E /* EmployeeListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmployeeListViewModel.swift; sourceTree = ""; }; 91 | F38249A225C5257200E6B92E /* EmployeeListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmployeeListView.swift; sourceTree = ""; }; 92 | F38249A525C526CE00E6B92E /* EmployeeListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmployeeListRow.swift; sourceTree = ""; }; 93 | F38249A825C5425E00E6B92E /* OnDiskCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnDiskCache.swift; sourceTree = ""; }; 94 | F38249AB25C79BF500E6B92E /* CustomImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomImageView.swift; sourceTree = ""; }; 95 | F38249B025C79D0200E6B92E /* CustomImageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomImageViewModel.swift; sourceTree = ""; }; 96 | F38249B325C7F73600E6B92E /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; 97 | F38249B625CA235300E6B92E /* ImageServiceError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageServiceError.swift; sourceTree = ""; }; 98 | F38249BC25CA9A5200E6B92E /* EmployeeListViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmployeeListViewModelTests.swift; sourceTree = ""; }; 99 | F38249BE25CA9AC800E6B92E /* MockEmployeesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockEmployeesService.swift; sourceTree = ""; }; 100 | F39657BB25F40D7A00EA0C00 /* EmployeeDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmployeeDetailView.swift; sourceTree = ""; }; 101 | F39657BF25F45B1900EA0C00 /* EmployeeProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmployeeProfileEditor.swift; sourceTree = ""; }; 102 | F39657C325F45B2800EA0C00 /* EmployeeProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmployeeProfile.swift; sourceTree = ""; }; 103 | F3E8FA8E25CC00E300010FFF /* imageDataFlow.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = imageDataFlow.png; path = ../../../../../Desktop/imageDataFlow.png; sourceTree = ""; }; 104 | F3E8FA8F25CC00E300010FFF /* achitecture.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = achitecture.png; path = ../../../../../Desktop/achitecture.png; sourceTree = ""; }; 105 | F3E8FA9025CC00E300010FFF /* employessListDataFlow.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = employessListDataFlow.png; path = ../../../../../Desktop/employessListDataFlow.png; sourceTree = ""; }; 106 | F3E8FA9E25CC6A3F00010FFF /* EmployeeListResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmployeeListResponse.swift; sourceTree = ""; }; 107 | F3E8FA9F25CC6A3F00010FFF /* Team.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Team.swift; sourceTree = ""; }; 108 | F3E8FAA025CC6A3F00010FFF /* Employee.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Employee.swift; sourceTree = ""; }; 109 | /* End PBXFileReference section */ 110 | 111 | /* Begin PBXFrameworksBuildPhase section */ 112 | F382495825C4F16C00E6B92E /* Frameworks */ = { 113 | isa = PBXFrameworksBuildPhase; 114 | buildActionMask = 2147483647; 115 | files = ( 116 | ); 117 | runOnlyForDeploymentPostprocessing = 0; 118 | }; 119 | F382496E25C4F16F00E6B92E /* Frameworks */ = { 120 | isa = PBXFrameworksBuildPhase; 121 | buildActionMask = 2147483647; 122 | files = ( 123 | ); 124 | runOnlyForDeploymentPostprocessing = 0; 125 | }; 126 | /* End PBXFrameworksBuildPhase section */ 127 | 128 | /* Begin PBXGroup section */ 129 | F34349EF25CB29B500312F72 /* Mocks */ = { 130 | isa = PBXGroup; 131 | children = ( 132 | F38249BE25CA9AC800E6B92E /* MockEmployeesService.swift */, 133 | F3434A0825CBB05800312F72 /* MockImageService.swift */, 134 | ); 135 | path = Mocks; 136 | sourceTree = ""; 137 | }; 138 | F34349F825CB2B6200312F72 /* ViewModels */ = { 139 | isa = PBXGroup; 140 | children = ( 141 | F38249BC25CA9A5200E6B92E /* EmployeeListViewModelTests.swift */, 142 | F3434A0625CBA80800312F72 /* CustomImageViewModelTests.swift */, 143 | ); 144 | path = ViewModels; 145 | sourceTree = ""; 146 | }; 147 | F382495225C4F16C00E6B92E = { 148 | isa = PBXGroup; 149 | children = ( 150 | F382495D25C4F16C00E6B92E /* EmployeeList */, 151 | F382497425C4F16F00E6B92E /* EmployeeListTests */, 152 | F382495C25C4F16C00E6B92E /* Products */, 153 | F34349FC25CB2E9600312F72 /* README.md */, 154 | ); 155 | sourceTree = ""; 156 | }; 157 | F382495C25C4F16C00E6B92E /* Products */ = { 158 | isa = PBXGroup; 159 | children = ( 160 | F382495B25C4F16C00E6B92E /* EmployeeList.app */, 161 | F382497125C4F16F00E6B92E /* EmployeeListTests.xctest */, 162 | ); 163 | name = Products; 164 | sourceTree = ""; 165 | }; 166 | F382495D25C4F16C00E6B92E /* EmployeeList */ = { 167 | isa = PBXGroup; 168 | children = ( 169 | F38249AE25C79C2F00E6B92E /* Utils */, 170 | F38249AF25C79C7700E6B92E /* Persistence */, 171 | F382498A25C4FD6500E6B92E /* Services */, 172 | F3E8FA9625CC69FF00010FFF /* Models */, 173 | F382499D25C51D8E00E6B92E /* ViewModels */, 174 | F38249A125C5255C00E6B92E /* Views */, 175 | F382498725C4F8DD00E6B92E /* Resources */, 176 | F382496025C4F16C00E6B92E /* SceneDelegate.swift */, 177 | F382496425C4F16F00E6B92E /* Assets.xcassets */, 178 | F382495E25C4F16C00E6B92E /* AppDelegate.swift */, 179 | F382496925C4F16F00E6B92E /* LaunchScreen.storyboard */, 180 | F382496C25C4F16F00E6B92E /* Info.plist */, 181 | F382496625C4F16F00E6B92E /* Preview Content */, 182 | ); 183 | path = EmployeeList; 184 | sourceTree = ""; 185 | }; 186 | F382496625C4F16F00E6B92E /* Preview Content */ = { 187 | isa = PBXGroup; 188 | children = ( 189 | F382496725C4F16F00E6B92E /* Preview Assets.xcassets */, 190 | ); 191 | path = "Preview Content"; 192 | sourceTree = ""; 193 | }; 194 | F382497425C4F16F00E6B92E /* EmployeeListTests */ = { 195 | isa = PBXGroup; 196 | children = ( 197 | F34349EF25CB29B500312F72 /* Mocks */, 198 | F382497525C4F16F00E6B92E /* EmployeeListTests.swift */, 199 | F34349F825CB2B6200312F72 /* ViewModels */, 200 | F382497725C4F16F00E6B92E /* Info.plist */, 201 | ); 202 | path = EmployeeListTests; 203 | sourceTree = ""; 204 | }; 205 | F382498725C4F8DD00E6B92E /* Resources */ = { 206 | isa = PBXGroup; 207 | children = ( 208 | F3E8FA8F25CC00E300010FFF /* achitecture.png */, 209 | F3E8FA9025CC00E300010FFF /* employessListDataFlow.png */, 210 | F3E8FA8E25CC00E300010FFF /* imageDataFlow.png */, 211 | F34349F925CB2BA500312F72 /* iu.png */, 212 | ); 213 | path = Resources; 214 | sourceTree = ""; 215 | }; 216 | F382498A25C4FD6500E6B92E /* Services */ = { 217 | isa = PBXGroup; 218 | children = ( 219 | F382499725C511DD00E6B92E /* EmployeesAPIService.swift */, 220 | F382499A25C5182900E6B92E /* ImageService.swift */, 221 | ); 222 | path = Services; 223 | sourceTree = ""; 224 | }; 225 | F382499D25C51D8E00E6B92E /* ViewModels */ = { 226 | isa = PBXGroup; 227 | children = ( 228 | F382499E25C51DA000E6B92E /* EmployeeListViewModel.swift */, 229 | F38249B025C79D0200E6B92E /* CustomImageViewModel.swift */, 230 | ); 231 | path = ViewModels; 232 | sourceTree = ""; 233 | }; 234 | F38249A125C5255C00E6B92E /* Views */ = { 235 | isa = PBXGroup; 236 | children = ( 237 | F38249A225C5257200E6B92E /* EmployeeListView.swift */, 238 | F38249A525C526CE00E6B92E /* EmployeeListRow.swift */, 239 | F38249AB25C79BF500E6B92E /* CustomImageView.swift */, 240 | F39657BB25F40D7A00EA0C00 /* EmployeeDetailView.swift */, 241 | F39657BF25F45B1900EA0C00 /* EmployeeProfileEditor.swift */, 242 | F39657C325F45B2800EA0C00 /* EmployeeProfile.swift */, 243 | ); 244 | path = Views; 245 | sourceTree = ""; 246 | }; 247 | F38249AE25C79C2F00E6B92E /* Utils */ = { 248 | isa = PBXGroup; 249 | children = ( 250 | F382498E25C4FE3900E6B92E /* APIServiceError.swift */, 251 | F38249B625CA235300E6B92E /* ImageServiceError.swift */, 252 | F382498B25C4FD7B00E6B92E /* APIRequest.swift */, 253 | F38249B325C7F73600E6B92E /* APIService.swift */, 254 | ); 255 | path = Utils; 256 | sourceTree = ""; 257 | }; 258 | F38249AF25C79C7700E6B92E /* Persistence */ = { 259 | isa = PBXGroup; 260 | children = ( 261 | F38249A825C5425E00E6B92E /* OnDiskCache.swift */, 262 | ); 263 | path = Persistence; 264 | sourceTree = ""; 265 | }; 266 | F3E8FA9625CC69FF00010FFF /* Models */ = { 267 | isa = PBXGroup; 268 | children = ( 269 | F3E8FA9E25CC6A3F00010FFF /* EmployeeListResponse.swift */, 270 | F3E8FA9F25CC6A3F00010FFF /* Team.swift */, 271 | F3E8FAA025CC6A3F00010FFF /* Employee.swift */, 272 | ); 273 | path = Models; 274 | sourceTree = ""; 275 | }; 276 | /* End PBXGroup section */ 277 | 278 | /* Begin PBXNativeTarget section */ 279 | F382495A25C4F16C00E6B92E /* EmployeeList */ = { 280 | isa = PBXNativeTarget; 281 | buildConfigurationList = F382497A25C4F16F00E6B92E /* Build configuration list for PBXNativeTarget "EmployeeList" */; 282 | buildPhases = ( 283 | F382495725C4F16C00E6B92E /* Sources */, 284 | F382495825C4F16C00E6B92E /* Frameworks */, 285 | F382495925C4F16C00E6B92E /* Resources */, 286 | ); 287 | buildRules = ( 288 | ); 289 | dependencies = ( 290 | ); 291 | name = EmployeeList; 292 | productName = EmployeeList; 293 | productReference = F382495B25C4F16C00E6B92E /* EmployeeList.app */; 294 | productType = "com.apple.product-type.application"; 295 | }; 296 | F382497025C4F16F00E6B92E /* EmployeeListTests */ = { 297 | isa = PBXNativeTarget; 298 | buildConfigurationList = F382497D25C4F16F00E6B92E /* Build configuration list for PBXNativeTarget "EmployeeListTests" */; 299 | buildPhases = ( 300 | F382496D25C4F16F00E6B92E /* Sources */, 301 | F382496E25C4F16F00E6B92E /* Frameworks */, 302 | F382496F25C4F16F00E6B92E /* Resources */, 303 | ); 304 | buildRules = ( 305 | ); 306 | dependencies = ( 307 | F382497325C4F16F00E6B92E /* PBXTargetDependency */, 308 | ); 309 | name = EmployeeListTests; 310 | productName = EmployeeListTests; 311 | productReference = F382497125C4F16F00E6B92E /* EmployeeListTests.xctest */; 312 | productType = "com.apple.product-type.bundle.unit-test"; 313 | }; 314 | /* End PBXNativeTarget section */ 315 | 316 | /* Begin PBXProject section */ 317 | F382495325C4F16C00E6B92E /* Project object */ = { 318 | isa = PBXProject; 319 | attributes = { 320 | LastSwiftUpdateCheck = 1150; 321 | LastUpgradeCheck = 1150; 322 | ORGANIZATIONNAME = None; 323 | TargetAttributes = { 324 | F382495A25C4F16C00E6B92E = { 325 | CreatedOnToolsVersion = 11.5; 326 | }; 327 | F382497025C4F16F00E6B92E = { 328 | CreatedOnToolsVersion = 11.5; 329 | TestTargetID = F382495A25C4F16C00E6B92E; 330 | }; 331 | }; 332 | }; 333 | buildConfigurationList = F382495625C4F16C00E6B92E /* Build configuration list for PBXProject "EmployeeList" */; 334 | compatibilityVersion = "Xcode 9.3"; 335 | developmentRegion = en; 336 | hasScannedForEncodings = 0; 337 | knownRegions = ( 338 | en, 339 | Base, 340 | ); 341 | mainGroup = F382495225C4F16C00E6B92E; 342 | productRefGroup = F382495C25C4F16C00E6B92E /* Products */; 343 | projectDirPath = ""; 344 | projectRoot = ""; 345 | targets = ( 346 | F382495A25C4F16C00E6B92E /* EmployeeList */, 347 | F382497025C4F16F00E6B92E /* EmployeeListTests */, 348 | ); 349 | }; 350 | /* End PBXProject section */ 351 | 352 | /* Begin PBXResourcesBuildPhase section */ 353 | F382495925C4F16C00E6B92E /* Resources */ = { 354 | isa = PBXResourcesBuildPhase; 355 | buildActionMask = 2147483647; 356 | files = ( 357 | F34349FD25CB2E9600312F72 /* README.md in Resources */, 358 | F3E8FA9325CC00E300010FFF /* employessListDataFlow.png in Resources */, 359 | F34349FA25CB2BA500312F72 /* iu.png in Resources */, 360 | F3E8FA9225CC00E300010FFF /* achitecture.png in Resources */, 361 | F382496B25C4F16F00E6B92E /* LaunchScreen.storyboard in Resources */, 362 | F3E8FA9125CC00E300010FFF /* imageDataFlow.png in Resources */, 363 | F382496825C4F16F00E6B92E /* Preview Assets.xcassets in Resources */, 364 | F382496525C4F16F00E6B92E /* Assets.xcassets in Resources */, 365 | ); 366 | runOnlyForDeploymentPostprocessing = 0; 367 | }; 368 | F382496F25C4F16F00E6B92E /* Resources */ = { 369 | isa = PBXResourcesBuildPhase; 370 | buildActionMask = 2147483647; 371 | files = ( 372 | F34349FB25CB2BA500312F72 /* iu.png in Resources */, 373 | ); 374 | runOnlyForDeploymentPostprocessing = 0; 375 | }; 376 | /* End PBXResourcesBuildPhase section */ 377 | 378 | /* Begin PBXSourcesBuildPhase section */ 379 | F382495725C4F16C00E6B92E /* Sources */ = { 380 | isa = PBXSourcesBuildPhase; 381 | buildActionMask = 2147483647; 382 | files = ( 383 | F38249B725CA235300E6B92E /* ImageServiceError.swift in Sources */, 384 | F38249A925C5425E00E6B92E /* OnDiskCache.swift in Sources */, 385 | F39657C425F45B2800EA0C00 /* EmployeeProfile.swift in Sources */, 386 | F38249B425C7F73600E6B92E /* APIService.swift in Sources */, 387 | F39657BC25F40D7A00EA0C00 /* EmployeeDetailView.swift in Sources */, 388 | F382495F25C4F16C00E6B92E /* AppDelegate.swift in Sources */, 389 | F38249A325C5257200E6B92E /* EmployeeListView.swift in Sources */, 390 | F382498F25C4FE3900E6B92E /* APIServiceError.swift in Sources */, 391 | F39657C025F45B1900EA0C00 /* EmployeeProfileEditor.swift in Sources */, 392 | F382496125C4F16C00E6B92E /* SceneDelegate.swift in Sources */, 393 | F382499825C511DD00E6B92E /* EmployeesAPIService.swift in Sources */, 394 | F3E8FAA125CC6A3F00010FFF /* EmployeeListResponse.swift in Sources */, 395 | F382498C25C4FD7B00E6B92E /* APIRequest.swift in Sources */, 396 | F382499B25C5182900E6B92E /* ImageService.swift in Sources */, 397 | F3E8FAA525CC6A3F00010FFF /* Employee.swift in Sources */, 398 | F382499F25C51DA000E6B92E /* EmployeeListViewModel.swift in Sources */, 399 | F3E8FAA325CC6A3F00010FFF /* Team.swift in Sources */, 400 | F38249B125C79D0200E6B92E /* CustomImageViewModel.swift in Sources */, 401 | F38249A625C526CE00E6B92E /* EmployeeListRow.swift in Sources */, 402 | F38249AC25C79BF500E6B92E /* CustomImageView.swift in Sources */, 403 | ); 404 | runOnlyForDeploymentPostprocessing = 0; 405 | }; 406 | F382496D25C4F16F00E6B92E /* Sources */ = { 407 | isa = PBXSourcesBuildPhase; 408 | buildActionMask = 2147483647; 409 | files = ( 410 | F382497625C4F16F00E6B92E /* EmployeeListTests.swift in Sources */, 411 | F3434A0725CBA80800312F72 /* CustomImageViewModelTests.swift in Sources */, 412 | F38249AD25C79BF500E6B92E /* CustomImageView.swift in Sources */, 413 | F38249A425C5257200E6B92E /* EmployeeListView.swift in Sources */, 414 | F382499925C511DD00E6B92E /* EmployeesAPIService.swift in Sources */, 415 | F382499C25C5182900E6B92E /* ImageService.swift in Sources */, 416 | F382499025C4FE3900E6B92E /* APIServiceError.swift in Sources */, 417 | F3E8FAA425CC6A3F00010FFF /* Team.swift in Sources */, 418 | F3E8FAA225CC6A3F00010FFF /* EmployeeListResponse.swift in Sources */, 419 | F38249AA25C5425E00E6B92E /* OnDiskCache.swift in Sources */, 420 | F38249B525C7F73600E6B92E /* APIService.swift in Sources */, 421 | F38249BF25CA9AC800E6B92E /* MockEmployeesService.swift in Sources */, 422 | F3E8FAA625CC6A3F00010FFF /* Employee.swift in Sources */, 423 | F38249A025C51DA000E6B92E /* EmployeeListViewModel.swift in Sources */, 424 | F38249A725C526CE00E6B92E /* EmployeeListRow.swift in Sources */, 425 | F38249B825CA235300E6B92E /* ImageServiceError.swift in Sources */, 426 | F38249BD25CA9A5200E6B92E /* EmployeeListViewModelTests.swift in Sources */, 427 | F382498D25C4FD7B00E6B92E /* APIRequest.swift in Sources */, 428 | F38249B225C79D0200E6B92E /* CustomImageViewModel.swift in Sources */, 429 | F3434A0925CBB05800312F72 /* MockImageService.swift in Sources */, 430 | ); 431 | runOnlyForDeploymentPostprocessing = 0; 432 | }; 433 | /* End PBXSourcesBuildPhase section */ 434 | 435 | /* Begin PBXTargetDependency section */ 436 | F382497325C4F16F00E6B92E /* PBXTargetDependency */ = { 437 | isa = PBXTargetDependency; 438 | target = F382495A25C4F16C00E6B92E /* EmployeeList */; 439 | targetProxy = F382497225C4F16F00E6B92E /* PBXContainerItemProxy */; 440 | }; 441 | /* End PBXTargetDependency section */ 442 | 443 | /* Begin PBXVariantGroup section */ 444 | F382496925C4F16F00E6B92E /* LaunchScreen.storyboard */ = { 445 | isa = PBXVariantGroup; 446 | children = ( 447 | F382496A25C4F16F00E6B92E /* Base */, 448 | ); 449 | name = LaunchScreen.storyboard; 450 | sourceTree = ""; 451 | }; 452 | /* End PBXVariantGroup section */ 453 | 454 | /* Begin XCBuildConfiguration section */ 455 | F382497825C4F16F00E6B92E /* Debug */ = { 456 | isa = XCBuildConfiguration; 457 | buildSettings = { 458 | ALWAYS_SEARCH_USER_PATHS = NO; 459 | CLANG_ANALYZER_NONNULL = YES; 460 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 461 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 462 | CLANG_CXX_LIBRARY = "libc++"; 463 | CLANG_ENABLE_MODULES = YES; 464 | CLANG_ENABLE_OBJC_ARC = YES; 465 | CLANG_ENABLE_OBJC_WEAK = YES; 466 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 467 | CLANG_WARN_BOOL_CONVERSION = YES; 468 | CLANG_WARN_COMMA = YES; 469 | CLANG_WARN_CONSTANT_CONVERSION = YES; 470 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 471 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 472 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 473 | CLANG_WARN_EMPTY_BODY = YES; 474 | CLANG_WARN_ENUM_CONVERSION = YES; 475 | CLANG_WARN_INFINITE_RECURSION = YES; 476 | CLANG_WARN_INT_CONVERSION = YES; 477 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 478 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 479 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 480 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 481 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 482 | CLANG_WARN_STRICT_PROTOTYPES = YES; 483 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 484 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 485 | CLANG_WARN_UNREACHABLE_CODE = YES; 486 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 487 | COPY_PHASE_STRIP = NO; 488 | DEBUG_INFORMATION_FORMAT = dwarf; 489 | ENABLE_STRICT_OBJC_MSGSEND = YES; 490 | ENABLE_TESTABILITY = YES; 491 | GCC_C_LANGUAGE_STANDARD = gnu11; 492 | GCC_DYNAMIC_NO_PIC = NO; 493 | GCC_NO_COMMON_BLOCKS = YES; 494 | GCC_OPTIMIZATION_LEVEL = 0; 495 | GCC_PREPROCESSOR_DEFINITIONS = ( 496 | "DEBUG=1", 497 | "$(inherited)", 498 | ); 499 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 500 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 501 | GCC_WARN_UNDECLARED_SELECTOR = YES; 502 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 503 | GCC_WARN_UNUSED_FUNCTION = YES; 504 | GCC_WARN_UNUSED_VARIABLE = YES; 505 | IPHONEOS_DEPLOYMENT_TARGET = 13.5; 506 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 507 | MTL_FAST_MATH = YES; 508 | ONLY_ACTIVE_ARCH = YES; 509 | SDKROOT = iphoneos; 510 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 511 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 512 | }; 513 | name = Debug; 514 | }; 515 | F382497925C4F16F00E6B92E /* Release */ = { 516 | isa = XCBuildConfiguration; 517 | buildSettings = { 518 | ALWAYS_SEARCH_USER_PATHS = NO; 519 | CLANG_ANALYZER_NONNULL = YES; 520 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 521 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 522 | CLANG_CXX_LIBRARY = "libc++"; 523 | CLANG_ENABLE_MODULES = YES; 524 | CLANG_ENABLE_OBJC_ARC = YES; 525 | CLANG_ENABLE_OBJC_WEAK = YES; 526 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 527 | CLANG_WARN_BOOL_CONVERSION = YES; 528 | CLANG_WARN_COMMA = YES; 529 | CLANG_WARN_CONSTANT_CONVERSION = YES; 530 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 531 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 532 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 533 | CLANG_WARN_EMPTY_BODY = YES; 534 | CLANG_WARN_ENUM_CONVERSION = YES; 535 | CLANG_WARN_INFINITE_RECURSION = YES; 536 | CLANG_WARN_INT_CONVERSION = YES; 537 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 538 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 539 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 540 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 541 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 542 | CLANG_WARN_STRICT_PROTOTYPES = YES; 543 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 544 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 545 | CLANG_WARN_UNREACHABLE_CODE = YES; 546 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 547 | COPY_PHASE_STRIP = NO; 548 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 549 | ENABLE_NS_ASSERTIONS = NO; 550 | ENABLE_STRICT_OBJC_MSGSEND = YES; 551 | GCC_C_LANGUAGE_STANDARD = gnu11; 552 | GCC_NO_COMMON_BLOCKS = YES; 553 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 554 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 555 | GCC_WARN_UNDECLARED_SELECTOR = YES; 556 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 557 | GCC_WARN_UNUSED_FUNCTION = YES; 558 | GCC_WARN_UNUSED_VARIABLE = YES; 559 | IPHONEOS_DEPLOYMENT_TARGET = 13.5; 560 | MTL_ENABLE_DEBUG_INFO = NO; 561 | MTL_FAST_MATH = YES; 562 | SDKROOT = iphoneos; 563 | SWIFT_COMPILATION_MODE = wholemodule; 564 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 565 | VALIDATE_PRODUCT = YES; 566 | }; 567 | name = Release; 568 | }; 569 | F382497B25C4F16F00E6B92E /* Debug */ = { 570 | isa = XCBuildConfiguration; 571 | buildSettings = { 572 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 573 | CODE_SIGN_STYLE = Automatic; 574 | DEVELOPMENT_ASSET_PATHS = "\"EmployeeList/Preview Content\""; 575 | DEVELOPMENT_TEAM = 63NU2U66Q9; 576 | ENABLE_PREVIEWS = YES; 577 | INFOPLIST_FILE = EmployeeList/Info.plist; 578 | LD_RUNPATH_SEARCH_PATHS = ( 579 | "$(inherited)", 580 | "@executable_path/Frameworks", 581 | ); 582 | PRODUCT_BUNDLE_IDENTIFIER = wangsiman.EmployeeList; 583 | PRODUCT_NAME = "$(TARGET_NAME)"; 584 | SWIFT_VERSION = 5.0; 585 | TARGETED_DEVICE_FAMILY = "1,2"; 586 | }; 587 | name = Debug; 588 | }; 589 | F382497C25C4F16F00E6B92E /* Release */ = { 590 | isa = XCBuildConfiguration; 591 | buildSettings = { 592 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 593 | CODE_SIGN_STYLE = Automatic; 594 | DEVELOPMENT_ASSET_PATHS = "\"EmployeeList/Preview Content\""; 595 | DEVELOPMENT_TEAM = 63NU2U66Q9; 596 | ENABLE_PREVIEWS = YES; 597 | INFOPLIST_FILE = EmployeeList/Info.plist; 598 | LD_RUNPATH_SEARCH_PATHS = ( 599 | "$(inherited)", 600 | "@executable_path/Frameworks", 601 | ); 602 | PRODUCT_BUNDLE_IDENTIFIER = wangsiman.EmployeeList; 603 | PRODUCT_NAME = "$(TARGET_NAME)"; 604 | SWIFT_VERSION = 5.0; 605 | TARGETED_DEVICE_FAMILY = "1,2"; 606 | }; 607 | name = Release; 608 | }; 609 | F382497E25C4F16F00E6B92E /* Debug */ = { 610 | isa = XCBuildConfiguration; 611 | buildSettings = { 612 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 613 | BUNDLE_LOADER = "$(TEST_HOST)"; 614 | CODE_SIGN_STYLE = Automatic; 615 | DEVELOPMENT_TEAM = 63NU2U66Q9; 616 | INFOPLIST_FILE = EmployeeListTests/Info.plist; 617 | IPHONEOS_DEPLOYMENT_TARGET = 13.5; 618 | LD_RUNPATH_SEARCH_PATHS = ( 619 | "$(inherited)", 620 | "@executable_path/Frameworks", 621 | "@loader_path/Frameworks", 622 | ); 623 | PRODUCT_BUNDLE_IDENTIFIER = wangsiman.EmployeeListTests; 624 | PRODUCT_NAME = "$(TARGET_NAME)"; 625 | SWIFT_VERSION = 5.0; 626 | TARGETED_DEVICE_FAMILY = "1,2"; 627 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EmployeeList.app/EmployeeList"; 628 | }; 629 | name = Debug; 630 | }; 631 | F382497F25C4F16F00E6B92E /* Release */ = { 632 | isa = XCBuildConfiguration; 633 | buildSettings = { 634 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 635 | BUNDLE_LOADER = "$(TEST_HOST)"; 636 | CODE_SIGN_STYLE = Automatic; 637 | DEVELOPMENT_TEAM = 63NU2U66Q9; 638 | INFOPLIST_FILE = EmployeeListTests/Info.plist; 639 | IPHONEOS_DEPLOYMENT_TARGET = 13.5; 640 | LD_RUNPATH_SEARCH_PATHS = ( 641 | "$(inherited)", 642 | "@executable_path/Frameworks", 643 | "@loader_path/Frameworks", 644 | ); 645 | PRODUCT_BUNDLE_IDENTIFIER = wangsiman.EmployeeListTests; 646 | PRODUCT_NAME = "$(TARGET_NAME)"; 647 | SWIFT_VERSION = 5.0; 648 | TARGETED_DEVICE_FAMILY = "1,2"; 649 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EmployeeList.app/EmployeeList"; 650 | }; 651 | name = Release; 652 | }; 653 | /* End XCBuildConfiguration section */ 654 | 655 | /* Begin XCConfigurationList section */ 656 | F382495625C4F16C00E6B92E /* Build configuration list for PBXProject "EmployeeList" */ = { 657 | isa = XCConfigurationList; 658 | buildConfigurations = ( 659 | F382497825C4F16F00E6B92E /* Debug */, 660 | F382497925C4F16F00E6B92E /* Release */, 661 | ); 662 | defaultConfigurationIsVisible = 0; 663 | defaultConfigurationName = Release; 664 | }; 665 | F382497A25C4F16F00E6B92E /* Build configuration list for PBXNativeTarget "EmployeeList" */ = { 666 | isa = XCConfigurationList; 667 | buildConfigurations = ( 668 | F382497B25C4F16F00E6B92E /* Debug */, 669 | F382497C25C4F16F00E6B92E /* Release */, 670 | ); 671 | defaultConfigurationIsVisible = 0; 672 | defaultConfigurationName = Release; 673 | }; 674 | F382497D25C4F16F00E6B92E /* Build configuration list for PBXNativeTarget "EmployeeListTests" */ = { 675 | isa = XCConfigurationList; 676 | buildConfigurations = ( 677 | F382497E25C4F16F00E6B92E /* Debug */, 678 | F382497F25C4F16F00E6B92E /* Release */, 679 | ); 680 | defaultConfigurationIsVisible = 0; 681 | defaultConfigurationName = Release; 682 | }; 683 | /* End XCConfigurationList section */ 684 | }; 685 | rootObject = F382495325C4F16C00E6B92E /* Project object */; 686 | } 687 | -------------------------------------------------------------------------------- /EmployeeList.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /EmployeeList.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /EmployeeList.xcodeproj/xcshareddata/xcschemes/EmployeeList.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 | -------------------------------------------------------------------------------- /EmployeeList.xcodeproj/xcuserdata/siman.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 9 | 21 | 22 | 23 | 25 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /EmployeeList.xcodeproj/xcuserdata/siman.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | EmployeeList.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | SuppressBuildableAutocreation 14 | 15 | F382495A25C4F16C00E6B92E 16 | 17 | primary 18 | 19 | 20 | F382497025C4F16F00E6B92E 21 | 22 | primary 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /EmployeeList/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // EmployeeList 4 | // 5 | // Created by Wang Siman on 1/29/21. 6 | // Copyright © 2021 None. 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 | -------------------------------------------------------------------------------- /EmployeeList/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 | -------------------------------------------------------------------------------- /EmployeeList/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /EmployeeList/Assets.xcassets/iu.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "iu.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /EmployeeList/Assets.xcassets/iu.imageset/iu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simanw/SwiftUI-MVVM-Demo/f1a421df8ba7b5948b4180c85bc28490e9f4c7d7/EmployeeList/Assets.xcassets/iu.imageset/iu.png -------------------------------------------------------------------------------- /EmployeeList/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 | -------------------------------------------------------------------------------- /EmployeeList/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 | -------------------------------------------------------------------------------- /EmployeeList/Models/Employee.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Employee.swift 3 | // EmployeeList 4 | // 5 | // Created by Wang Siman on 1/29/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | //enum EmployeeType: String, Codable { 13 | // 14 | // case FULL_TIME 15 | // case PART_TIME 16 | // case CONTRACTOR 17 | // 18 | //} 19 | 20 | enum EmployeeType: String, Codable, CaseIterable { 21 | case fullTime = "FULL_TIME" 22 | case partTime = "PART_TIME" 23 | case contractor = "CONTRACTOR" 24 | } 25 | 26 | struct Employee: Codable, Identifiable { 27 | var id: UUID 28 | var fullName: String 29 | var phoneNumber: String? 30 | var emailAddress: String 31 | var biography: String? 32 | var photoUrlSmall: String? 33 | var photoUrlLarge: String? 34 | var team: String 35 | var employeeType: EmployeeType 36 | 37 | enum CodingKeys: String, CodingKey { 38 | case id = "uuid" 39 | case fullName = "full_name" 40 | case phoneNumber = "phone_number" 41 | case emailAddress = "email_address" 42 | case biography 43 | case photoUrlSmall = "photo_url_small" 44 | case photoUrlLarge = "photo_url_large" 45 | case team 46 | case employeeType = "employee_type" 47 | } 48 | 49 | } 50 | 51 | let testEmployee = Employee( 52 | id: UUID(uuidString: "B6DEA526-C571-4D43-8B41-375CA5CD9FDB")!, 53 | fullName: "Elisa Rizzo", 54 | phoneNumber: "123456789", 55 | emailAddress: "erizzo.demo@squareup.com", 56 | biography: Optional("iOS Engineer on the Restaurants team."), 57 | photoUrlSmall: "https://s3.amazonaws.com/sq-mobile-interview/photos/16c00560-6dd3-4af4-97a6-d4754e7f2394/small.jpg", 58 | photoUrlLarge: "https://s3.amazonaws.com/sq-mobile-interview/photos/16c00560-6dd3-4af4-97a6-d4754e7f2394/large.jpg", 59 | team: "Operations", 60 | employeeType: EmployeeType.fullTime) 61 | -------------------------------------------------------------------------------- /EmployeeList/Models/EmployeeListResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmployeeListResponse.swift 3 | // EmployeeList 4 | // 5 | // Created by Wang Siman on 1/29/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct EmployeeListResponse: Decodable { 12 | var employees: [Employee] 13 | } 14 | -------------------------------------------------------------------------------- /EmployeeList/Models/Team.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Team.swift 3 | // EmployeeList 4 | // 5 | // Created by Wang Siman on 2/4/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Team: Codable { 12 | var name: String 13 | var employees: [Employee] = [] 14 | } 15 | -------------------------------------------------------------------------------- /EmployeeList/Persistence/OnDiskCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cache.swift 3 | // EmployeeList 4 | // 5 | // Created by Wang Siman on 1/29/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | import Combine 12 | 13 | final class Cache { 14 | 15 | private var fileManager: FileManager = FileManager.default 16 | 17 | private func buildFilePath(forKey key: String) -> URL? { 18 | if let fileURL = self.fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { 19 | return fileURL.appendingPathComponent(key) 20 | } 21 | return nil 22 | } 23 | 24 | func saveToDisk(image: UIImage, forKey key: String) -> AnyPublisher{ 25 | 26 | do { 27 | guard let jpeg = image.jpegData(compressionQuality: 0.9) else { 28 | throw ImageProcessingError.unknown("compressing image") 29 | } 30 | guard let filePath = buildFilePath(forKey: key) else { 31 | throw ImageProcessingError.invalidFilePath(key) 32 | } 33 | try jpeg.write(to: filePath, options: .atomic) 34 | // print("Successfully stored image in disk for key \(key)") 35 | return CurrentValueSubject(true).eraseToAnyPublisher() 36 | } catch let error { 37 | return Fail(error: error).eraseToAnyPublisher() 38 | } 39 | } 40 | 41 | func loadFromDisk(forKey key: String) -> AnyPublisher { 42 | do { 43 | guard let filePath = buildFilePath(forKey: key) else { 44 | throw ImageProcessingError.invalidFilePath(key) 45 | } 46 | guard exists(forKey: key) else { 47 | throw ImageProcessingError.notFoundInDisk(filePath.path) 48 | } 49 | guard let fileData = self.fileManager.contents(atPath: filePath.path) else { 50 | throw ImageProcessingError.unknown("fetching image from the disk") 51 | } 52 | return CurrentValueSubject(fileData).eraseToAnyPublisher() 53 | } catch let error { 54 | return Fail(error: error).eraseToAnyPublisher() 55 | } 56 | } 57 | 58 | func exists(forKey key: String) -> Bool { 59 | if let filePath = buildFilePath(forKey: key) { 60 | let res = self.fileManager.fileExists(atPath: filePath.path) 61 | // print("onDiskCache test if image exists -> \(res) for key \(filePath.path)") 62 | return res 63 | } else { 64 | return false 65 | } 66 | } 67 | } 68 | 69 | -------------------------------------------------------------------------------- /EmployeeList/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /EmployeeList/Resources/achitecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simanw/SwiftUI-MVVM-Demo/f1a421df8ba7b5948b4180c85bc28490e9f4c7d7/EmployeeList/Resources/achitecture.png -------------------------------------------------------------------------------- /EmployeeList/Resources/default-avatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simanw/SwiftUI-MVVM-Demo/f1a421df8ba7b5948b4180c85bc28490e9f4c7d7/EmployeeList/Resources/default-avatar.jpg -------------------------------------------------------------------------------- /EmployeeList/Resources/employessListDataFlow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simanw/SwiftUI-MVVM-Demo/f1a421df8ba7b5948b4180c85bc28490e9f4c7d7/EmployeeList/Resources/employessListDataFlow.png -------------------------------------------------------------------------------- /EmployeeList/Resources/imageDataFlow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simanw/SwiftUI-MVVM-Demo/f1a421df8ba7b5948b4180c85bc28490e9f4c7d7/EmployeeList/Resources/imageDataFlow.png -------------------------------------------------------------------------------- /EmployeeList/Resources/iu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simanw/SwiftUI-MVVM-Demo/f1a421df8ba7b5948b4180c85bc28490e9f4c7d7/EmployeeList/Resources/iu.png -------------------------------------------------------------------------------- /EmployeeList/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // EmployeeList 4 | // 5 | // Created by Wang Siman on 1/29/21. 6 | // Copyright © 2021 None. 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 | 24 | // Use a UIHostingController as window root view controller. 25 | if let windowScene = scene as? UIWindowScene { 26 | let window = UIWindow(windowScene: windowScene) 27 | window.rootViewController = UIHostingController(rootView: EmployeeListView(employeeListViewModel: .init())) 28 | self.window = window 29 | window.makeKeyAndVisible() 30 | } 31 | } 32 | 33 | func sceneDidDisconnect(_ scene: UIScene) { 34 | // Called as the scene is being released by the system. 35 | // This occurs shortly after the scene enters the background, or when its session is discarded. 36 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 37 | // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). 38 | } 39 | 40 | func sceneDidBecomeActive(_ scene: UIScene) { 41 | // Called when the scene has moved from an inactive state to an active state. 42 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 43 | } 44 | 45 | func sceneWillResignActive(_ scene: UIScene) { 46 | // Called when the scene will move from an active state to an inactive state. 47 | // This may occur due to temporary interruptions (ex. an incoming phone call). 48 | } 49 | 50 | func sceneWillEnterForeground(_ scene: UIScene) { 51 | // Called as the scene transitions from the background to the foreground. 52 | // Use this method to undo the changes made on entering the background. 53 | } 54 | 55 | func sceneDidEnterBackground(_ scene: UIScene) { 56 | // Called as the scene transitions from the foreground to the background. 57 | // Use this method to save data, release shared resources, and store enough scene-specific state information 58 | // to restore the scene back to its current state. 59 | } 60 | 61 | 62 | } 63 | 64 | -------------------------------------------------------------------------------- /EmployeeList/Services/EmployeesAPIService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmployeeListService.swift 3 | // EmployeeList 4 | // 5 | // Created by Wang Siman on 1/29/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | 12 | final class EmployeesAPIService: APIServiceType { 13 | 14 | internal let baseURL: String 15 | internal let session: URLSession = URLSession.shared 16 | internal let bgQueue: DispatchQueue = DispatchQueue.main 17 | 18 | init(baseURL: String = "https://s3.amazonaws.com/sq-mobile-interview/") { 19 | self.baseURL = baseURL 20 | } 21 | 22 | func call(from endpoint: Request) -> AnyPublisher where Request : APIRequestType { 23 | do { 24 | let request = try endpoint.buildRequest(baseURL: baseURL) 25 | return session.dataTaskPublisher(for: request) 26 | .retry(1) 27 | .tryMap { 28 | guard let code = ($0.response as? HTTPURLResponse)?.statusCode else { 29 | throw APIServiceError.unexpectedResponse 30 | } 31 | guard HTTPCodes.success.contains(code) else { 32 | throw APIServiceError.httpError(code) 33 | } 34 | return $0.data // Pass data to downstream publishers 35 | } 36 | .decode(type: Request.ModelType.self, decoder: JSONDecoder()) 37 | .mapError {_ in APIServiceError.parseError} 38 | .receive(on: self.bgQueue) 39 | .eraseToAnyPublisher() 40 | } catch let error { 41 | return Fail(error: error).eraseToAnyPublisher() 42 | } 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /EmployeeList/Services/ImageDiskService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageDiskService.swift 3 | // EmployeeList 4 | // 5 | // Created by Wang Siman on 2/3/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import UIKit 12 | 13 | class ImageDiskService { 14 | 15 | private let cache = Cache() 16 | 17 | func loadFromDisk(forKey key: String) -> AnyPublisher { 18 | return self.cache.loadFromDisk(forKey: key) 19 | } 20 | 21 | func existsInDisk(_ key: String) -> Bool { 22 | return self.cache.exits(forKey: key) 23 | 24 | } 25 | 26 | func saveToDisk(_ image: UIImage, forKey key: String) -> AnyPublisher { 27 | return self.cache.saveToDisk(image: image, forKey: key) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /EmployeeList/Services/ImageService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageService.swift 3 | // EmployeeList 4 | // 5 | // Created by Wang Siman on 1/29/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import UIKit 12 | 13 | protocol ImageServiceType: APIServiceType{ 14 | func existsInDisk(_ key: String) -> Bool 15 | func loadFromDisk(forKey key: String) -> AnyPublisher 16 | func saveToDisk(_ image: UIImage, forKey key: String) -> AnyPublisher 17 | } 18 | 19 | final class ImageService: ImageServiceType { 20 | internal let baseURL: String 21 | internal let session: URLSession = URLSession.shared 22 | internal let bgQueue: DispatchQueue = DispatchQueue.main 23 | 24 | init(baseURL: String = "") { 25 | self.baseURL = baseURL 26 | } 27 | 28 | private var cancellables: [AnyCancellable] = [] 29 | private let onAppearSubject = PassthroughSubject() 30 | private let cache = Cache() 31 | 32 | func call(from endpoint: Request) -> AnyPublisher where Request : APIRequestType { 33 | do { 34 | let request = try endpoint.buildRequest(baseURL: baseURL) 35 | return session.dataTaskPublisher(for: request) 36 | .retry(1) 37 | .tryMap { 38 | guard let code = ($0.1 as? HTTPURLResponse)?.statusCode else { 39 | throw APIServiceError.unexpectedResponse 40 | } 41 | guard HTTPCodes.success.contains(code) else { 42 | throw APIServiceError.httpError(code) 43 | } 44 | print("call image from url ") 45 | 46 | return $0.0 as! Request.ModelType // Pass data to downstream publishers 47 | } 48 | .receive(on: self.bgQueue) 49 | .eraseToAnyPublisher() 50 | } catch let error { 51 | return Fail(error: error).eraseToAnyPublisher() 52 | } 53 | } 54 | 55 | func loadFromDisk(forKey key: String) -> AnyPublisher { 56 | cache.loadFromDisk(forKey: key) 57 | } 58 | 59 | func existsInDisk(_ key: String) -> Bool { 60 | cache.exists(forKey: key) 61 | } 62 | 63 | func saveToDisk(_ image: UIImage, forKey key: String) -> AnyPublisher { 64 | cache.saveToDisk(image: image, forKey: key) 65 | } 66 | } 67 | 68 | -------------------------------------------------------------------------------- /EmployeeList/Utils/APIRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIService.swift 3 | // EmployeeList 4 | // 5 | // Created by Wang Siman on 1/29/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol APIRequestType { 12 | associatedtype ModelType: Decodable 13 | 14 | var path: String {get} 15 | var method: String {get} 16 | var headers: [String: String]? {get} 17 | var queryItems: [URLQueryItem]? {get} 18 | func body() throws -> Data? 19 | } 20 | 21 | extension APIRequestType { 22 | func buildRequest(baseURL: String) throws -> URLRequest { 23 | 24 | guard let url = URL(string: baseURL + path) else { 25 | throw APIServiceError.invalidURL 26 | } 27 | var components = URLComponents(url: url, resolvingAgainstBaseURL: true)! 28 | components.queryItems = queryItems 29 | 30 | var request = URLRequest(url: components.url!) 31 | request.httpMethod = method 32 | request.allHTTPHeaderFields = headers 33 | request.httpBody = try body() 34 | return request 35 | } 36 | } 37 | 38 | struct EmployeeListRequest: APIRequestType { 39 | typealias ModelType = EmployeeListResponse 40 | 41 | var path: String 42 | var method: String { return "GET" } 43 | var headers: [String: String]? { return ["Content-Type": "application/json"] } 44 | var queryItems: [URLQueryItem]? 45 | func body() throws -> Data? { 46 | return Data() 47 | } 48 | } 49 | 50 | struct ImageRequest: APIRequestType { 51 | typealias ModelType = Data 52 | 53 | var path: String 54 | var method: String { return "GET" } 55 | var headers: [String : String]? 56 | var queryItems: [URLQueryItem]? 57 | func body() throws -> Data? { 58 | return Data() 59 | } 60 | } 61 | 62 | struct EmployeePostRequest: APIRequestType { 63 | 64 | typealias ModelType = Employee 65 | 66 | var employee: ModelType 67 | var path: String 68 | var method: String { return "POST" } 69 | var headers: [String : String]? 70 | var queryItems: [URLQueryItem]? 71 | func body() throws -> Data? { 72 | let encoder = JSONEncoder() 73 | return try encoder.encode(employee) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /EmployeeList/Utils/APIService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIService.swift 3 | // EmployeeList 4 | // 5 | // Created by Wang Siman on 2/1/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | 12 | protocol APIServiceType { 13 | var session: URLSession {get} 14 | var baseURL: String {get} 15 | var bgQueue: DispatchQueue {get} 16 | func call(from endpoint: Request) -> AnyPublisher where Request: APIRequestType 17 | } 18 | 19 | -------------------------------------------------------------------------------- /EmployeeList/Utils/APIServiceError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIServiceError.swift 3 | // EmployeeList 4 | // 5 | // Created by Wang Siman on 1/29/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum APIServiceError: Error { 12 | case invalidURL 13 | case httpError(HTTPCode) 14 | case parseError 15 | case unexpectedResponse 16 | } 17 | 18 | extension APIServiceError: LocalizedError { 19 | var errorDescription: String? { 20 | switch self { 21 | case .invalidURL: return "Invalid URL" 22 | case let .httpError(statusCode): return "Unexpected HTTP status code: \(statusCode)" 23 | case .parseError: return "Unexpected JSON parse error" 24 | case .unexpectedResponse: return "Unexpected response from the server" 25 | } 26 | } 27 | } 28 | 29 | typealias HTTPCode = Int 30 | typealias HTTPCodes = Range 31 | 32 | extension HTTPCodes { 33 | static let success = 200 ..< 300 34 | } 35 | 36 | -------------------------------------------------------------------------------- /EmployeeList/Utils/ImageServiceError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageServiceError.swift 3 | // EmployeeList 4 | // 5 | // Created by Wang Siman on 2/2/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum ImageProcessingError: Error { 12 | case notFoundInDisk(String) 13 | case storingFailed 14 | case invalidFilePath(String) 15 | case unknown(String) 16 | } 17 | 18 | extension ImageProcessingError: LocalizedError { 19 | var errorDescription: String? { 20 | switch self { 21 | case let .notFoundInDisk(path): return "Unable to find item at path: \(path)" 22 | case .storingFailed: return "Unable to store the image" 23 | case let .invalidFilePath(path): return "Unable to build valid file path: \(path)" 24 | case let .unknown(msg): return "Unknown error incurred when \(msg)" 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /EmployeeList/ViewModels/CustomImageViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomImageViewModel.swift 3 | // EmployeeList 4 | // 5 | // Created by Wang Siman on 1/31/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | import Combine 12 | 13 | final class CustomImageViewModel: ObservableObject { 14 | 15 | @Published var image = UIImage() 16 | 17 | private var cancellables: [String :AnyCancellable] = [:] 18 | private let onAppearSubject = PassthroughSubject() 19 | private let imageURL: String 20 | 21 | private let imageService: ImageServiceType 22 | 23 | private let imageSubject = PassthroughSubject() 24 | private let diskSaveSubject = PassthroughSubject() 25 | 26 | @Published var isErrorShown = false 27 | @Published var errorMessage = "" 28 | 29 | init(imageService: ImageServiceType = ImageService(), imageURL: String) { 30 | self.imageService = imageService 31 | self.imageURL = imageURL 32 | 33 | bindInputsToOutputs() 34 | print(" --- custom image view model init done ---") 35 | } 36 | 37 | // Publish values on-demand by calling the send() method 38 | func update() { 39 | onAppearSubject.send(()) 40 | } 41 | 42 | private func buildImageKey(_ url: String) -> String { 43 | if let imageURLComponents = URL(string: url)?.pathComponents { 44 | if imageURLComponents.count == 1 { 45 | return url 46 | } 47 | let endIndex = imageURLComponents.endIndex 48 | let id = imageURLComponents[endIndex - 2] 49 | let size = imageURLComponents[endIndex - 1] 50 | return id + size 51 | } 52 | return url 53 | } 54 | 55 | func bindInputsToOutputs() { 56 | let imageKey = buildImageKey(self.imageURL) 57 | 58 | if self.imageService.existsInDisk(imageKey) { 59 | print("Load image from disk") 60 | loadFromDisk(from: imageKey) 61 | loadImage() 62 | } else { 63 | print("Load image from web") 64 | loadFromWeb(from: self.imageURL) 65 | loadImage() 66 | } 67 | 68 | } 69 | 70 | 71 | // MARK: Bind inputs 72 | func loadFromDisk(from key: String) { 73 | 74 | let publisher = onAppearSubject.flatMap { _ -> AnyPublisher in 75 | return self.imageService.loadFromDisk(forKey: key) 76 | } 77 | let diskStream = publisher.subscribe(imageSubject) 78 | 79 | cancellables["disk"] = diskStream 80 | } 81 | 82 | func loadFromWeb(from url: String) { 83 | 84 | let endpoint = ImageRequest(path: url) 85 | let publisher = onAppearSubject.flatMap { _ -> AnyPublisher in 86 | return self.imageService.call(from: endpoint) 87 | } 88 | let webStream = publisher.subscribe(imageSubject) 89 | cancellables["web"] = webStream 90 | } 91 | 92 | // Only save the image to disk the first time the image is downloaded from the web 93 | func saveToDisk(_ image: UIImage, _ key: String) { 94 | 95 | let publisher = diskSaveSubject.flatMap { _ -> AnyPublisher in 96 | return self.imageService.saveToDisk(self.image, forKey: key) 97 | } 98 | 99 | let diskSaveStream = publisher 100 | .map { $0 } 101 | .sink(receiveCompletion: { result in 102 | switch result { 103 | case .failure(let error): do { 104 | self.isErrorShown = true 105 | self.errorMessage = error.localizedDescription 106 | } 107 | case .finished: 108 | break 109 | } 110 | }, receiveValue: { data in 111 | print("Successfully stored image, boolean is \(data)") 112 | }) 113 | 114 | diskSaveSubject.send() 115 | cancellables["diskSave"] = diskSaveStream 116 | } 117 | 118 | // MARK: Bind output 119 | func loadImage() { 120 | 121 | let imageStream = imageSubject 122 | .map{ $0 } 123 | .sink(receiveCompletion: { result in 124 | switch result { 125 | case .failure(let error): do { 126 | self.isErrorShown = true 127 | self.errorMessage = error.localizedDescription 128 | } 129 | case .finished: 130 | break 131 | } 132 | }, receiveValue: { data in 133 | self.image = UIImage(data: data) ?? UIImage(named: "iu")! 134 | let imageKey = self.buildImageKey(self.imageURL) 135 | if !self.imageService.existsInDisk(imageKey) { 136 | self.saveToDisk(self.image, imageKey) 137 | self.cancellables["web"]!.cancel() 138 | } 139 | }) 140 | cancellables["image"] = imageStream 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /EmployeeList/ViewModels/EmployeeListViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmployeeListViewModel.swift 3 | // EmployeeList 4 | // 5 | // Created by Wang Siman on 1/29/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import SwiftUI 12 | 13 | final class EmployeeListViewModel: ObservableObject { 14 | private var cancellables: [AnyCancellable] = [] 15 | 16 | private let onAppearSubject = PassthroughSubject() 17 | 18 | private let employeesService: APIServiceType 19 | 20 | // Publish values on-demand by calling the send() method 21 | func update() { 22 | onAppearSubject.send(()) 23 | //objectWillChange.send() 24 | } 25 | 26 | private var employees: [Employee] = [] 27 | 28 | @Published var sortedTeams: [Team] = [] 29 | @Published var isErrorShown = false 30 | @Published var errorMessage = "" 31 | 32 | init(employeesService: APIServiceType = EmployeesAPIService()) { 33 | self.employeesService = employeesService 34 | 35 | fetchEmployeeList() 36 | print("------- employee list view model init done ---------") 37 | } 38 | 39 | private func fetchEmployeeList() { 40 | 41 | let endpoint = EmployeeListRequest(path: "employees.json") 42 | let publisher = onAppearSubject.flatMap { _ -> AnyPublisher in 43 | return self.employeesService.call(from: endpoint) 44 | } 45 | 46 | let employeesStream = publisher 47 | .map { $0.employees } 48 | .sink(receiveCompletion: { result in 49 | switch result { 50 | case .failure(let error): do { 51 | self.errorMessage = error.localizedDescription 52 | self.isErrorShown = true; 53 | } 54 | case .finished: 55 | break 56 | } 57 | 58 | }, receiveValue: { (employees) in 59 | self.employees = employees 60 | self.groupEmployeesByTeam() 61 | }) 62 | 63 | cancellables += [ 64 | employeesStream 65 | ] 66 | } 67 | } 68 | 69 | extension EmployeeListViewModel { 70 | 71 | private func groupEmployeesByTeam() { 72 | var buckets: [String: Team] = [:] 73 | var teams: [Team] = [] 74 | for employee in self.employees { 75 | if var team = buckets[employee.team] { 76 | team.employees.append(employee) 77 | } else { 78 | var team = Team(name: employee.team) 79 | team.employees.append(employee) 80 | buckets[employee.team] = team 81 | teams.append(team) 82 | } 83 | } 84 | self.sortedTeams = teams.sorted { (lhs, rhs) -> Bool in 85 | return lhs.name < rhs.name 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /EmployeeList/ViewModels/postTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // postTest.swift 3 | // EmployeeList 4 | // 5 | // Created by Wang Siman on 3/6/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class PostTest { 12 | static let employee = Employee(id: UUID(uuidString: "B6DEA526-C571-4D43-8B41-375CA5CD9FDB")!, 13 | fullName: "Post Test", phoneNumber: "98765431", emailAddress: "post@mail.com", team: "Sales", employeeType: EmployeeType(rawValue: "FULL_TIME")!) 14 | let endpoint = EmployeePostRequest(employee: employee, path: <#T##String#>, headers: <#T##[String : String]?#>, queryItems: nil) 15 | 16 | let request = endpoint.buildRequest(baseURL: "") 17 | } 18 | -------------------------------------------------------------------------------- /EmployeeList/Views/CustomImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomImageView.swift 3 | // EmployeeList 4 | // 5 | // Created by Wang Siman on 1/31/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Combine 11 | 12 | struct CustomImageView: View { 13 | 14 | @ObservedObject private(set) var customImageViewModel: CustomImageViewModel 15 | 16 | var body: some View { 17 | content 18 | .alert(isPresented: $customImageViewModel.isErrorShown, content: { () -> Alert in 19 | Alert(title: Text("Error"), message: Text(self.customImageViewModel.errorMessage)) 20 | }) 21 | .onAppear(perform: { self.customImageViewModel.update() }) 22 | } 23 | 24 | private var content: Image { 25 | return Image(uiImage: self.customImageViewModel.image) 26 | .resizable() 27 | 28 | } 29 | } 30 | 31 | struct CustomImageView_Previews: PreviewProvider { 32 | static var previews: some View { 33 | CustomImageView(customImageViewModel: .init(imageURL: "https://s3.amazonaws.com/sq-mobile-interview/photos/5095a907-abc9-4734-8d1e-0eeb2506bfa8/large.jpg")) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /EmployeeList/Views/EmployeeDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmployeeDetailView.swift 3 | // EmployeeList 4 | // 5 | // Created by Wang Siman on 3/6/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct EmployeeDetailView: View { 12 | @Environment(\.editMode) var mode 13 | @State var employee: Employee 14 | @State var draftProfile = testEmployee 15 | 16 | var body: some View { 17 | VStack(alignment: .leading, spacing: 20) { 18 | HStack { 19 | if self.mode?.wrappedValue == .active { 20 | Button("Cancel") { 21 | self.draftProfile = self.employee 22 | self.mode?.animation().wrappedValue = .inactive 23 | } 24 | } 25 | 26 | Spacer() 27 | 28 | EditButton() 29 | } 30 | if self.mode?.wrappedValue == .inactive { 31 | EmployeeProfile(employee: $employee) 32 | } else { 33 | EmployeeProfileEditor(employee: $draftProfile) 34 | .onAppear { 35 | self.draftProfile = self.employee 36 | } 37 | .onDisappear { 38 | self.employee = self.draftProfile 39 | } 40 | } 41 | } 42 | .padding() 43 | } 44 | } 45 | 46 | struct EmployeeDetailView_Previews: PreviewProvider { 47 | //@State static var employee = testEmployee 48 | static var previews: some View { 49 | EmployeeDetailView(employee: testEmployee) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /EmployeeList/Views/EmployeeListRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmployeeListRow.swift 3 | // EmployeeList 4 | // 5 | // Created by Wang Siman on 1/29/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import UIKit 11 | 12 | struct EmployeeListRow: View { 13 | @State var employee: Employee 14 | 15 | var body: some View { 16 | NavigationLink(destination: EmployeeDetailView(employee: employee)) { 17 | HStack { 18 | CustomImageView(customImageViewModel: .init(imageURL: employee.photoUrlSmall!)) 19 | .frame(width: 50, height: 50) 20 | .cornerRadius(25) 21 | Text(employee.fullName) 22 | Spacer() 23 | Text(employee.team) 24 | .foregroundColor(.gray) 25 | } 26 | .padding() 27 | } 28 | 29 | } 30 | } 31 | 32 | struct EmployeeListRow_Previews: PreviewProvider { 33 | static var previews: some View { 34 | EmployeeListRow(employee: 35 | testEmployee 36 | ) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /EmployeeList/Views/EmployeeListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmployeeListView.swift 3 | // EmployeeList 4 | // 5 | // Created by Wang Siman on 1/29/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct EmployeeListView: View { 12 | @ObservedObject private(set) var employeeListViewModel: EmployeeListViewModel 13 | 14 | var body: some View { 15 | // We need to return a row wrapped up in a navigation button to allow user to tap on it 16 | // Then trigger a destination for that button 17 | NavigationView { 18 | List { 19 | ForEach(employeeListViewModel.sortedTeams, id: \.name) { team in 20 | Section(header: Text(team.name)) { 21 | ForEach(team.employees, id: \.id) { employee in 22 | EmployeeListRow(employee: employee) 23 | } 24 | } 25 | } 26 | } 27 | .alert(isPresented: $employeeListViewModel.isErrorShown, content: { () -> Alert in 28 | Alert(title: Text("Error"), message: Text(employeeListViewModel.errorMessage)) 29 | }) 30 | .navigationBarTitle(Text("Employees"), displayMode: .inline) 31 | } 32 | .onAppear(perform: {self.employeeListViewModel.update()}) 33 | //.onAppear(perform: {self.employeeListViewModel.objectWillChange.send()}) 34 | } 35 | } 36 | 37 | struct EmployeeListView_Previews: PreviewProvider { 38 | static var previews: some View { 39 | EmployeeListView(employeeListViewModel: .init()) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /EmployeeList/Views/EmployeeProfile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileSummary.swift 3 | // EmployeeList 4 | // 5 | // Created by Wang Siman on 3/6/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct EmployeeProfile: View { 12 | @Binding var employee: Employee 13 | 14 | var body: some View { 15 | VStack(spacing: 0) { 16 | VStack { 17 | Text(employee.fullName) 18 | .font(/*@START_MENU_TOKEN@*/.title/*@END_MENU_TOKEN@*/) 19 | CustomImageView(customImageViewModel: .init(imageURL: employee.photoUrlSmall!)) 20 | .frame(width: 200, height: 200) 21 | .cornerRadius(100) 22 | 23 | } 24 | 25 | VStack { 26 | Divider() 27 | VStack(alignment: .leading, spacing: 10) { 28 | Text("Contact") 29 | .frame(width: 80, height: 1, alignment: .leading) 30 | HStack { 31 | Text(employee.phoneNumber!) 32 | Spacer() 33 | Text(employee.emailAddress) 34 | } 35 | .foregroundColor(.gray) 36 | 37 | } 38 | .padding() 39 | 40 | VStack(alignment: .leading, spacing: 10) { 41 | Text("Team") 42 | .frame(width: 80, height: 1, alignment: .leading) 43 | HStack { 44 | Text(employee.team) 45 | Spacer() 46 | Text(employee.employeeType.rawValue) 47 | } 48 | .foregroundColor(.gray) 49 | } 50 | .padding() 51 | 52 | Divider() 53 | 54 | Text(employee.biography!) 55 | .frame( alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/) 56 | .offset(x: 0, y: 20) 57 | } 58 | .offset(x: 0, y: 80) 59 | } 60 | .padding(.bottom) 61 | 62 | } 63 | } 64 | 65 | struct EmployeeProfile_Previews: PreviewProvider { 66 | @State static var employee = testEmployee 67 | static var previews: some View { 68 | EmployeeProfile(employee: $employee) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /EmployeeList/Views/EmployeeProfileEditor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileEditor.swift 3 | // EmployeeList 4 | // 5 | // Created by Wang Siman on 3/6/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct EmployeeProfileEditor: View { 12 | @Binding var employee: Employee 13 | 14 | var body: some View { 15 | List { 16 | HStack { 17 | Text("Fullname").bold() 18 | Divider() 19 | TextField("Fullname", text: $employee.fullName) 20 | } 21 | 22 | VStack(alignment: .leading, spacing: 20) { 23 | Text("Employeement type").bold() 24 | 25 | Picker("Seasonal Photo", selection: $employee.employeeType) { 26 | ForEach(EmployeeType.allCases, id: \.self) { type in 27 | Text(type.rawValue).tag(type) 28 | } 29 | } 30 | .pickerStyle(SegmentedPickerStyle()) 31 | } 32 | .padding(.top) 33 | } 34 | } 35 | } 36 | 37 | struct EmployeeProfileEditor_Previews: PreviewProvider { 38 | @State static var em = testEmployee 39 | static var previews: some View { 40 | EmployeeProfileEditor(employee: $em) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /EmployeeListTests/EmployeeListTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmployeeListTests.swift 3 | // EmployeeListTests 4 | // 5 | // Created by Wang Siman on 1/29/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import EmployeeList 11 | 12 | class EmployeeListTests: 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 | -------------------------------------------------------------------------------- /EmployeeListTests/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 | -------------------------------------------------------------------------------- /EmployeeListTests/Mocks/MockEmployeesService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockEmployeesService.swift 3 | // EmployeeListTests 4 | // 5 | // Created by Wang Siman on 2/3/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | @testable import EmployeeList 12 | 13 | final class MockEmployeesService: APIServiceType { 14 | var stubs: [Any] = [] 15 | internal let baseURL: String = "" 16 | internal let session: URLSession = URLSession.shared 17 | internal let bgQueue: DispatchQueue = DispatchQueue(label: "test") 18 | 19 | func stub(for type: Request.Type, response: @escaping ((Request) -> AnyPublisher)) where Request: APIRequestType { 20 | stubs.append(response) 21 | } 22 | 23 | func call(from request: Request) -> AnyPublisher where Request: APIRequestType { 24 | 25 | let response = stubs.compactMap { stub -> AnyPublisher? in 26 | let stub = stub as? ((Request) -> AnyPublisher) 27 | return stub?(request) 28 | }.last 29 | 30 | return response ?? Empty() 31 | .eraseToAnyPublisher() 32 | } 33 | } 34 | 35 | 36 | -------------------------------------------------------------------------------- /EmployeeListTests/Mocks/MockImageService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockImageService.swift 3 | // EmployeeListTests 4 | // 5 | // Created by Wang Siman on 2/3/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import UIKit 12 | @testable import EmployeeList 13 | 14 | final class MockImageService: ImageServiceType { 15 | 16 | var stubs: [Any] = [] 17 | internal let baseURL: String = "" 18 | internal let session: URLSession = URLSession.shared 19 | internal let bgQueue: DispatchQueue = DispatchQueue(label: "test") 20 | 21 | private var isExisting = false 22 | var isExistingState: Bool { 23 | get { 24 | return isExisting 25 | } 26 | set(newSate) { 27 | isExisting = newSate 28 | } 29 | } 30 | var isLoadFromDiskCalled = false 31 | var isSaveToDiskCalled = false 32 | 33 | func stub(for type: Request.Type, response: @escaping ((Request) -> AnyPublisher)) where Request: APIRequestType { 34 | stubs.append(response) 35 | } 36 | 37 | func call(from request: Request) -> AnyPublisher where Request: APIRequestType { 38 | 39 | let response = stubs.compactMap { stub -> AnyPublisher? in 40 | let stub = stub as? ((Request) -> AnyPublisher) 41 | return stub?(request) 42 | }.last 43 | 44 | return response ?? Empty() 45 | .eraseToAnyPublisher() 46 | } 47 | 48 | func loadFromDisk(forKey key: String) -> AnyPublisher { 49 | isLoadFromDiskCalled = true 50 | return Empty().eraseToAnyPublisher() 51 | } 52 | 53 | func existsInDisk(_ key: String) -> Bool { 54 | return isExisting 55 | } 56 | 57 | func saveToDisk(_ image: UIImage, forKey key: String) -> AnyPublisher { 58 | isSaveToDiskCalled = true 59 | return Empty().eraseToAnyPublisher() 60 | } 61 | } 62 | 63 | -------------------------------------------------------------------------------- /EmployeeListTests/Mocks/MockResponses.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockResponses.swift 3 | // EmployeeList 4 | // 5 | // Created by Wang Siman on 2/3/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | @testable import EmployeeList 12 | 13 | enum MockResponse { 14 | case employeeListResponse 15 | case emptyListResponse 16 | case malformedJsonResponse 17 | } 18 | 19 | struct MockResponseGenerator { 20 | 21 | let normalJson = """ 22 | "employees" : [ 23 | { 24 | "uuid" : "0d8fcc12-4d0c-425c-8355-390b312b909c", 25 | 26 | "full_name" : "Justine Mason", 27 | "phone_number" : "5553280123", 28 | "email_address" : "jmason.demo@squareup.com", 29 | "biography" : "Engineer on the Point of Sale team.", 30 | 31 | "photo_url_small" : "https://s3.amazonaws.com/sq-mobile-interview/photos/16c00560-6dd3-4af4-97a6-d4754e7f2394/small.jpg", 32 | "photo_url_large" : "https://s3.amazonaws.com/sq-mobile-interview/photos/16c00560-6dd3-4af4-97a6-d4754e7f2394/large.jpg", 33 | 34 | "team" : "Point of Sale", 35 | "employee_type" : "FULL_TIME" 36 | } 37 | ] 38 | """.data(using: .utf8)! 39 | 40 | let emptyJson = """ 41 | { 42 | "employees" : [ 43 | ] 44 | } 45 | """.data(using: .utf8)! 46 | 47 | let malformedJson = """ 48 | "employees" : [ 49 | { 50 | "uuid" : "0d8fcc12-4d0c-425c-8355-390b312b909c", 51 | 52 | "full_name" : "Justine Mason", 53 | "phone_number" : "5553280123", 54 | "biography" : "Engineer on the Point of Sale team.", 55 | 56 | "photo_url_small" : "https://s3.amazonaws.com/sq-mobile-interview/photos/16c00560-6dd3-4af4-97a6-d4754e7f2394/small.jpg", 57 | "photo_url_large" : "https://s3.amazonaws.com/sq-mobile-interview/photos/16c00560-6dd3-4af4-97a6-d4754e7f2394/large.jpg", 58 | 59 | "team" : "Point of Sale", 60 | "employee_type" : "FULL_TIME" 61 | } 62 | ] 63 | """.data(using: .utf8)! 64 | 65 | func makeEmployeeList() -> AnyPublisher{ 66 | let publisher = CurrentValueSubject(normalJson) 67 | 68 | return publisher 69 | .decode(type: Data.self, decoder: JSONDecoder()) 70 | .eraseToAnyPublisher() 71 | } 72 | 73 | func makeEmptyList() -> AnyPublisher { 74 | let publisher = CurrentValueSubject(emptyJson) 75 | 76 | return publisher 77 | .decode(type: Data.self, decoder: JSONDecoder()) 78 | .eraseToAnyPublisher() 79 | } 80 | 81 | func makeMalformed() -> AnyPublisher { 82 | let publisher = CurrentValueSubject(normalJson) 83 | 84 | return publisher 85 | .decode(type: Data.self, decoder: JSONDecoder()) 86 | .eraseToAnyPublisher() 87 | } 88 | } 89 | 90 | -------------------------------------------------------------------------------- /EmployeeListTests/ViewModels/CustomImageViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomImageViewModelTests.swift 3 | // EmployeeListTests 4 | // 5 | // Created by Wang Siman on 2/3/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | import Combine 12 | @testable import EmployeeList 13 | 14 | class CustomImageViewModelTests: XCTestCase { 15 | 16 | func test_loadImageFromWebWhenImageNotExisting_validImage() { 17 | let mockImageService = MockImageService() 18 | 19 | mockImageService.stub(for: ImageRequest.self, response: { _ in 20 | Result.Publisher( 21 | (UIImage(named: "iu")?.jpegData(compressionQuality: 0.6))! 22 | ).eraseToAnyPublisher() 23 | 24 | }) 25 | let viewModel = CustomImageViewModel(imageService: mockImageService, 26 | imageURL: "") 27 | 28 | viewModel.update() 29 | XCTAssertTrue(mockImageService.isSaveToDiskCalled) 30 | XCTAssertNotEqual(viewModel.image, UIImage(named: "iu")) 31 | } 32 | 33 | func test_loadImageFromWebWhenImageNotExisting_invalidImage() { 34 | let mockImageService = MockImageService() 35 | 36 | mockImageService.stub(for: ImageRequest.self, response: { _ in 37 | Result.Publisher( 38 | Data() 39 | ).eraseToAnyPublisher() 40 | 41 | }) 42 | 43 | let viewModel = CustomImageViewModel(imageService: mockImageService, 44 | imageURL: "") 45 | viewModel.update() 46 | XCTAssertTrue(mockImageService.isSaveToDiskCalled) 47 | XCTAssertEqual(viewModel.image, UIImage(named: "iu")) 48 | 49 | } 50 | 51 | func test_loadImageFromDiskWhenImageExisting() { 52 | let mockImageService = MockImageService() 53 | mockImageService.isExistingState = true 54 | 55 | let viewModel = CustomImageViewModel(imageService: mockImageService, 56 | imageURL: "") 57 | viewModel.update() 58 | XCTAssertTrue(mockImageService.isLoadFromDiskCalled) 59 | } 60 | 61 | func test_throwError() { 62 | let mockImageService = MockImageService() 63 | mockImageService.stub(for: ImageRequest.self, response: { _ in 64 | Result.Publisher( 65 | ImageProcessingError.notFoundInDisk("dd") 66 | ).eraseToAnyPublisher() 67 | }) 68 | let viewModel = CustomImageViewModel(imageService: mockImageService, 69 | imageURL: "") 70 | viewModel.update() 71 | XCTAssertEqual(viewModel.errorMessage, ImageProcessingError.notFoundInDisk("dd").errorDescription) 72 | 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /EmployeeListTests/ViewModels/EmployeeListViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmployeeListViewModelTests.swift 3 | // EmployeeListTests 4 | // 5 | // Created by Wang Siman on 2/3/21. 6 | // Copyright © 2021 None. All rights reserved. 7 | // 8 | import Foundation 9 | import XCTest 10 | import Combine 11 | @testable import EmployeeList 12 | 13 | class EmployeeListViewModelTests: XCTestCase { 14 | 15 | func test_updateEmployeeListWhenOnAppear() { 16 | 17 | let mockEmployeesService = MockEmployeesService() 18 | 19 | mockEmployeesService.stub(for: EmployeeListRequest.self, response: { _ in 20 | Result.Publisher( 21 | EmployeeListResponse(employees: [ 22 | .init(id: UUID(uuidString: "B6DEA526-C571-4D43-8B41-375CA5CD9FDB")!, 23 | fullName: "Elisa Rizzo", 24 | phoneNumber: "123456789", 25 | emailAddress: "erizzo.demo@squareup.com", 26 | biography: Optional("iOS Engineer on the Restaurants team."), 27 | photoUrlSmall: "https://s3.amazonaws.com/sq-mobile-interview/photos/16c00560-6dd3-4af4-97a6-d4754e7f2394/small.jpg", 28 | photoUrlLarge: "https://s3.amazonaws.com/sq-mobile-interview/photos/16c00560-6dd3-4af4-97a6-d4754e7f2394/large.jpg", 29 | team: "Operations", 30 | employeeType: EmployeeType.FULL_TIME) 31 | ]) 32 | ).eraseToAnyPublisher() 33 | 34 | }) 35 | 36 | let viewModel = EmployeeListViewModel(employeesService: mockEmployeesService) 37 | viewModel.update() 38 | XCTAssertTrue(!viewModel.sortedTeams.isEmpty) 39 | } 40 | 41 | func test_emptyEmployeeListForEmptyJson() { 42 | let mockEmployeesService = MockEmployeesService() 43 | mockEmployeesService.stub(for: EmployeeListRequest.self, response: { _ in 44 | Result.Publisher( 45 | EmployeeListResponse(employees: []) 46 | ).eraseToAnyPublisher() 47 | 48 | }) 49 | let viewModel = EmployeeListViewModel(employeesService: mockEmployeesService) 50 | 51 | viewModel.update() 52 | XCTAssertTrue(viewModel.sortedTeams.isEmpty) 53 | } 54 | 55 | func test_throwParseErrorForMalformedJson() { 56 | let mockEmployeesService = MockEmployeesService() 57 | 58 | let viewModel = EmployeeListViewModel(employeesService: mockEmployeesService) 59 | 60 | mockEmployeesService.stub(for: EmployeeListRequest.self, response: { _ in 61 | Result.Publisher( 62 | APIServiceError.parseError 63 | ).eraseToAnyPublisher() 64 | }) 65 | 66 | viewModel.update() 67 | XCTAssertEqual(viewModel.errorMessage, APIServiceError.parseError.errorDescription) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | <#Employee List#> 2 | 3 | # Build Tools & Version Used & Phone Focus 4 | Xcode 11.5 5 | 6 | iOS 13.0+ (for Combine framework) 7 | 8 | No external dependencies 9 | 10 | Focused on phone 11 | 12 | # Focus Area 13 | I mostly focused on the architecture and data flow design. 14 | ## Architecture 15 | I used MVVM + Combine architecture. 16 | 17 | ![](./EmployeeList/Resources/achitecture.png) 18 | 19 | ### Data Access Layer 20 | 21 | Data Access Layer consists of `Utils` and `Persistence`. 22 | 23 | `Utils` provides asynchronous APIs for networking. `Persistence` provides interfaces to store and retrieve images. Both pass data as a `Publisher` from `Combine` framework. They are accessible and used only by the `Services` in the business layer. 24 | 25 | ***Protocol-oriented networking*** 26 | 27 | I defined `APIRequestType` protocol and `APIServiceType` protocol. 28 | 29 | This approach avoids constantly changes to make room for new network requests and data types. It allows us to create as many types of requests as we need. All we need to do is add new classes that comform to these two protocols. There is no need to change existing code, respecting the Open-closed principle. 30 | 31 | Also, this approach allows dependency injection that makes the code more testable. 32 | 33 | ### Business Logic Layer 34 | 35 | Business Logic Layer includes `Services` and `ViewModels`. 36 | 37 | For the current required features, `Services` consists of `EmployeesAPIService` that receives requests to download the employee list from the given remote endpoint, and `ImageService` that either receives requests to download images remotely or retrieves images from the disk locally. Note that `ImageService` comforms to `ImageServiceType` such that we can use dependency injection to inject `MockImageService` in `ViewModel` in unit tests. 38 | 39 | `ViewModels` work between `Views` and `Services`, encapsulating business logic to the `Views`. `ViewModels` are downstream subscribers that receive and process whatever is passed from upstream publishers. They are marked as `@ObservedObject` such that SwiftUI is able to monitor `ViewModels` for updates and redraw the UI. 40 | 41 | ### Presentation Layer 42 | 43 | Presentation Layer is represented by `Views`. 44 | 45 | Views are independent from business logic. Side effects are triggered by the view lifecycle event `onAppear` and are forwarded to the corresponding the `ViewModel` 46 | 47 | ## Data flow 48 | 49 | ***Yet another informal data flow chart*** 50 | 51 | ![](./EmployeeList/Resources/employessListDataFlow.png) 52 | 53 | ![](./EmployeeList/Resources/imageDataFlow.png) 54 | 55 | # Copied-in code or copied-in dependencies 56 | I copied the following code snippets for making mock APIService in unit tests. 57 | [Source](https://github.com/kitasuke/SwiftUI-MVVM/blob/master/SwiftUI-MVVMTests/MockAPISearvice.swift) 58 | ```swift 59 | var stubs: [Any] = [] 60 | 61 | func stub(for type: Request.Type, response: @escaping ((Request) -> AnyPublisher)) where Request: APIRequestType { 62 | stubs.append(response) 63 | } 64 | 65 | func call(from request: Request) -> AnyPublisher where Request: APIRequestType { 66 | 67 | let response = stubs.compactMap { stub -> AnyPublisher? in 68 | let stub = stub as? ((Request) -> AnyPublisher) 69 | return stub?(request) 70 | }.last 71 | 72 | return response ?? Empty() 73 | .eraseToAnyPublisher() 74 | } 75 | ``` 76 | # Spent time 77 | Roughly 6.5 hours 78 | 79 | # Unit testing 80 | 81 | With limited time, I implemented two unit test suites 82 | 83 | ### Implemented unit tests: 84 | 85 | EmployeeListViewModel 86 | 87 | - should fetch data from the EmployeesService 88 | - should display an error message if the request failed 89 | 90 | CustomImageViewModel 91 | 92 | - should fetch image from the ImageService 93 | - should display the default photo placeholder if photo loading failed 94 | - should dispaly an error message if any error incurred during persistence 95 | 96 | ### Other reasonable unit tests (not yet implemented) 97 | 98 | OnDiskCache 99 | 100 | - should store an image with a correct key 101 | - should pass a Fail Publisher to downstreams if storing failed 102 | - should pass a Publisher with image data with a correct key 103 | - should pass a Fail Publisher to downstreams if fetching failed 104 | 105 | EmployeesService 106 | 107 | - should send a request to the given endpoint and pass a Publisher with a decoded list of Employee type 108 | - should pass a Fail Publisher to downstreams if any error 109 | 110 | ImageService 111 | 112 | - should send a request to the given endpoint and pass a Publisher with the Data (or error) to downstreams 113 | - should load the image Data from disk and pass a Publisher with the fetched image Data (or error) to downstreams 114 | - should save an image to disk and pass a Publisher with the storing result to downstreams 115 | 116 | 117 | 118 | --------------------------------------------------------------------------------