├── .github └── workflows │ └── workflow-test.yml ├── .gitignore ├── CombineDemo.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ ├── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcuserdata │ │ └── mcichecki.xcuserdatad │ │ ├── IDEFindNavigatorScopes.plist │ │ └── UserInterfaceState.xcuserstate └── xcuserdata │ └── mcichecki.xcuserdatad │ ├── xcdebugger │ └── Breakpoints_v2.xcbkptlist │ └── xcschemes │ └── xcschememanagement.plist ├── CombineDemo ├── API │ ├── Model │ │ ├── Player.swift │ │ ├── PlayerData.swift │ │ └── Team.swift │ └── Service │ │ └── PlayersService.swift ├── AppDelegate.swift ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json ├── Base.lproj │ └── LaunchScreen.storyboard ├── ColorAssets.xcassets │ ├── Contents.json │ ├── mainBackground.colorset │ │ └── Contents.json │ ├── nonValid.colorset │ │ └── Contents.json │ └── valid.colorset │ │ └── Contents.json ├── Combine │ ├── InitialPublished.swift │ └── UITextField+Publisher.swift ├── Info.plist ├── List │ ├── Cell │ │ ├── PlayerCellViewModel.swift │ │ └── PlayerCollectionCell.swift │ ├── ListView.swift │ ├── ListViewController.swift │ └── ListViewModel.swift ├── Login │ ├── LoginView.swift │ ├── LoginViewController.swift │ └── LoginViewModel.swift ├── SceneDelegate.swift ├── Shared │ └── ActivityIndicatorView.swift ├── UIButton │ └── UIButton+Validation.swift └── UIColor │ └── UIColor+Colors.swift ├── CombineDemoTests ├── Info.plist ├── Mocks │ ├── MockCredentialsValidator.swift │ └── MockPlayersService.swift └── Tests │ ├── ListViewModelTests.swift │ └── LoginViewModelTests.swift └── README.md /.github/workflows/workflow-test.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | runs-on: macOS-latest 14 | strategy: 15 | matrix: 16 | destination: ['platform=iOS Simulator,name=iPhone 12 Pro'] 17 | steps: 18 | - uses: actions/checkout@v1 19 | - name: Run unit tests 20 | run: | 21 | set -o pipefail && \ 22 | xcodebuild -project CombineDemo.xcodeproj \ 23 | -scheme CombineDemoTests \ 24 | -destination "${destination}" \ 25 | test | xcpretty 26 | env: 27 | destination: ${{ matrix.destination }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | Constants.swift -------------------------------------------------------------------------------- /CombineDemo.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 50; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 518B0F35258F60D30076FFD6 /* MockCredentialsValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 518B0F34258F60D30076FFD6 /* MockCredentialsValidator.swift */; }; 11 | 51ED8C4D258E1C2A000301C5 /* ActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51ED8C4C258E1C2A000301C5 /* ActivityIndicatorView.swift */; }; 12 | CD03AA9A22CA5CDD00914AF4 /* InitialPublished.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD03AA9922CA5CDD00914AF4 /* InitialPublished.swift */; }; 13 | CD1951E022CE910B00EEE445 /* LoginViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1951DF22CE910B00EEE445 /* LoginViewModelTests.swift */; }; 14 | CD9B81862721E4FB008E648F /* ListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9B81852721E4FB008E648F /* ListViewModelTests.swift */; }; 15 | CD9B818A2721E54F008E648F /* MockPlayersService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9B81892721E54F008E648F /* MockPlayersService.swift */; }; 16 | CDA610CA22CD55BA00DFECD7 /* ColorAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CDA610C922CD55BA00DFECD7 /* ColorAssets.xcassets */; }; 17 | CDA610CE22CD56BC00DFECD7 /* UIColor+Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA610CD22CD56BC00DFECD7 /* UIColor+Colors.swift */; }; 18 | CDC2721B2640938100260841 /* PlayerCollectionCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDC2721A2640938100260841 /* PlayerCollectionCell.swift */; }; 19 | CDCCEE9322AAFF3A00D1A8F9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCCEE9222AAFF3A00D1A8F9 /* AppDelegate.swift */; }; 20 | CDCCEE9522AAFF3A00D1A8F9 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCCEE9422AAFF3A00D1A8F9 /* SceneDelegate.swift */; }; 21 | CDCCEE9722AAFF3A00D1A8F9 /* LoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCCEE9622AAFF3A00D1A8F9 /* LoginViewController.swift */; }; 22 | CDCCEE9C22AAFF3B00D1A8F9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CDCCEE9B22AAFF3B00D1A8F9 /* Assets.xcassets */; }; 23 | CDCCEE9F22AAFF3B00D1A8F9 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = CDCCEE9D22AAFF3B00D1A8F9 /* LaunchScreen.storyboard */; }; 24 | CDCCEEA722AB019100D1A8F9 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCCEEA622AB019100D1A8F9 /* LoginView.swift */; }; 25 | CDCCEEAA22AB01A500D1A8F9 /* LoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCCEEA922AB01A500D1A8F9 /* LoginViewModel.swift */; }; 26 | CDCE66CD22C8B74400B1D54C /* ListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCE66CC22C8B74400B1D54C /* ListViewController.swift */; }; 27 | CDCE66CF22C8B7A500B1D54C /* ListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCE66CE22C8B7A500B1D54C /* ListViewModel.swift */; }; 28 | CDCE66D122C8B7B300B1D54C /* ListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCE66D022C8B7B300B1D54C /* ListView.swift */; }; 29 | CDCE66D822C8BABB00B1D54C /* PlayersService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCE66D722C8BABB00B1D54C /* PlayersService.swift */; }; 30 | CDCE66DA22C8BB8D00B1D54C /* Player.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCE66D922C8BB8D00B1D54C /* Player.swift */; }; 31 | CDCE66DC22C8BBDF00B1D54C /* Team.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCE66DB22C8BBDF00B1D54C /* Team.swift */; }; 32 | CDCE66DE22C8C9D200B1D54C /* PlayerData.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCE66DD22C8C9D200B1D54C /* PlayerData.swift */; }; 33 | CDCF8F5B22CE6E1F008A86CA /* UIButton+Validation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCF8F5A22CE6E1F008A86CA /* UIButton+Validation.swift */; }; 34 | CDF9286322CD195D001EF276 /* UITextField+Publisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF9286222CD195D001EF276 /* UITextField+Publisher.swift */; }; 35 | CDFDFB5722C934D300117F44 /* PlayerCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDFDFB5622C934D300117F44 /* PlayerCellViewModel.swift */; }; 36 | /* End PBXBuildFile section */ 37 | 38 | /* Begin PBXContainerItemProxy section */ 39 | CD1951E222CE910B00EEE445 /* PBXContainerItemProxy */ = { 40 | isa = PBXContainerItemProxy; 41 | containerPortal = CDCCEE8722AAFF3A00D1A8F9 /* Project object */; 42 | proxyType = 1; 43 | remoteGlobalIDString = CDCCEE8E22AAFF3A00D1A8F9; 44 | remoteInfo = CombineDemo; 45 | }; 46 | /* End PBXContainerItemProxy section */ 47 | 48 | /* Begin PBXFileReference section */ 49 | 518B0F34258F60D30076FFD6 /* MockCredentialsValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCredentialsValidator.swift; sourceTree = ""; }; 50 | 51ED8C4C258E1C2A000301C5 /* ActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicatorView.swift; sourceTree = ""; }; 51 | CD03AA9922CA5CDD00914AF4 /* InitialPublished.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InitialPublished.swift; sourceTree = ""; }; 52 | CD1951DD22CE910B00EEE445 /* CombineDemoTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CombineDemoTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 53 | CD1951DF22CE910B00EEE445 /* LoginViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModelTests.swift; sourceTree = ""; }; 54 | CD1951E122CE910B00EEE445 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 55 | CD9B81832721E41B008E648F /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 56 | CD9B81852721E4FB008E648F /* ListViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViewModelTests.swift; sourceTree = ""; }; 57 | CD9B81892721E54F008E648F /* MockPlayersService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPlayersService.swift; sourceTree = ""; }; 58 | CDA610C922CD55BA00DFECD7 /* ColorAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = ColorAssets.xcassets; sourceTree = ""; }; 59 | CDA610CD22CD56BC00DFECD7 /* UIColor+Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Colors.swift"; sourceTree = ""; }; 60 | CDC2721A2640938100260841 /* PlayerCollectionCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerCollectionCell.swift; sourceTree = ""; }; 61 | CDCCEE8F22AAFF3A00D1A8F9 /* CombineDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CombineDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 62 | CDCCEE9222AAFF3A00D1A8F9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 63 | CDCCEE9422AAFF3A00D1A8F9 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 64 | CDCCEE9622AAFF3A00D1A8F9 /* LoginViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewController.swift; sourceTree = ""; }; 65 | CDCCEE9B22AAFF3B00D1A8F9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 66 | CDCCEE9E22AAFF3B00D1A8F9 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 67 | CDCCEEA022AAFF3B00D1A8F9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 68 | CDCCEEA622AB019100D1A8F9 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; 69 | CDCCEEA922AB01A500D1A8F9 /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = ""; }; 70 | CDCE66CC22C8B74400B1D54C /* ListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViewController.swift; sourceTree = ""; }; 71 | CDCE66CE22C8B7A500B1D54C /* ListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViewModel.swift; sourceTree = ""; }; 72 | CDCE66D022C8B7B300B1D54C /* ListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListView.swift; sourceTree = ""; }; 73 | CDCE66D722C8BABB00B1D54C /* PlayersService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayersService.swift; sourceTree = ""; }; 74 | CDCE66D922C8BB8D00B1D54C /* Player.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Player.swift; sourceTree = ""; }; 75 | CDCE66DB22C8BBDF00B1D54C /* Team.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Team.swift; sourceTree = ""; }; 76 | CDCE66DD22C8C9D200B1D54C /* PlayerData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerData.swift; sourceTree = ""; }; 77 | CDCF8F5A22CE6E1F008A86CA /* UIButton+Validation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIButton+Validation.swift"; sourceTree = ""; }; 78 | CDF9286222CD195D001EF276 /* UITextField+Publisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITextField+Publisher.swift"; sourceTree = ""; }; 79 | CDFDFB5622C934D300117F44 /* PlayerCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerCellViewModel.swift; sourceTree = ""; }; 80 | /* End PBXFileReference section */ 81 | 82 | /* Begin PBXFrameworksBuildPhase section */ 83 | CD1951DA22CE910B00EEE445 /* Frameworks */ = { 84 | isa = PBXFrameworksBuildPhase; 85 | buildActionMask = 2147483647; 86 | files = ( 87 | ); 88 | runOnlyForDeploymentPostprocessing = 0; 89 | }; 90 | CDCCEE8C22AAFF3A00D1A8F9 /* Frameworks */ = { 91 | isa = PBXFrameworksBuildPhase; 92 | buildActionMask = 2147483647; 93 | files = ( 94 | ); 95 | runOnlyForDeploymentPostprocessing = 0; 96 | }; 97 | /* End PBXFrameworksBuildPhase section */ 98 | 99 | /* Begin PBXGroup section */ 100 | 51ED8C4B258E1C16000301C5 /* Shared */ = { 101 | isa = PBXGroup; 102 | children = ( 103 | 51ED8C4C258E1C2A000301C5 /* ActivityIndicatorView.swift */, 104 | ); 105 | path = Shared; 106 | sourceTree = ""; 107 | }; 108 | CD03AA9822CA5CD400914AF4 /* Combine */ = { 109 | isa = PBXGroup; 110 | children = ( 111 | CD03AA9922CA5CDD00914AF4 /* InitialPublished.swift */, 112 | CDF9286222CD195D001EF276 /* UITextField+Publisher.swift */, 113 | ); 114 | path = Combine; 115 | sourceTree = ""; 116 | }; 117 | CD1951DE22CE910B00EEE445 /* CombineDemoTests */ = { 118 | isa = PBXGroup; 119 | children = ( 120 | CD9B81882721E513008E648F /* Mocks */, 121 | CD9B81872721E508008E648F /* Tests */, 122 | CD1951E122CE910B00EEE445 /* Info.plist */, 123 | ); 124 | path = CombineDemoTests; 125 | sourceTree = ""; 126 | }; 127 | CD3607AE22CF713D00748EBF /* Frameworks */ = { 128 | isa = PBXGroup; 129 | children = ( 130 | ); 131 | name = Frameworks; 132 | sourceTree = ""; 133 | }; 134 | CD9B81872721E508008E648F /* Tests */ = { 135 | isa = PBXGroup; 136 | children = ( 137 | CD9B81852721E4FB008E648F /* ListViewModelTests.swift */, 138 | CD1951DF22CE910B00EEE445 /* LoginViewModelTests.swift */, 139 | ); 140 | path = Tests; 141 | sourceTree = ""; 142 | }; 143 | CD9B81882721E513008E648F /* Mocks */ = { 144 | isa = PBXGroup; 145 | children = ( 146 | 518B0F34258F60D30076FFD6 /* MockCredentialsValidator.swift */, 147 | CD9B81892721E54F008E648F /* MockPlayersService.swift */, 148 | ); 149 | path = Mocks; 150 | sourceTree = ""; 151 | }; 152 | CDA610CB22CD569A00DFECD7 /* Extensions */ = { 153 | isa = PBXGroup; 154 | children = ( 155 | CDCF8F5922CE6E06008A86CA /* UIButton */, 156 | CDA610CC22CD56B000DFECD7 /* UIColor */, 157 | CD03AA9822CA5CD400914AF4 /* Combine */, 158 | ); 159 | name = Extensions; 160 | sourceTree = ""; 161 | }; 162 | CDA610CC22CD56B000DFECD7 /* UIColor */ = { 163 | isa = PBXGroup; 164 | children = ( 165 | CDA610CD22CD56BC00DFECD7 /* UIColor+Colors.swift */, 166 | ); 167 | path = UIColor; 168 | sourceTree = ""; 169 | }; 170 | CDCCEE8622AAFF3900D1A8F9 = { 171 | isa = PBXGroup; 172 | children = ( 173 | CDCCEE9122AAFF3A00D1A8F9 /* CombineDemo */, 174 | CD1951DE22CE910B00EEE445 /* CombineDemoTests */, 175 | CDCCEE9022AAFF3A00D1A8F9 /* Products */, 176 | CD3607AE22CF713D00748EBF /* Frameworks */, 177 | ); 178 | sourceTree = ""; 179 | }; 180 | CDCCEE9022AAFF3A00D1A8F9 /* Products */ = { 181 | isa = PBXGroup; 182 | children = ( 183 | CDCCEE8F22AAFF3A00D1A8F9 /* CombineDemo.app */, 184 | CD1951DD22CE910B00EEE445 /* CombineDemoTests.xctest */, 185 | ); 186 | name = Products; 187 | sourceTree = ""; 188 | }; 189 | CDCCEE9122AAFF3A00D1A8F9 /* CombineDemo */ = { 190 | isa = PBXGroup; 191 | children = ( 192 | 51ED8C4B258E1C16000301C5 /* Shared */, 193 | CDCCEE9222AAFF3A00D1A8F9 /* AppDelegate.swift */, 194 | CDCCEE9422AAFF3A00D1A8F9 /* SceneDelegate.swift */, 195 | CDA610CB22CD569A00DFECD7 /* Extensions */, 196 | CDCE66D222C8B9AF00B1D54C /* API */, 197 | CDCCEEA822AB019600D1A8F9 /* Login */, 198 | CDCE66CB22C8B72A00B1D54C /* List */, 199 | CDCCEE9B22AAFF3B00D1A8F9 /* Assets.xcassets */, 200 | CDA610C922CD55BA00DFECD7 /* ColorAssets.xcassets */, 201 | CDCCEE9D22AAFF3B00D1A8F9 /* LaunchScreen.storyboard */, 202 | CDCCEEA022AAFF3B00D1A8F9 /* Info.plist */, 203 | ); 204 | path = CombineDemo; 205 | sourceTree = ""; 206 | }; 207 | CDCCEEA822AB019600D1A8F9 /* Login */ = { 208 | isa = PBXGroup; 209 | children = ( 210 | CDCCEE9622AAFF3A00D1A8F9 /* LoginViewController.swift */, 211 | CDCCEEA922AB01A500D1A8F9 /* LoginViewModel.swift */, 212 | CDCCEEA622AB019100D1A8F9 /* LoginView.swift */, 213 | ); 214 | path = Login; 215 | sourceTree = ""; 216 | }; 217 | CDCE66CB22C8B72A00B1D54C /* List */ = { 218 | isa = PBXGroup; 219 | children = ( 220 | CDCE66CC22C8B74400B1D54C /* ListViewController.swift */, 221 | CDCE66CE22C8B7A500B1D54C /* ListViewModel.swift */, 222 | CDCE66D022C8B7B300B1D54C /* ListView.swift */, 223 | CDFDFB5522C934C400117F44 /* Cell */, 224 | ); 225 | path = List; 226 | sourceTree = ""; 227 | }; 228 | CDCE66D222C8B9AF00B1D54C /* API */ = { 229 | isa = PBXGroup; 230 | children = ( 231 | CDCE66D422C8B9BB00B1D54C /* Model */, 232 | CDCE66D322C8B9B600B1D54C /* Service */, 233 | CD9B81832721E41B008E648F /* Constants.swift */, 234 | ); 235 | path = API; 236 | sourceTree = ""; 237 | }; 238 | CDCE66D322C8B9B600B1D54C /* Service */ = { 239 | isa = PBXGroup; 240 | children = ( 241 | CDCE66D722C8BABB00B1D54C /* PlayersService.swift */, 242 | ); 243 | path = Service; 244 | sourceTree = ""; 245 | }; 246 | CDCE66D422C8B9BB00B1D54C /* Model */ = { 247 | isa = PBXGroup; 248 | children = ( 249 | CDCE66DD22C8C9D200B1D54C /* PlayerData.swift */, 250 | CDCE66D922C8BB8D00B1D54C /* Player.swift */, 251 | CDCE66DB22C8BBDF00B1D54C /* Team.swift */, 252 | ); 253 | path = Model; 254 | sourceTree = ""; 255 | }; 256 | CDCF8F5922CE6E06008A86CA /* UIButton */ = { 257 | isa = PBXGroup; 258 | children = ( 259 | CDCF8F5A22CE6E1F008A86CA /* UIButton+Validation.swift */, 260 | ); 261 | path = UIButton; 262 | sourceTree = ""; 263 | }; 264 | CDFDFB5522C934C400117F44 /* Cell */ = { 265 | isa = PBXGroup; 266 | children = ( 267 | CDC2721A2640938100260841 /* PlayerCollectionCell.swift */, 268 | CDFDFB5622C934D300117F44 /* PlayerCellViewModel.swift */, 269 | ); 270 | path = Cell; 271 | sourceTree = ""; 272 | }; 273 | /* End PBXGroup section */ 274 | 275 | /* Begin PBXNativeTarget section */ 276 | CD1951DC22CE910B00EEE445 /* CombineDemoTests */ = { 277 | isa = PBXNativeTarget; 278 | buildConfigurationList = CD1951E622CE910B00EEE445 /* Build configuration list for PBXNativeTarget "CombineDemoTests" */; 279 | buildPhases = ( 280 | CD1951D922CE910B00EEE445 /* Sources */, 281 | CD1951DA22CE910B00EEE445 /* Frameworks */, 282 | CD1951DB22CE910B00EEE445 /* Resources */, 283 | ); 284 | buildRules = ( 285 | ); 286 | dependencies = ( 287 | CD1951E322CE910B00EEE445 /* PBXTargetDependency */, 288 | ); 289 | name = CombineDemoTests; 290 | productName = CombineDemoTests; 291 | productReference = CD1951DD22CE910B00EEE445 /* CombineDemoTests.xctest */; 292 | productType = "com.apple.product-type.bundle.unit-test"; 293 | }; 294 | CDCCEE8E22AAFF3A00D1A8F9 /* CombineDemo */ = { 295 | isa = PBXNativeTarget; 296 | buildConfigurationList = CDCCEEA322AAFF3B00D1A8F9 /* Build configuration list for PBXNativeTarget "CombineDemo" */; 297 | buildPhases = ( 298 | CDCCEE8B22AAFF3A00D1A8F9 /* Sources */, 299 | CDCCEE8C22AAFF3A00D1A8F9 /* Frameworks */, 300 | CDCCEE8D22AAFF3A00D1A8F9 /* Resources */, 301 | ); 302 | buildRules = ( 303 | ); 304 | dependencies = ( 305 | ); 306 | name = CombineDemo; 307 | packageProductDependencies = ( 308 | ); 309 | productName = CombineDemo; 310 | productReference = CDCCEE8F22AAFF3A00D1A8F9 /* CombineDemo.app */; 311 | productType = "com.apple.product-type.application"; 312 | }; 313 | /* End PBXNativeTarget section */ 314 | 315 | /* Begin PBXProject section */ 316 | CDCCEE8722AAFF3A00D1A8F9 /* Project object */ = { 317 | isa = PBXProject; 318 | attributes = { 319 | LastSwiftUpdateCheck = 1100; 320 | LastUpgradeCheck = 1100; 321 | ORGANIZATIONNAME = codeuqest; 322 | TargetAttributes = { 323 | CD1951DC22CE910B00EEE445 = { 324 | CreatedOnToolsVersion = 11.0; 325 | TestTargetID = CDCCEE8E22AAFF3A00D1A8F9; 326 | }; 327 | CDCCEE8E22AAFF3A00D1A8F9 = { 328 | CreatedOnToolsVersion = 11.0; 329 | }; 330 | }; 331 | }; 332 | buildConfigurationList = CDCCEE8A22AAFF3A00D1A8F9 /* Build configuration list for PBXProject "CombineDemo" */; 333 | compatibilityVersion = "Xcode 9.3"; 334 | developmentRegion = en; 335 | hasScannedForEncodings = 0; 336 | knownRegions = ( 337 | en, 338 | Base, 339 | ); 340 | mainGroup = CDCCEE8622AAFF3900D1A8F9; 341 | packageReferences = ( 342 | ); 343 | productRefGroup = CDCCEE9022AAFF3A00D1A8F9 /* Products */; 344 | projectDirPath = ""; 345 | projectRoot = ""; 346 | targets = ( 347 | CDCCEE8E22AAFF3A00D1A8F9 /* CombineDemo */, 348 | CD1951DC22CE910B00EEE445 /* CombineDemoTests */, 349 | ); 350 | }; 351 | /* End PBXProject section */ 352 | 353 | /* Begin PBXResourcesBuildPhase section */ 354 | CD1951DB22CE910B00EEE445 /* Resources */ = { 355 | isa = PBXResourcesBuildPhase; 356 | buildActionMask = 2147483647; 357 | files = ( 358 | ); 359 | runOnlyForDeploymentPostprocessing = 0; 360 | }; 361 | CDCCEE8D22AAFF3A00D1A8F9 /* Resources */ = { 362 | isa = PBXResourcesBuildPhase; 363 | buildActionMask = 2147483647; 364 | files = ( 365 | CDCCEE9F22AAFF3B00D1A8F9 /* LaunchScreen.storyboard in Resources */, 366 | CDA610CA22CD55BA00DFECD7 /* ColorAssets.xcassets in Resources */, 367 | CDCCEE9C22AAFF3B00D1A8F9 /* Assets.xcassets in Resources */, 368 | ); 369 | runOnlyForDeploymentPostprocessing = 0; 370 | }; 371 | /* End PBXResourcesBuildPhase section */ 372 | 373 | /* Begin PBXSourcesBuildPhase section */ 374 | CD1951D922CE910B00EEE445 /* Sources */ = { 375 | isa = PBXSourcesBuildPhase; 376 | buildActionMask = 2147483647; 377 | files = ( 378 | CD1951E022CE910B00EEE445 /* LoginViewModelTests.swift in Sources */, 379 | 518B0F35258F60D30076FFD6 /* MockCredentialsValidator.swift in Sources */, 380 | CD9B818A2721E54F008E648F /* MockPlayersService.swift in Sources */, 381 | CD9B81862721E4FB008E648F /* ListViewModelTests.swift in Sources */, 382 | ); 383 | runOnlyForDeploymentPostprocessing = 0; 384 | }; 385 | CDCCEE8B22AAFF3A00D1A8F9 /* Sources */ = { 386 | isa = PBXSourcesBuildPhase; 387 | buildActionMask = 2147483647; 388 | files = ( 389 | CDCE66DC22C8BBDF00B1D54C /* Team.swift in Sources */, 390 | CDA610CE22CD56BC00DFECD7 /* UIColor+Colors.swift in Sources */, 391 | CDCE66DA22C8BB8D00B1D54C /* Player.swift in Sources */, 392 | CDF9286322CD195D001EF276 /* UITextField+Publisher.swift in Sources */, 393 | CDCE66D122C8B7B300B1D54C /* ListView.swift in Sources */, 394 | CDCCEEAA22AB01A500D1A8F9 /* LoginViewModel.swift in Sources */, 395 | CDC2721B2640938100260841 /* PlayerCollectionCell.swift in Sources */, 396 | CDCE66DE22C8C9D200B1D54C /* PlayerData.swift in Sources */, 397 | CDCCEEA722AB019100D1A8F9 /* LoginView.swift in Sources */, 398 | CDCE66CF22C8B7A500B1D54C /* ListViewModel.swift in Sources */, 399 | CDFDFB5722C934D300117F44 /* PlayerCellViewModel.swift in Sources */, 400 | CDCE66CD22C8B74400B1D54C /* ListViewController.swift in Sources */, 401 | CDCCEE9722AAFF3A00D1A8F9 /* LoginViewController.swift in Sources */, 402 | 51ED8C4D258E1C2A000301C5 /* ActivityIndicatorView.swift in Sources */, 403 | CDCF8F5B22CE6E1F008A86CA /* UIButton+Validation.swift in Sources */, 404 | CDCCEE9322AAFF3A00D1A8F9 /* AppDelegate.swift in Sources */, 405 | CD03AA9A22CA5CDD00914AF4 /* InitialPublished.swift in Sources */, 406 | CDCCEE9522AAFF3A00D1A8F9 /* SceneDelegate.swift in Sources */, 407 | CDCE66D822C8BABB00B1D54C /* PlayersService.swift in Sources */, 408 | ); 409 | runOnlyForDeploymentPostprocessing = 0; 410 | }; 411 | /* End PBXSourcesBuildPhase section */ 412 | 413 | /* Begin PBXTargetDependency section */ 414 | CD1951E322CE910B00EEE445 /* PBXTargetDependency */ = { 415 | isa = PBXTargetDependency; 416 | target = CDCCEE8E22AAFF3A00D1A8F9 /* CombineDemo */; 417 | targetProxy = CD1951E222CE910B00EEE445 /* PBXContainerItemProxy */; 418 | }; 419 | /* End PBXTargetDependency section */ 420 | 421 | /* Begin PBXVariantGroup section */ 422 | CDCCEE9D22AAFF3B00D1A8F9 /* LaunchScreen.storyboard */ = { 423 | isa = PBXVariantGroup; 424 | children = ( 425 | CDCCEE9E22AAFF3B00D1A8F9 /* Base */, 426 | ); 427 | name = LaunchScreen.storyboard; 428 | sourceTree = ""; 429 | }; 430 | /* End PBXVariantGroup section */ 431 | 432 | /* Begin XCBuildConfiguration section */ 433 | CD1951E422CE910B00EEE445 /* Debug */ = { 434 | isa = XCBuildConfiguration; 435 | buildSettings = { 436 | BUNDLE_LOADER = "$(TEST_HOST)"; 437 | CODE_SIGN_STYLE = Automatic; 438 | DEVELOPMENT_TEAM = W5SLRW4B3C; 439 | INFOPLIST_FILE = CombineDemoTests/Info.plist; 440 | LD_RUNPATH_SEARCH_PATHS = ( 441 | "$(inherited)", 442 | "@executable_path/Frameworks", 443 | "@loader_path/Frameworks", 444 | ); 445 | PRODUCT_BUNDLE_IDENTIFIER = com.mcichecki.CombineDemoTests; 446 | PRODUCT_NAME = "$(TARGET_NAME)"; 447 | SWIFT_VERSION = 5.0; 448 | TARGETED_DEVICE_FAMILY = "1,2"; 449 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CombineDemo.app/CombineDemo"; 450 | }; 451 | name = Debug; 452 | }; 453 | CD1951E522CE910B00EEE445 /* Release */ = { 454 | isa = XCBuildConfiguration; 455 | buildSettings = { 456 | BUNDLE_LOADER = "$(TEST_HOST)"; 457 | CODE_SIGN_STYLE = Automatic; 458 | DEVELOPMENT_TEAM = W5SLRW4B3C; 459 | INFOPLIST_FILE = CombineDemoTests/Info.plist; 460 | LD_RUNPATH_SEARCH_PATHS = ( 461 | "$(inherited)", 462 | "@executable_path/Frameworks", 463 | "@loader_path/Frameworks", 464 | ); 465 | PRODUCT_BUNDLE_IDENTIFIER = com.mcichecki.CombineDemoTests; 466 | PRODUCT_NAME = "$(TARGET_NAME)"; 467 | SWIFT_VERSION = 5.0; 468 | TARGETED_DEVICE_FAMILY = "1,2"; 469 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CombineDemo.app/CombineDemo"; 470 | }; 471 | name = Release; 472 | }; 473 | CDCCEEA122AAFF3B00D1A8F9 /* Debug */ = { 474 | isa = XCBuildConfiguration; 475 | buildSettings = { 476 | ALWAYS_SEARCH_USER_PATHS = NO; 477 | CLANG_ANALYZER_NONNULL = YES; 478 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 479 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 480 | CLANG_CXX_LIBRARY = "libc++"; 481 | CLANG_ENABLE_MODULES = YES; 482 | CLANG_ENABLE_OBJC_ARC = YES; 483 | CLANG_ENABLE_OBJC_WEAK = YES; 484 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 485 | CLANG_WARN_BOOL_CONVERSION = YES; 486 | CLANG_WARN_COMMA = YES; 487 | CLANG_WARN_CONSTANT_CONVERSION = YES; 488 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 489 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 490 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 491 | CLANG_WARN_EMPTY_BODY = YES; 492 | CLANG_WARN_ENUM_CONVERSION = YES; 493 | CLANG_WARN_INFINITE_RECURSION = YES; 494 | CLANG_WARN_INT_CONVERSION = YES; 495 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 496 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 497 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 498 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 499 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 500 | CLANG_WARN_STRICT_PROTOTYPES = YES; 501 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 502 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 503 | CLANG_WARN_UNREACHABLE_CODE = YES; 504 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 505 | COPY_PHASE_STRIP = NO; 506 | DEBUG_INFORMATION_FORMAT = dwarf; 507 | ENABLE_STRICT_OBJC_MSGSEND = YES; 508 | ENABLE_TESTABILITY = YES; 509 | GCC_C_LANGUAGE_STANDARD = gnu11; 510 | GCC_DYNAMIC_NO_PIC = NO; 511 | GCC_NO_COMMON_BLOCKS = YES; 512 | GCC_OPTIMIZATION_LEVEL = 0; 513 | GCC_PREPROCESSOR_DEFINITIONS = ( 514 | "DEBUG=1", 515 | "$(inherited)", 516 | ); 517 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 518 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 519 | GCC_WARN_UNDECLARED_SELECTOR = YES; 520 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 521 | GCC_WARN_UNUSED_FUNCTION = YES; 522 | GCC_WARN_UNUSED_VARIABLE = YES; 523 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 524 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 525 | MTL_FAST_MATH = YES; 526 | ONLY_ACTIVE_ARCH = YES; 527 | SDKROOT = iphoneos; 528 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 529 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 530 | }; 531 | name = Debug; 532 | }; 533 | CDCCEEA222AAFF3B00D1A8F9 /* Release */ = { 534 | isa = XCBuildConfiguration; 535 | buildSettings = { 536 | ALWAYS_SEARCH_USER_PATHS = NO; 537 | CLANG_ANALYZER_NONNULL = YES; 538 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 539 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 540 | CLANG_CXX_LIBRARY = "libc++"; 541 | CLANG_ENABLE_MODULES = YES; 542 | CLANG_ENABLE_OBJC_ARC = YES; 543 | CLANG_ENABLE_OBJC_WEAK = YES; 544 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 545 | CLANG_WARN_BOOL_CONVERSION = YES; 546 | CLANG_WARN_COMMA = YES; 547 | CLANG_WARN_CONSTANT_CONVERSION = YES; 548 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 549 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 550 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 551 | CLANG_WARN_EMPTY_BODY = YES; 552 | CLANG_WARN_ENUM_CONVERSION = YES; 553 | CLANG_WARN_INFINITE_RECURSION = YES; 554 | CLANG_WARN_INT_CONVERSION = YES; 555 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 556 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 557 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 558 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 559 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 560 | CLANG_WARN_STRICT_PROTOTYPES = YES; 561 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 562 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 563 | CLANG_WARN_UNREACHABLE_CODE = YES; 564 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 565 | COPY_PHASE_STRIP = NO; 566 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 567 | ENABLE_NS_ASSERTIONS = NO; 568 | ENABLE_STRICT_OBJC_MSGSEND = YES; 569 | GCC_C_LANGUAGE_STANDARD = gnu11; 570 | GCC_NO_COMMON_BLOCKS = YES; 571 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 572 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 573 | GCC_WARN_UNDECLARED_SELECTOR = YES; 574 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 575 | GCC_WARN_UNUSED_FUNCTION = YES; 576 | GCC_WARN_UNUSED_VARIABLE = YES; 577 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 578 | MTL_ENABLE_DEBUG_INFO = NO; 579 | MTL_FAST_MATH = YES; 580 | SDKROOT = iphoneos; 581 | SWIFT_COMPILATION_MODE = wholemodule; 582 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 583 | VALIDATE_PRODUCT = YES; 584 | }; 585 | name = Release; 586 | }; 587 | CDCCEEA422AAFF3B00D1A8F9 /* Debug */ = { 588 | isa = XCBuildConfiguration; 589 | buildSettings = { 590 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 591 | CODE_SIGN_STYLE = Automatic; 592 | DEVELOPMENT_TEAM = W5SLRW4B3C; 593 | INFOPLIST_FILE = CombineDemo/Info.plist; 594 | LD_RUNPATH_SEARCH_PATHS = ( 595 | "$(inherited)", 596 | "@executable_path/Frameworks", 597 | ); 598 | PRODUCT_BUNDLE_IDENTIFIER = com.mcichecki.CombineDemo; 599 | PRODUCT_NAME = "$(TARGET_NAME)"; 600 | SWIFT_VERSION = 5.0; 601 | TARGETED_DEVICE_FAMILY = "1,2"; 602 | }; 603 | name = Debug; 604 | }; 605 | CDCCEEA522AAFF3B00D1A8F9 /* Release */ = { 606 | isa = XCBuildConfiguration; 607 | buildSettings = { 608 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 609 | CODE_SIGN_STYLE = Automatic; 610 | DEVELOPMENT_TEAM = W5SLRW4B3C; 611 | INFOPLIST_FILE = CombineDemo/Info.plist; 612 | LD_RUNPATH_SEARCH_PATHS = ( 613 | "$(inherited)", 614 | "@executable_path/Frameworks", 615 | ); 616 | PRODUCT_BUNDLE_IDENTIFIER = com.mcichecki.CombineDemo; 617 | PRODUCT_NAME = "$(TARGET_NAME)"; 618 | SWIFT_VERSION = 5.0; 619 | TARGETED_DEVICE_FAMILY = "1,2"; 620 | }; 621 | name = Release; 622 | }; 623 | /* End XCBuildConfiguration section */ 624 | 625 | /* Begin XCConfigurationList section */ 626 | CD1951E622CE910B00EEE445 /* Build configuration list for PBXNativeTarget "CombineDemoTests" */ = { 627 | isa = XCConfigurationList; 628 | buildConfigurations = ( 629 | CD1951E422CE910B00EEE445 /* Debug */, 630 | CD1951E522CE910B00EEE445 /* Release */, 631 | ); 632 | defaultConfigurationIsVisible = 0; 633 | defaultConfigurationName = Release; 634 | }; 635 | CDCCEE8A22AAFF3A00D1A8F9 /* Build configuration list for PBXProject "CombineDemo" */ = { 636 | isa = XCConfigurationList; 637 | buildConfigurations = ( 638 | CDCCEEA122AAFF3B00D1A8F9 /* Debug */, 639 | CDCCEEA222AAFF3B00D1A8F9 /* Release */, 640 | ); 641 | defaultConfigurationIsVisible = 0; 642 | defaultConfigurationName = Release; 643 | }; 644 | CDCCEEA322AAFF3B00D1A8F9 /* Build configuration list for PBXNativeTarget "CombineDemo" */ = { 645 | isa = XCConfigurationList; 646 | buildConfigurations = ( 647 | CDCCEEA422AAFF3B00D1A8F9 /* Debug */, 648 | CDCCEEA522AAFF3B00D1A8F9 /* Release */, 649 | ); 650 | defaultConfigurationIsVisible = 0; 651 | defaultConfigurationName = Release; 652 | }; 653 | /* End XCConfigurationList section */ 654 | }; 655 | rootObject = CDCCEE8722AAFF3A00D1A8F9 /* Project object */; 656 | } 657 | -------------------------------------------------------------------------------- /CombineDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /CombineDemo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /CombineDemo.xcodeproj/project.xcworkspace/xcuserdata/mcichecki.xcuserdatad/IDEFindNavigatorScopes.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /CombineDemo.xcodeproj/project.xcworkspace/xcuserdata/mcichecki.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcichecki/combine-mvvm/d0410ba65c10d013c2701cb7470072700eab6642/CombineDemo.xcodeproj/project.xcworkspace/xcuserdata/mcichecki.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /CombineDemo.xcodeproj/xcuserdata/mcichecki.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 9 | 16 | 17 | 18 | 20 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /CombineDemo.xcodeproj/xcuserdata/mcichecki.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | CombineDemo.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /CombineDemo/API/Model/Player.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Player.swift 3 | // CombineDemo 4 | // 5 | // Created by Michal Cichecki on 30/06/2019. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Player: Equatable, Hashable, Decodable { 11 | var firstName: String 12 | var lastName: String 13 | let team: Team 14 | } 15 | 16 | extension Player { 17 | enum CodingKeys: String, CodingKey { 18 | case first_name 19 | case last_name 20 | case team 21 | } 22 | 23 | init(from decoder: Decoder) throws { 24 | let container = try decoder.container(keyedBy: CodingKeys.self) 25 | firstName = try container.decode(String.self, forKey: .first_name) 26 | lastName = try container.decode(String.self, forKey: .last_name) 27 | team = try container.decode(Team.self, forKey: .team) 28 | } 29 | } 30 | 31 | 32 | -------------------------------------------------------------------------------- /CombineDemo/API/Model/PlayerData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayerData.swift 3 | // CombineDemo 4 | // 5 | // Created by Michal Cichecki on 30/06/2019. 6 | // 7 | 8 | import Foundation 9 | 10 | struct PlayerData: Decodable { 11 | let data: [Player] 12 | } 13 | -------------------------------------------------------------------------------- /CombineDemo/API/Model/Team.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Team.swift 3 | // CombineDemo 4 | // 5 | // Created by Michal Cichecki on 30/06/2019. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Team: Decodable, Equatable, Hashable { 11 | var abbreviation: String 12 | } 13 | 14 | extension Team: ExpressibleByStringLiteral { 15 | init(stringLiteral value: StringLiteralType) { 16 | self = Team(abbreviation: value) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /CombineDemo/API/Service/PlayersService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayersService.swift 3 | // CombineDemo 4 | // 5 | // Created by Michal Cichecki on 30/06/2019. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | enum ServiceError: Error { 12 | case url(URLError) 13 | case urlRequest 14 | case decode 15 | } 16 | 17 | protocol PlayersServiceProtocol { 18 | func get(searchTerm: String?) -> AnyPublisher<[Player], Error> 19 | } 20 | 21 | let apiKey: String = "" // use your rapidapi 22 | 23 | final class PlayersService: PlayersServiceProtocol { 24 | 25 | func get(searchTerm: String?) -> AnyPublisher<[Player], Error> { 26 | var dataTask: URLSessionDataTask? 27 | 28 | let onSubscription: (Subscription) -> Void = { _ in dataTask?.resume() } 29 | let onCancel: () -> Void = { dataTask?.cancel() } 30 | 31 | // promise type is Result<[Player], Error> 32 | return Future<[Player], Error> { [weak self] promise in 33 | guard let urlRequest = self?.getUrlRequest(searchTerm: searchTerm) else { 34 | promise(.failure(ServiceError.urlRequest)) 35 | return 36 | } 37 | 38 | dataTask = URLSession.shared.dataTask(with: urlRequest) { (data, _, error) in 39 | guard let data = data else { 40 | if let error = error { 41 | promise(.failure(error)) 42 | } 43 | return 44 | } 45 | do { 46 | let players = try JSONDecoder().decode(PlayerData.self, from: data) 47 | promise(.success(players.data)) 48 | } catch { 49 | promise(.failure(ServiceError.decode)) 50 | } 51 | } 52 | } 53 | .handleEvents(receiveSubscription: onSubscription, receiveCancel: onCancel) 54 | .receive(on: DispatchQueue.main) 55 | .eraseToAnyPublisher() 56 | } 57 | 58 | private func getUrlRequest(searchTerm: String?) -> URLRequest? { 59 | var components = URLComponents() 60 | components.scheme = "https" 61 | components.host = "free-nba.p.rapidapi.com" 62 | components.path = "/players" 63 | if let searchTerm = searchTerm, !searchTerm.isEmpty { 64 | components.queryItems = [ 65 | URLQueryItem(name: "search", value: searchTerm) 66 | ] 67 | } 68 | 69 | guard let url = components.url else { return nil } 70 | 71 | var urlRequest = URLRequest(url: url) 72 | urlRequest.timeoutInterval = 10.0 73 | urlRequest.httpMethod = "GET" 74 | urlRequest.allHTTPHeaderFields = [ 75 | "X-RapidAPI-Host": "free-nba.p.rapidapi.com", 76 | "X-RapidAPI-Key": apiKey 77 | ] 78 | return urlRequest 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /CombineDemo/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // CombineDemo 4 | // 5 | // Created by Michal Cichecki on 07/06/2019. 6 | // 7 | 8 | import UIKit 9 | 10 | @UIApplicationMain 11 | class AppDelegate: UIResponder, UIApplicationDelegate { 12 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 13 | true 14 | } 15 | } 16 | 17 | -------------------------------------------------------------------------------- /CombineDemo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /CombineDemo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /CombineDemo/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 | -------------------------------------------------------------------------------- /CombineDemo/ColorAssets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /CombineDemo/ColorAssets.xcassets/mainBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | }, 6 | "colors" : [ 7 | { 8 | "idiom" : "universal", 9 | "color" : { 10 | "color-space" : "srgb", 11 | "components" : { 12 | "red" : "240", 13 | "alpha" : "1.000", 14 | "blue" : "240", 15 | "green" : "240" 16 | } 17 | } 18 | }, 19 | { 20 | "idiom" : "universal", 21 | "appearances" : [ 22 | { 23 | "appearance" : "luminosity", 24 | "value" : "dark" 25 | } 26 | ], 27 | "color" : { 28 | "color-space" : "srgb", 29 | "components" : { 30 | "red" : "55", 31 | "alpha" : "1.000", 32 | "blue" : "55", 33 | "green" : "55" 34 | } 35 | } 36 | } 37 | ] 38 | } -------------------------------------------------------------------------------- /CombineDemo/ColorAssets.xcassets/nonValid.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | }, 6 | "colors" : [ 7 | { 8 | "idiom" : "universal", 9 | "color" : { 10 | "color-space" : "srgb", 11 | "components" : { 12 | "red" : "255", 13 | "alpha" : "1.000", 14 | "blue" : "45", 15 | "green" : "57" 16 | } 17 | } 18 | }, 19 | { 20 | "idiom" : "universal", 21 | "appearances" : [ 22 | { 23 | "appearance" : "luminosity", 24 | "value" : "dark" 25 | } 26 | ], 27 | "color" : { 28 | "color-space" : "srgb", 29 | "components" : { 30 | "red" : "253", 31 | "alpha" : "1.000", 32 | "blue" : "59", 33 | "green" : "73" 34 | } 35 | } 36 | } 37 | ] 38 | } -------------------------------------------------------------------------------- /CombineDemo/ColorAssets.xcassets/valid.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | }, 6 | "colors" : [ 7 | { 8 | "idiom" : "universal", 9 | "color" : { 10 | "color-space" : "srgb", 11 | "components" : { 12 | "red" : "36", 13 | "alpha" : "1.000", 14 | "blue" : "62", 15 | "green" : "206" 16 | } 17 | } 18 | }, 19 | { 20 | "idiom" : "universal", 21 | "appearances" : [ 22 | { 23 | "appearance" : "luminosity", 24 | "value" : "dark" 25 | } 26 | ], 27 | "color" : { 28 | "color-space" : "srgb", 29 | "components" : { 30 | "red" : "52", 31 | "alpha" : "1.000", 32 | "blue" : "73", 33 | "green" : "215" 34 | } 35 | } 36 | } 37 | ] 38 | } -------------------------------------------------------------------------------- /CombineDemo/Combine/InitialPublished.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InitialPublished.swift 3 | // CombineDemo 4 | // 5 | // Created by Michal Cichecki on 01/07/2019. 6 | // 7 | 8 | import Combine 9 | 10 | @propertyWrapper // @propertyDelegate 11 | struct InitialPublished: Publisher { 12 | typealias Output = Value 13 | typealias Failure = Never 14 | 15 | private let subject: CurrentValueSubject 16 | 17 | var wrappedValue: Value { 18 | get { subject.value } 19 | set { subject.send(newValue) } 20 | } 21 | 22 | init(wrappedValue initialValue: Value) { 23 | subject = CurrentValueSubject(initialValue) 24 | } 25 | 26 | func receive(subscriber: S) where S : Subscriber, Failure == S.Failure, Value == S.Input { 27 | subject.receive(subscriber: subscriber) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /CombineDemo/Combine/UITextField+Publisher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UITextField+Publisher.swift 3 | // CombineDemo 4 | // 5 | // Created by Michal Cichecki on 03/07/2019. 6 | // 7 | 8 | import UIKit 9 | import Combine 10 | 11 | extension UITextField { 12 | var textPublisher: AnyPublisher { 13 | NotificationCenter.default 14 | .publisher(for: UITextField.textDidChangeNotification, object: self) 15 | .compactMap { $0.object as? UITextField } // receiving notifications with objects which are instances of UITextFields 16 | .compactMap(\.text) // extracting text and removing optional values (even though the text cannot be nil) 17 | .eraseToAnyPublisher() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /CombineDemo/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UILaunchStoryboardName 33 | LaunchScreen 34 | UISceneConfigurationName 35 | Default Configuration 36 | UISceneDelegateClassName 37 | $(PRODUCT_MODULE_NAME).SceneDelegate 38 | 39 | 40 | 41 | 42 | UILaunchStoryboardName 43 | LaunchScreen 44 | UIRequiredDeviceCapabilities 45 | 46 | armv7 47 | 48 | UISupportedInterfaceOrientations 49 | 50 | UIInterfaceOrientationPortrait 51 | 52 | UISupportedInterfaceOrientations~ipad 53 | 54 | UIInterfaceOrientationPortrait 55 | UIInterfaceOrientationPortraitUpsideDown 56 | UIInterfaceOrientationLandscapeLeft 57 | UIInterfaceOrientationLandscapeRight 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /CombineDemo/List/Cell/PlayerCellViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayerCellViewModel.swift 3 | // CombineDemo 4 | // 5 | // Created by Michal Cichecki on 30/06/2019. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | final class PlayerCellViewModel { 12 | @Published var playerName: String = "" 13 | @Published var team: String = "" 14 | 15 | private let player: Player 16 | 17 | init(player: Player) { 18 | self.player = player 19 | 20 | setUpBindings() 21 | } 22 | 23 | private func setUpBindings() { 24 | playerName = [player.firstName, player.lastName].joined(separator: " ") 25 | team = player.team.abbreviation 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /CombineDemo/List/Cell/PlayerCollectionCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayerCollectionCell.swift 3 | // CombineDemo 4 | // 5 | // Created by Michal Cichecki on 30/06/2019. 6 | // 7 | 8 | import UIKit 9 | import Combine 10 | 11 | final class PlayerCollectionCell: UICollectionViewCell { 12 | static let identifier = "PlayerTableViewCell" 13 | 14 | var viewModel: PlayerCellViewModel! { 15 | didSet { setUpViewModel() } 16 | } 17 | 18 | lazy var playerNameLabel = UILabel() 19 | lazy var teamLabel = UILabel() 20 | 21 | override init(frame: CGRect) { 22 | super.init(frame: .zero) 23 | 24 | addSubiews() 25 | setUpConstraints() 26 | } 27 | 28 | required init?(coder: NSCoder) { 29 | fatalError("init(coder:) has not been implemented") 30 | } 31 | 32 | private func addSubiews() { 33 | let subviews = [playerNameLabel, teamLabel] 34 | 35 | subviews.forEach { 36 | contentView.addSubview($0) 37 | $0.translatesAutoresizingMaskIntoConstraints = false 38 | } 39 | } 40 | 41 | private func setUpConstraints() { 42 | NSLayoutConstraint.activate([ 43 | playerNameLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10.0), 44 | playerNameLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10.0), 45 | playerNameLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10.0), 46 | 47 | teamLabel.centerYAnchor.constraint(equalTo: playerNameLabel.centerYAnchor), 48 | teamLabel.leadingAnchor.constraint(equalTo: playerNameLabel.trailingAnchor, constant: 10.0), 49 | teamLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10.0), 50 | teamLabel.heightAnchor.constraint(equalTo: playerNameLabel.heightAnchor) 51 | ]) 52 | } 53 | 54 | private func setUpViewModel() { 55 | playerNameLabel.text = viewModel.playerName 56 | teamLabel.text = viewModel.team 57 | 58 | // accessing PropertyWrapper does not work here 59 | // viewModel.$playerName.assign(to: \.text, on: playerNameLabel) 60 | // viewModel.$team.assign(to: \.text, on: teamLabel) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /CombineDemo/List/ListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListView.swift 3 | // CombineDemo 4 | // 5 | // Created by Michal Cichecki on 30/06/2019. 6 | // 7 | 8 | import UIKit 9 | 10 | final class ListView: UIView { 11 | lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout()) 12 | lazy var searchTextField = UITextField() 13 | lazy var activityIndicationView = ActivityIndicatorView(style: .medium) 14 | 15 | init() { 16 | super.init(frame: .zero) 17 | 18 | addSubviews() 19 | setUpConstraints() 20 | setUpViews() 21 | } 22 | 23 | required init?(coder: NSCoder) { 24 | fatalError("init(coder:) has not been implemented") 25 | } 26 | 27 | private func addSubviews() { 28 | let subviews = [searchTextField, collectionView, activityIndicationView] 29 | 30 | subviews.forEach { 31 | addSubview($0) 32 | $0.translatesAutoresizingMaskIntoConstraints = false 33 | } 34 | } 35 | 36 | func startLoading() { 37 | collectionView.isUserInteractionEnabled = false 38 | searchTextField.isUserInteractionEnabled = false 39 | 40 | activityIndicationView.isHidden = false 41 | activityIndicationView.startAnimating() 42 | } 43 | 44 | func finishLoading() { 45 | collectionView.isUserInteractionEnabled = true 46 | searchTextField.isUserInteractionEnabled = true 47 | 48 | activityIndicationView.stopAnimating() 49 | } 50 | 51 | private func setUpConstraints() { 52 | let defaultMargin: CGFloat = 4.0 53 | 54 | NSLayoutConstraint.activate([ 55 | searchTextField.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: defaultMargin), 56 | searchTextField.leadingAnchor.constraint(equalTo: leadingAnchor, constant: defaultMargin), 57 | searchTextField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -defaultMargin), 58 | searchTextField.heightAnchor.constraint(equalToConstant: 30.0), 59 | 60 | collectionView.leadingAnchor.constraint(equalTo: leadingAnchor), 61 | collectionView.trailingAnchor.constraint(equalTo: trailingAnchor), 62 | collectionView.topAnchor.constraint(equalTo: searchTextField.bottomAnchor, constant: defaultMargin), 63 | collectionView.bottomAnchor.constraint(equalTo: bottomAnchor), 64 | 65 | activityIndicationView.centerXAnchor.constraint(equalTo: centerXAnchor), 66 | activityIndicationView.centerYAnchor.constraint(equalTo: centerYAnchor), 67 | activityIndicationView.heightAnchor.constraint(equalToConstant: 50), 68 | activityIndicationView.widthAnchor.constraint(equalToConstant: 50.0) 69 | ]) 70 | } 71 | 72 | private func setUpViews() { 73 | collectionView.backgroundColor = .background 74 | 75 | searchTextField.autocorrectionType = .no 76 | searchTextField.backgroundColor = .background 77 | searchTextField.placeholder = "NBA Player" 78 | } 79 | 80 | private func createLayout() -> UICollectionViewLayout { 81 | let size = NSCollectionLayoutSize( 82 | widthDimension: .fractionalWidth(1), 83 | heightDimension: .estimated(40)) 84 | let item = NSCollectionLayoutItem(layoutSize: size) 85 | let group = NSCollectionLayoutGroup.horizontal(layoutSize: size, subitem: item, count: 1) 86 | 87 | let section = NSCollectionLayoutSection(group: group) 88 | section.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5) 89 | section.interGroupSpacing = 5 90 | 91 | return UICollectionViewCompositionalLayout(section: section) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /CombineDemo/List/ListViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListViewController.swift 3 | // CombineDemo 4 | // 5 | // Created by Michal Cichecki on 30/06/2019. 6 | // 7 | 8 | import UIKit 9 | import Combine 10 | 11 | final class ListViewController: UIViewController { 12 | private typealias DataSource = UICollectionViewDiffableDataSource 13 | private typealias Snapshot = NSDiffableDataSourceSnapshot 14 | 15 | private lazy var contentView = ListView() 16 | private let viewModel: ListViewModel 17 | private var bindings = Set() 18 | 19 | private var dataSource: DataSource! 20 | 21 | init(viewModel: ListViewModel = ListViewModel()) { 22 | self.viewModel = viewModel 23 | super.init(nibName: nil, bundle: nil) 24 | } 25 | 26 | required init?(coder: NSCoder) { 27 | fatalError("init(coder:) has not been implemented") 28 | } 29 | 30 | override func loadView() { 31 | view = contentView 32 | } 33 | 34 | override func viewDidLoad() { 35 | view.backgroundColor = .darkGray 36 | 37 | setUpTableView() 38 | configureDataSource() 39 | setUpBindings() 40 | } 41 | 42 | override func viewWillAppear(_ animated: Bool) { 43 | super.viewWillAppear(animated) 44 | viewModel.retrySearch() 45 | } 46 | 47 | private func setUpTableView() { 48 | contentView.collectionView.register( 49 | PlayerCollectionCell.self, 50 | forCellWithReuseIdentifier: PlayerCollectionCell.identifier) 51 | } 52 | 53 | private func setUpBindings() { 54 | func bindViewToViewModel() { 55 | contentView.searchTextField.textPublisher 56 | .debounce(for: 0.5, scheduler: RunLoop.main) 57 | .removeDuplicates() 58 | .sink { [weak viewModel] in 59 | viewModel?.search(query: $0) 60 | } 61 | .store(in: &bindings) 62 | } 63 | 64 | func bindViewModelToView() { 65 | viewModel.$players 66 | .receive(on: RunLoop.main) 67 | .sink(receiveValue: { [weak self] _ in 68 | self?.updateSections() 69 | }) 70 | .store(in: &bindings) 71 | 72 | let stateValueHandler: (ListViewModelState) -> Void = { [weak self] state in 73 | switch state { 74 | case .loading: 75 | self?.contentView.startLoading() 76 | case .finishedLoading: 77 | self?.contentView.finishLoading() 78 | case .error(let error): 79 | self?.contentView.finishLoading() 80 | self?.showError(error) 81 | } 82 | } 83 | 84 | viewModel.$state 85 | .receive(on: RunLoop.main) 86 | .sink(receiveValue: stateValueHandler) 87 | .store(in: &bindings) 88 | } 89 | 90 | bindViewToViewModel() 91 | bindViewModelToView() 92 | } 93 | 94 | private func showError(_ error: Error) { 95 | let alertController = UIAlertController(title: "Error", message: error.localizedDescription, preferredStyle: .alert) 96 | let alertAction = UIAlertAction(title: "OK", style: .default) { [unowned self] _ in 97 | self.dismiss(animated: true, completion: nil) 98 | } 99 | alertController.addAction(alertAction) 100 | present(alertController, animated: true, completion: nil) 101 | } 102 | 103 | private func updateSections() { 104 | var snapshot = Snapshot() 105 | snapshot.appendSections([.players]) 106 | snapshot.appendItems(viewModel.players) 107 | dataSource.apply(snapshot, animatingDifferences: true) 108 | } 109 | } 110 | 111 | // MARK: - UICollectionViewDataSource 112 | 113 | extension ListViewController { 114 | private func configureDataSource() { 115 | dataSource = DataSource( 116 | collectionView: contentView.collectionView, 117 | cellProvider: { (collectionView, indexPath, player) -> UICollectionViewCell? in 118 | let cell = collectionView.dequeueReusableCell( 119 | withReuseIdentifier: PlayerCollectionCell.identifier, 120 | for: indexPath) as? PlayerCollectionCell 121 | cell?.viewModel = PlayerCellViewModel(player: player) 122 | return cell 123 | }) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /CombineDemo/List/ListViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListViewModel.swift 3 | // CombineDemo 4 | // 5 | // Created by Michal Cichecki on 30/06/2019. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | enum ListViewModelError: Error, Equatable { 12 | case playersFetch 13 | } 14 | 15 | enum ListViewModelState: Equatable { 16 | case loading 17 | case finishedLoading 18 | case error(ListViewModelError) 19 | } 20 | 21 | final class ListViewModel { 22 | enum Section { case players } 23 | 24 | @Published private(set) var players: [Player] = [] 25 | @Published private(set) var state: ListViewModelState = .loading 26 | private var currentSearchQuery: String = "" 27 | 28 | private let playersService: PlayersServiceProtocol 29 | private var bindings = Set() 30 | 31 | init(playersService: PlayersServiceProtocol = PlayersService()) { 32 | self.playersService = playersService 33 | } 34 | 35 | func search(query: String) { 36 | currentSearchQuery = query 37 | fetchPlayers(with: query) 38 | } 39 | 40 | func retrySearch() { 41 | fetchPlayers(with: currentSearchQuery) 42 | } 43 | } 44 | 45 | extension ListViewModel { 46 | private func fetchPlayers(with searchTerm: String?) { 47 | state = .loading 48 | 49 | let searchTermCompletionHandler: (Subscribers.Completion) -> Void = { [weak self] completion in 50 | switch completion { 51 | case .failure: 52 | self?.state = .error(.playersFetch) 53 | case .finished: 54 | self?.state = .finishedLoading 55 | } 56 | } 57 | 58 | let searchTermValueHandler: ([Player]) -> Void = { [weak self] players in 59 | self?.players = players 60 | } 61 | 62 | playersService 63 | .get(searchTerm: searchTerm) 64 | .sink(receiveCompletion: searchTermCompletionHandler, receiveValue: searchTermValueHandler) 65 | .store(in: &bindings) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /CombineDemo/Login/LoginView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginView.swift 3 | // CombineDemo 4 | // 5 | // Created by Michal Cichecki on 07/06/2019. 6 | // 7 | 8 | import UIKit 9 | 10 | final class LoginView: UIView { 11 | 12 | lazy var loginTextField = UITextField() 13 | lazy var passwordTextField = UITextField() 14 | lazy var loginButton = UIButton() 15 | lazy var activityIndicator = ActivityIndicatorView(style: .medium) 16 | 17 | var isLoading: Bool = false { 18 | didSet { isLoading ? startLoading() : finishLoading() } 19 | } 20 | 21 | init() { 22 | super.init(frame: .zero) 23 | 24 | addSubviews() 25 | setUpConstraints() 26 | setUpViews() 27 | } 28 | 29 | required init?(coder: NSCoder) { 30 | fatalError("init(coder:) has not been implemented") 31 | } 32 | 33 | func startLoading() { 34 | isUserInteractionEnabled = false 35 | activityIndicator.isHidden = false 36 | activityIndicator.startAnimating() 37 | } 38 | 39 | func finishLoading() { 40 | isUserInteractionEnabled = true 41 | activityIndicator.stopAnimating() 42 | } 43 | 44 | private func addSubviews() { 45 | [loginTextField, passwordTextField, loginButton, activityIndicator] 46 | .forEach { 47 | addSubview($0) 48 | $0.translatesAutoresizingMaskIntoConstraints = false 49 | } 50 | } 51 | 52 | private func setUpConstraints() { 53 | NSLayoutConstraint.activate([ 54 | loginTextField.centerXAnchor.constraint(equalTo: self.centerXAnchor), 55 | loginTextField.centerYAnchor.constraint(equalTo: self.centerYAnchor, constant: -30.0), 56 | loginTextField.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 40.0), 57 | loginTextField.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -40.0), 58 | loginTextField.heightAnchor.constraint(equalToConstant: 30.0), 59 | 60 | passwordTextField.topAnchor.constraint(equalTo: loginTextField.bottomAnchor, constant: 10.0), 61 | passwordTextField.centerXAnchor.constraint(equalTo: loginTextField.centerXAnchor), 62 | passwordTextField.widthAnchor.constraint(equalTo: loginTextField.widthAnchor, multiplier: 1.0), 63 | passwordTextField.heightAnchor.constraint(equalTo: loginTextField.heightAnchor), 64 | 65 | loginButton.topAnchor.constraint(equalTo: passwordTextField.bottomAnchor, constant: 20.0), 66 | loginButton.centerXAnchor.constraint(equalTo: loginTextField.centerXAnchor), 67 | loginButton.widthAnchor.constraint(equalToConstant: 120.0), 68 | loginButton.heightAnchor.constraint(equalToConstant: 30.0), 69 | 70 | activityIndicator.centerXAnchor.constraint(equalTo: centerXAnchor), 71 | activityIndicator.centerYAnchor.constraint(equalTo: centerYAnchor), 72 | activityIndicator.heightAnchor.constraint(equalToConstant: 50), 73 | activityIndicator.widthAnchor.constraint(equalToConstant: 50.0) 74 | ]) 75 | } 76 | 77 | private func setUpViews() { 78 | backgroundColor = .background 79 | loginTextField.backgroundColor = .systemBackground 80 | loginTextField.placeholder = "login" 81 | 82 | passwordTextField.backgroundColor = .systemBackground 83 | passwordTextField.placeholder = "password" 84 | 85 | loginButton.setTitle("Log in", for: UIControl.State()) 86 | loginButton.setTitleColor(.white, for: UIControl.State()) 87 | loginButton.backgroundColor = .nonValid 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /CombineDemo/Login/LoginViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginViewController.swift 3 | // CombineDemo 4 | // 5 | // Created by Michal Cichecki on 07/06/2019. 6 | // 7 | 8 | import UIKit 9 | import Combine 10 | 11 | class LoginViewController: UIViewController { 12 | private lazy var contentView = LoginView() 13 | private let viewModel: LoginViewModel 14 | private var bindings = Set() 15 | 16 | init(viewModel: LoginViewModel = LoginViewModel()) { 17 | self.viewModel = viewModel 18 | super.init(nibName: nil, bundle: nil) 19 | } 20 | 21 | required init?(coder: NSCoder) { 22 | fatalError("init(coder:) has not been implemented") 23 | } 24 | 25 | override func loadView() { 26 | view = contentView 27 | } 28 | 29 | override func viewDidLoad() { 30 | super.viewDidLoad() 31 | 32 | view.backgroundColor = .background 33 | 34 | setUpTargets() 35 | setUpBindings() 36 | } 37 | 38 | private func setUpTargets() { 39 | contentView.loginButton.addTarget(self, action: #selector(onClick), for: .touchUpInside) 40 | } 41 | 42 | private func setUpBindings() { 43 | func bindViewToViewModel() { 44 | contentView.loginTextField.textPublisher 45 | .receive(on: DispatchQueue.main) 46 | .assign(to: \.login, on: viewModel) 47 | .store(in: &bindings) 48 | 49 | contentView.passwordTextField.textPublisher 50 | .receive(on: RunLoop.main) 51 | .assign(to: \.password, on: viewModel) 52 | .store(in: &bindings) 53 | } 54 | 55 | func bindViewModelToView() { 56 | viewModel.isInputValid 57 | .receive(on: RunLoop.main) 58 | .assign(to: \.isValid, on: contentView.loginButton) 59 | .store(in: &bindings) 60 | 61 | viewModel.$isLoading 62 | .assign(to: \.isLoading, on: contentView) 63 | .store(in: &bindings) 64 | 65 | viewModel.validationResult 66 | .sink { completion in 67 | switch completion { 68 | case .failure: 69 | // Error can be handled here (e.g. alert) 70 | return 71 | case .finished: 72 | return 73 | } 74 | } receiveValue: { [weak self] _ in 75 | self?.navigateToList() 76 | } 77 | .store(in: &bindings) 78 | 79 | } 80 | 81 | bindViewToViewModel() 82 | bindViewModelToView() 83 | } 84 | 85 | @objc private func onClick() { 86 | viewModel.validateCredentials() 87 | } 88 | 89 | private func navigateToList() { 90 | let listViewController = ListViewController() 91 | navigationController?.pushViewController(listViewController, animated: true) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /CombineDemo/Login/LoginViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewModel.swift 3 | // CombineDemo 4 | // 5 | // Created by Michal Cichecki on 07/06/2019. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | final class LoginViewModel { 12 | @Published var login: String = "" 13 | @Published var password: String = "" 14 | @Published var isLoading = false 15 | let validationResult = PassthroughSubject() 16 | 17 | private(set) lazy var isInputValid = Publishers.CombineLatest($login, $password) 18 | .map { $0.count > 2 && $1.count > 2 } 19 | .eraseToAnyPublisher() 20 | 21 | private let credentialsValidator: CredentialsValidatorProtocol 22 | 23 | init(credentialsValidator: CredentialsValidatorProtocol = CredentialsValidator()) { 24 | self.credentialsValidator = credentialsValidator 25 | } 26 | 27 | func validateCredentials() { 28 | isLoading = true 29 | 30 | credentialsValidator.validateCredentials(login: login, password: password) { [weak self] result in 31 | self?.isLoading = false 32 | switch result { 33 | case .success: 34 | self?.validationResult.send(()) 35 | case let .failure(error): 36 | self?.validationResult.send(completion: .failure(error)) 37 | } 38 | } 39 | } 40 | } 41 | 42 | // MARK: - CredentialsValidatorProtocol 43 | 44 | protocol CredentialsValidatorProtocol { 45 | func validateCredentials( 46 | login: String, 47 | password: String, 48 | completion: @escaping (Result<(), Error>) -> Void) 49 | } 50 | 51 | /// This class acts as an example of asynchronous credentials validation 52 | /// It's for demo purpose only. In the real world it would make an actual request or use other authentication method 53 | final class CredentialsValidator: CredentialsValidatorProtocol { 54 | func validateCredentials( 55 | login: String, 56 | password: String, 57 | completion: @escaping (Result<(), Error>) -> Void) { 58 | let time: DispatchTime = .now() + .milliseconds(Int.random(in: 200 ... 1_000)) 59 | DispatchQueue.main.asyncAfter(deadline: time) { 60 | // hardcoded success 61 | completion(.success(())) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /CombineDemo/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // CombineDemo 4 | // 5 | // Created by Michal Cichecki on 07/06/2019. 6 | // 7 | 8 | import UIKit 9 | 10 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 11 | var window: UIWindow? 12 | 13 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 14 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 15 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 16 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 17 | guard let windowScene = scene as? UIWindowScene else { return } 18 | 19 | let loginViewController = LoginViewController() 20 | 21 | let window = UIWindow(windowScene: windowScene) 22 | window.rootViewController = UINavigationController(rootViewController: loginViewController) 23 | self.window = window 24 | window.makeKeyAndVisible() 25 | } 26 | } 27 | 28 | -------------------------------------------------------------------------------- /CombineDemo/Shared/ActivityIndicatorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActivityIndicatorView.swift 3 | // CombineDemo 4 | // 5 | // Created by Michal Cichecki on 19/12/2020. 6 | // Copyright © 2020 codeuqest. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | final class ActivityIndicatorView: UIActivityIndicatorView { 13 | override init(style: UIActivityIndicatorView.Style) { 14 | super.init(style: style) 15 | 16 | setUp() 17 | } 18 | 19 | required init(coder: NSCoder) { 20 | fatalError("init(coder:) has not been implemented") 21 | } 22 | 23 | private func setUp() { 24 | color = .white 25 | backgroundColor = .darkGray 26 | layer.cornerRadius = 5.0 27 | hidesWhenStopped = true 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /CombineDemo/UIButton/UIButton+Validation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIButton+Validation.swift 3 | // CombineDemo 4 | // 5 | // Created by Michal Cichecki on 04/07/2019. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIButton { 11 | var isValid: Bool { 12 | get { isEnabled && backgroundColor == .valid } 13 | set { 14 | backgroundColor = newValue ? .valid : .nonValid 15 | isEnabled = newValue 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /CombineDemo/UIColor/UIColor+Colors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIColor+Colors.swift 3 | // CombineDemo 4 | // 5 | // Created by Michal Cichecki on 03/07/2019. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIColor { 11 | static let background = UIColor(named: "mainBackground") 12 | static let valid = UIColor(named: "valid") 13 | static let nonValid = UIColor(named: "nonValid") 14 | } 15 | -------------------------------------------------------------------------------- /CombineDemoTests/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 | -------------------------------------------------------------------------------- /CombineDemoTests/Mocks/MockCredentialsValidator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockCredentialsValidator.swift 3 | // CombineDemoTests 4 | // 5 | // Created by Michal Cichecki on 20/12/2020. 6 | // 7 | 8 | import Foundation 9 | @testable import CombineDemo 10 | 11 | final class MockCredentialsValidator: CredentialsValidatorProtocol { 12 | // MARK: - validateCredentials 13 | var validateCredentialsCalled = false 14 | var validateCredentialsClosure: ((@escaping (Result<(), Error>) -> Void) -> Void)? 15 | 16 | func validateCredentials( 17 | login: String, 18 | password: String, 19 | completion: @escaping (Result<(), Error>) -> Void 20 | ) { 21 | validateCredentialsCalled = true 22 | validateCredentialsClosure?(completion) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /CombineDemoTests/Mocks/MockPlayersService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockPlayersService.swift 3 | // CombineDemoTests 4 | // 5 | // Created by Michal Cichecki on 21/10/2021. 6 | // Copyright © 2021 codeuqest. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | @testable import CombineDemo 12 | 13 | final class MockPlayersService: PlayersServiceProtocol { 14 | var getArguments: [String?] = [] 15 | var getCallsCount: Int = 0 16 | 17 | var getResult: Result<[Player], Error> = .success([]) 18 | 19 | func get(searchTerm: String?) -> AnyPublisher<[Player], Error> { 20 | getArguments.append(searchTerm) 21 | getCallsCount += 1 22 | 23 | return getResult.publisher.eraseToAnyPublisher() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /CombineDemoTests/Tests/ListViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListViewModelTests.swift 3 | // CombineDemoTests 4 | // 5 | // Created by Michal Cichecki on 21/10/2021. 6 | // Copyright © 2021 codeuqest. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | import Combine 12 | @testable import CombineDemo 13 | 14 | final class ListViewModelTests: XCTestCase { 15 | private var subject: ListViewModel! 16 | private var mockPlayersService: MockPlayersService! 17 | private var cancellables: Set = [] 18 | 19 | override func setUp() { 20 | super.setUp() 21 | 22 | mockPlayersService = MockPlayersService() 23 | subject = ListViewModel(playersService: mockPlayersService) 24 | } 25 | 26 | override func tearDown() { 27 | cancellables.forEach { $0.cancel() } 28 | cancellables.removeAll() 29 | mockPlayersService = nil 30 | subject = nil 31 | 32 | super.tearDown() 33 | } 34 | 35 | func test_searchText_shouldCallService() { 36 | // when 37 | subject.search(query: "test") 38 | 39 | // then 40 | XCTAssertEqual(mockPlayersService.getCallsCount, 1) 41 | XCTAssertEqual(mockPlayersService.getArguments.first, "test") 42 | } 43 | 44 | func test_retrySearch_givenSearchWasPerformed_shouldUseCurrentQuery() { 45 | // given (setup to update search query) 46 | subject.search(query: "test") 47 | XCTAssertEqual(mockPlayersService.getCallsCount, 1) 48 | XCTAssertEqual(mockPlayersService.getArguments.first, "test") 49 | 50 | // when 51 | subject.retrySearch() 52 | 53 | // then 54 | XCTAssertEqual(mockPlayersService.getCallsCount, 2) 55 | XCTAssertEqual(mockPlayersService.getArguments.last, "test") 56 | } 57 | 58 | func test_searchText_givenServiceCallSucceeds_shouldUpdatePlayers() { 59 | // given 60 | mockPlayersService.getResult = .success(Constants.players) 61 | 62 | // when 63 | subject.search(query: "test") 64 | 65 | // then 66 | XCTAssertEqual(mockPlayersService.getCallsCount, 1) 67 | XCTAssertEqual(mockPlayersService.getArguments.last, "test") 68 | subject.$players 69 | .sink { XCTAssertEqual($0, Constants.players) } 70 | .store(in: &cancellables) 71 | 72 | subject.$state 73 | .sink { XCTAssertEqual($0, .finishedLoading) } 74 | .store(in: &cancellables) 75 | } 76 | 77 | func test_searchText_givenServiceCallFails_shouldUpdateStateWithError() { 78 | // given 79 | mockPlayersService.getResult = .failure(MockError.error) 80 | 81 | // when 82 | subject.search(query: "test") 83 | 84 | // then 85 | XCTAssertEqual(mockPlayersService.getCallsCount, 1) 86 | XCTAssertEqual(mockPlayersService.getArguments.last, "test") 87 | subject.$players 88 | .sink { XCTAssert($0.isEmpty) } 89 | .store(in: &cancellables) 90 | 91 | subject.$state 92 | .sink { XCTAssertEqual($0, .error(.playersFetch)) } 93 | .store(in: &cancellables) 94 | } 95 | } 96 | 97 | // MARK: - Helpers 98 | 99 | extension ListViewModelTests { 100 | enum Constants { 101 | static let players = [ 102 | Player(firstName: "Kobe", lastName: "Bryant", team: "LAL"), 103 | Player(firstName: "Dirk", lastName: "Nowitzki", team: "DAL") 104 | ] 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /CombineDemoTests/Tests/LoginViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CombineDemoTests.swift 3 | // CombineDemoTests 4 | // 5 | // Created by Michal Cichecki on 04/07/2019. 6 | // 7 | 8 | @testable import CombineDemo 9 | import XCTest 10 | import Combine 11 | 12 | class LoginViewModelTests: XCTestCase { 13 | private var subject: LoginViewModel! 14 | private var mockCredentialsValidator: MockCredentialsValidator! 15 | private var cancellables: Set = [] 16 | 17 | override func setUp() { 18 | super.setUp() 19 | 20 | mockCredentialsValidator = MockCredentialsValidator() 21 | subject = LoginViewModel(credentialsValidator: mockCredentialsValidator) 22 | } 23 | 24 | override func tearDown() { 25 | cancellables.forEach { $0.cancel() } 26 | mockCredentialsValidator = nil 27 | subject = nil 28 | 29 | super.tearDown() 30 | } 31 | 32 | func test_isInputValid_WhenBothStringsAreEmpty_ShouldProduceFalse() { 33 | // given 34 | subject.login = "" 35 | subject.password = "" 36 | 37 | // when 38 | subject.isInputValid.sink { 39 | // then 40 | XCTAssertFalse($0) 41 | } 42 | .store(in: &cancellables) 43 | } 44 | 45 | func test_isInputValid_WhenAtLeastOneStringIsEmpty_ShouldProduceFalse() { 46 | // given 47 | subject.login = "login" 48 | subject.password = "" 49 | 50 | // when 51 | subject.isInputValid.sink { 52 | // then 53 | XCTAssertFalse($0) 54 | } 55 | .store(in: &cancellables) 56 | } 57 | 58 | func test_isInputValid_WhenBothStringsAreLongerThanTwo_ShouldProduceTrue() { 59 | // given 60 | subject.login = "login" 61 | subject.password = "password" 62 | 63 | // when 64 | subject.isInputValid.sink { 65 | // then 66 | XCTAssertTrue($0) 67 | } 68 | .store(in: &cancellables) 69 | } 70 | 71 | func test_validateCredentials_GivenValidatorReturnsSuccess_ShouldProduceInput() { 72 | // given 73 | mockCredentialsValidator.validateCredentialsClosure = { completion in 74 | completion(.success(())) 75 | } 76 | 77 | subject.validationResult.sink( 78 | receiveCompletion: { _ in 79 | XCTFail("Expected to receive value") 80 | }, receiveValue: { _ in 81 | return 82 | }) 83 | .store(in: &cancellables) 84 | 85 | // when 86 | subject.validateCredentials() 87 | 88 | // then 89 | XCTAssertTrue(mockCredentialsValidator.validateCredentialsCalled) 90 | } 91 | 92 | func test_validateCredentials_GivenValidatorReturnsFailure_ShouldProduceError() { 93 | // given 94 | mockCredentialsValidator.validateCredentialsClosure = { completion in 95 | completion(.failure(MockError.error)) 96 | } 97 | 98 | subject.validationResult.sink( 99 | receiveCompletion: { completion in 100 | switch completion { 101 | case .finished: 102 | XCTFail("Expected to receive faillure") 103 | case .failure: 104 | return 105 | } 106 | }, receiveValue: { _ in 107 | XCTFail("Expected to receive error") 108 | }) 109 | .store(in: &cancellables) 110 | 111 | // when 112 | subject.validateCredentials() 113 | 114 | // then 115 | XCTAssertTrue(mockCredentialsValidator.validateCredentialsCalled) 116 | } 117 | } 118 | 119 | // MARK: - MockError 120 | 121 | enum MockError: Error { 122 | case error 123 | } 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Combine + UIKit + MVVM 2 | 3 | [![Unit tests status](https://github.com/mcichecki/Combine-MVVM/workflows/Unit%20Tests/badge.svg?branch=master)](https://github.com/mcichecki/Combine-MVVM/actions?query=workflow%3A%22Unit+Tests%22) 4 | 5 | ### Sample project with Combine, UIKit and MVVM architecture 6 | 7 | This simple app consists of two screens and includes basic concepts that are common usecases for using reactive programming. 8 | 9 | First screen showcases offline validation of text fields (login screen as an example). 10 | 11 | Second screen presents fetching data from public and [free API](https://rapidapi.com). 12 | 13 | ### Set up 14 | 15 | To run the app clone this repository and provide apiKey in [`PlayersService`](https://github.com/mcichecki/Combine-MVVM/blob/79fe946d372ae7dd3969ab4f5654a30b74271ed9/CombineDemo/API/Service/PlayersService.swift#L22) class. 16 | 17 | ### Github Actions 18 | 19 | Simple workflow responsible for running unit tests can be found in [workflow-test.yml](https://github.com/mcichecki/Combine-MVVM/blob/master/.github/workflows/workflow-test.yml) file. 20 | --------------------------------------------------------------------------------