├── CleanAsyncObservation.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcuserdata │ │ └── cesmejia.xcuserdatad │ │ └── IDEFindNavigatorScopes.plist ├── xcshareddata │ └── xcschemes │ │ └── CleanAsyncObservation.xcscheme └── xcuserdata │ └── cesmejia.xcuserdatad │ ├── xcdebugger │ └── Breakpoints_v2.xcbkptlist │ └── xcschemes │ └── xcschememanagement.plist ├── CleanAsyncObservation.xctestplan ├── CleanAsyncObservation ├── AppDelegate.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json ├── Base.lproj │ └── LaunchScreen.storyboard ├── Composition Root │ ├── Coordinator │ │ ├── AppCoordinator.swift │ │ ├── Coordinator.swift │ │ └── Home │ │ │ ├── CompletedTodosCoordinator.swift │ │ │ └── HomeCoordinator.swift │ └── Factories │ │ ├── AppFactory.swift │ │ ├── CompletedTodosFactory.swift │ │ └── HomeFactory.swift ├── Data │ ├── Local │ │ └── TodosLocalDataSource.swift │ ├── Remote │ │ └── TodosRemoteDataSource.swift │ └── Repository │ │ └── GetTodosRepository.swift ├── Domain │ ├── Entities │ │ └── Todo.swift │ └── Use Cases │ │ ├── Data Sources │ │ └── TodosDataSource.swift │ │ └── GetTodosUseCase.swift ├── Framework │ ├── Constants.swift │ ├── Local │ │ ├── TodosDatabase.swift │ │ ├── TodosDatabaseImp.swift │ │ ├── TodosLocalDataGateway.swift │ │ └── TodosMemoryDatabaseImp.swift │ ├── Remote │ │ ├── TodosRemoteDataGateway.swift │ │ ├── TodosService.swift │ │ └── TodosServiceImp.swift │ └── Views │ │ ├── AllTodos │ │ ├── TodoDetailsView.swift │ │ └── TodosView.swift │ │ ├── CompletedTodos │ │ └── CompletedTodosView.swift │ │ └── Extensions │ │ └── ViewDidLoadModifier.swift ├── Info.plist ├── Presentation │ ├── AllTodos │ │ ├── TodoDetailsViewModel.swift │ │ └── TodosViewModel.swift │ └── CompletedTodos │ │ └── CompletedTodosViewModel.swift └── SceneDelegate.swift ├── CleanAsyncObservationTests ├── Data │ └── Repository │ │ └── GetTodosRepositoryTests.swift ├── Domain │ └── GetTodosUseCaseTests.swift ├── Framework │ ├── Local │ │ └── TodosDatabaseImpTests.swift │ └── Remote │ │ ├── Helpers │ │ └── URLProtocolStub.swift │ │ └── TodosServiceTests.swift └── Presentation │ └── TodosViewModelTests.swift ├── CleanAsyncObservationUITests ├── CleanAsyncObservationUITests.swift └── CleanAsyncObservationUITestsLaunchTests.swift └── README.md /CleanAsyncObservation.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXContainerItemProxy section */ 10 | 9C948D8A2C6FD9A8001AA0D2 /* PBXContainerItemProxy */ = { 11 | isa = PBXContainerItemProxy; 12 | containerPortal = 9C948D6B2C6FD9A7001AA0D2 /* Project object */; 13 | proxyType = 1; 14 | remoteGlobalIDString = 9C948D722C6FD9A7001AA0D2; 15 | remoteInfo = CleanAsyncObservation; 16 | }; 17 | 9C948D942C6FD9A8001AA0D2 /* PBXContainerItemProxy */ = { 18 | isa = PBXContainerItemProxy; 19 | containerPortal = 9C948D6B2C6FD9A7001AA0D2 /* Project object */; 20 | proxyType = 1; 21 | remoteGlobalIDString = 9C948D722C6FD9A7001AA0D2; 22 | remoteInfo = CleanAsyncObservation; 23 | }; 24 | /* End PBXContainerItemProxy section */ 25 | 26 | /* Begin PBXFileReference section */ 27 | 9C6E03EC2C6FDB1A0028486E /* CleanAsyncObservation.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = CleanAsyncObservation.xctestplan; sourceTree = ""; }; 28 | 9C948D732C6FD9A7001AA0D2 /* CleanAsyncObservation.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CleanAsyncObservation.app; sourceTree = BUILT_PRODUCTS_DIR; }; 29 | 9C948D892C6FD9A8001AA0D2 /* CleanAsyncObservationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CleanAsyncObservationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 30 | 9C948D932C6FD9A8001AA0D2 /* CleanAsyncObservationUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CleanAsyncObservationUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 31 | /* End PBXFileReference section */ 32 | 33 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 34 | 9C948D9B2C6FD9A8001AA0D2 /* Exceptions for "CleanAsyncObservation" folder in "CleanAsyncObservation" target */ = { 35 | isa = PBXFileSystemSynchronizedBuildFileExceptionSet; 36 | membershipExceptions = ( 37 | "/Localized: LaunchScreen.storyboard", 38 | Info.plist, 39 | ); 40 | target = 9C948D722C6FD9A7001AA0D2 /* CleanAsyncObservation */; 41 | }; 42 | /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ 43 | 44 | /* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ 45 | 9C948D9F2C6FD9A8001AA0D2 /* Exceptions for "CleanAsyncObservation" folder in "Copy Bundle Resources" phase from "CleanAsyncObservation" target */ = { 46 | isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet; 47 | buildPhase = 9C948D712C6FD9A7001AA0D2 /* Resources */; 48 | membershipExceptions = ( 49 | Base.lproj/LaunchScreen.storyboard, 50 | ); 51 | }; 52 | /* End PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ 53 | 54 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 55 | 9C948D752C6FD9A7001AA0D2 /* CleanAsyncObservation */ = { 56 | isa = PBXFileSystemSynchronizedRootGroup; 57 | exceptions = ( 58 | 9C948D9B2C6FD9A8001AA0D2 /* Exceptions for "CleanAsyncObservation" folder in "CleanAsyncObservation" target */, 59 | 9C948D9F2C6FD9A8001AA0D2 /* Exceptions for "CleanAsyncObservation" folder in "Copy Bundle Resources" phase from "CleanAsyncObservation" target */, 60 | ); 61 | path = CleanAsyncObservation; 62 | sourceTree = ""; 63 | }; 64 | 9C948D8C2C6FD9A8001AA0D2 /* CleanAsyncObservationTests */ = { 65 | isa = PBXFileSystemSynchronizedRootGroup; 66 | path = CleanAsyncObservationTests; 67 | sourceTree = ""; 68 | }; 69 | 9C948D962C6FD9A8001AA0D2 /* CleanAsyncObservationUITests */ = { 70 | isa = PBXFileSystemSynchronizedRootGroup; 71 | path = CleanAsyncObservationUITests; 72 | sourceTree = ""; 73 | }; 74 | /* End PBXFileSystemSynchronizedRootGroup section */ 75 | 76 | /* Begin PBXFrameworksBuildPhase section */ 77 | 9C948D702C6FD9A7001AA0D2 /* Frameworks */ = { 78 | isa = PBXFrameworksBuildPhase; 79 | buildActionMask = 2147483647; 80 | files = ( 81 | ); 82 | runOnlyForDeploymentPostprocessing = 0; 83 | }; 84 | 9C948D862C6FD9A8001AA0D2 /* Frameworks */ = { 85 | isa = PBXFrameworksBuildPhase; 86 | buildActionMask = 2147483647; 87 | files = ( 88 | ); 89 | runOnlyForDeploymentPostprocessing = 0; 90 | }; 91 | 9C948D902C6FD9A8001AA0D2 /* Frameworks */ = { 92 | isa = PBXFrameworksBuildPhase; 93 | buildActionMask = 2147483647; 94 | files = ( 95 | ); 96 | runOnlyForDeploymentPostprocessing = 0; 97 | }; 98 | /* End PBXFrameworksBuildPhase section */ 99 | 100 | /* Begin PBXGroup section */ 101 | 9C948D6A2C6FD9A7001AA0D2 = { 102 | isa = PBXGroup; 103 | children = ( 104 | 9C6E03EC2C6FDB1A0028486E /* CleanAsyncObservation.xctestplan */, 105 | 9C948D752C6FD9A7001AA0D2 /* CleanAsyncObservation */, 106 | 9C948D8C2C6FD9A8001AA0D2 /* CleanAsyncObservationTests */, 107 | 9C948D962C6FD9A8001AA0D2 /* CleanAsyncObservationUITests */, 108 | 9C948D742C6FD9A7001AA0D2 /* Products */, 109 | ); 110 | sourceTree = ""; 111 | }; 112 | 9C948D742C6FD9A7001AA0D2 /* Products */ = { 113 | isa = PBXGroup; 114 | children = ( 115 | 9C948D732C6FD9A7001AA0D2 /* CleanAsyncObservation.app */, 116 | 9C948D892C6FD9A8001AA0D2 /* CleanAsyncObservationTests.xctest */, 117 | 9C948D932C6FD9A8001AA0D2 /* CleanAsyncObservationUITests.xctest */, 118 | ); 119 | name = Products; 120 | sourceTree = ""; 121 | }; 122 | /* End PBXGroup section */ 123 | 124 | /* Begin PBXNativeTarget section */ 125 | 9C948D722C6FD9A7001AA0D2 /* CleanAsyncObservation */ = { 126 | isa = PBXNativeTarget; 127 | buildConfigurationList = 9C948D9C2C6FD9A8001AA0D2 /* Build configuration list for PBXNativeTarget "CleanAsyncObservation" */; 128 | buildPhases = ( 129 | 9C948D6F2C6FD9A7001AA0D2 /* Sources */, 130 | 9C948D702C6FD9A7001AA0D2 /* Frameworks */, 131 | 9C948D712C6FD9A7001AA0D2 /* Resources */, 132 | ); 133 | buildRules = ( 134 | ); 135 | dependencies = ( 136 | ); 137 | fileSystemSynchronizedGroups = ( 138 | 9C948D752C6FD9A7001AA0D2 /* CleanAsyncObservation */, 139 | ); 140 | name = CleanAsyncObservation; 141 | packageProductDependencies = ( 142 | ); 143 | productName = CleanAsyncObservation; 144 | productReference = 9C948D732C6FD9A7001AA0D2 /* CleanAsyncObservation.app */; 145 | productType = "com.apple.product-type.application"; 146 | }; 147 | 9C948D882C6FD9A8001AA0D2 /* CleanAsyncObservationTests */ = { 148 | isa = PBXNativeTarget; 149 | buildConfigurationList = 9C948DA22C6FD9A8001AA0D2 /* Build configuration list for PBXNativeTarget "CleanAsyncObservationTests" */; 150 | buildPhases = ( 151 | 9C948D852C6FD9A8001AA0D2 /* Sources */, 152 | 9C948D862C6FD9A8001AA0D2 /* Frameworks */, 153 | 9C948D872C6FD9A8001AA0D2 /* Resources */, 154 | ); 155 | buildRules = ( 156 | ); 157 | dependencies = ( 158 | 9C948D8B2C6FD9A8001AA0D2 /* PBXTargetDependency */, 159 | ); 160 | fileSystemSynchronizedGroups = ( 161 | 9C948D8C2C6FD9A8001AA0D2 /* CleanAsyncObservationTests */, 162 | ); 163 | name = CleanAsyncObservationTests; 164 | packageProductDependencies = ( 165 | ); 166 | productName = CleanAsyncObservationTests; 167 | productReference = 9C948D892C6FD9A8001AA0D2 /* CleanAsyncObservationTests.xctest */; 168 | productType = "com.apple.product-type.bundle.unit-test"; 169 | }; 170 | 9C948D922C6FD9A8001AA0D2 /* CleanAsyncObservationUITests */ = { 171 | isa = PBXNativeTarget; 172 | buildConfigurationList = 9C948DA52C6FD9A8001AA0D2 /* Build configuration list for PBXNativeTarget "CleanAsyncObservationUITests" */; 173 | buildPhases = ( 174 | 9C948D8F2C6FD9A8001AA0D2 /* Sources */, 175 | 9C948D902C6FD9A8001AA0D2 /* Frameworks */, 176 | 9C948D912C6FD9A8001AA0D2 /* Resources */, 177 | ); 178 | buildRules = ( 179 | ); 180 | dependencies = ( 181 | 9C948D952C6FD9A8001AA0D2 /* PBXTargetDependency */, 182 | ); 183 | fileSystemSynchronizedGroups = ( 184 | 9C948D962C6FD9A8001AA0D2 /* CleanAsyncObservationUITests */, 185 | ); 186 | name = CleanAsyncObservationUITests; 187 | packageProductDependencies = ( 188 | ); 189 | productName = CleanAsyncObservationUITests; 190 | productReference = 9C948D932C6FD9A8001AA0D2 /* CleanAsyncObservationUITests.xctest */; 191 | productType = "com.apple.product-type.bundle.ui-testing"; 192 | }; 193 | /* End PBXNativeTarget section */ 194 | 195 | /* Begin PBXProject section */ 196 | 9C948D6B2C6FD9A7001AA0D2 /* Project object */ = { 197 | isa = PBXProject; 198 | attributes = { 199 | BuildIndependentTargetsInParallel = 1; 200 | LastSwiftUpdateCheck = 1610; 201 | LastUpgradeCheck = 1610; 202 | TargetAttributes = { 203 | 9C948D722C6FD9A7001AA0D2 = { 204 | CreatedOnToolsVersion = 16.1; 205 | }; 206 | 9C948D882C6FD9A8001AA0D2 = { 207 | CreatedOnToolsVersion = 16.1; 208 | TestTargetID = 9C948D722C6FD9A7001AA0D2; 209 | }; 210 | 9C948D922C6FD9A8001AA0D2 = { 211 | CreatedOnToolsVersion = 16.1; 212 | TestTargetID = 9C948D722C6FD9A7001AA0D2; 213 | }; 214 | }; 215 | }; 216 | buildConfigurationList = 9C948D6E2C6FD9A7001AA0D2 /* Build configuration list for PBXProject "CleanAsyncObservation" */; 217 | developmentRegion = en; 218 | hasScannedForEncodings = 0; 219 | knownRegions = ( 220 | en, 221 | Base, 222 | ); 223 | mainGroup = 9C948D6A2C6FD9A7001AA0D2; 224 | minimizedProjectReferenceProxies = 1; 225 | preferredProjectObjectVersion = 77; 226 | productRefGroup = 9C948D742C6FD9A7001AA0D2 /* Products */; 227 | projectDirPath = ""; 228 | projectRoot = ""; 229 | targets = ( 230 | 9C948D722C6FD9A7001AA0D2 /* CleanAsyncObservation */, 231 | 9C948D882C6FD9A8001AA0D2 /* CleanAsyncObservationTests */, 232 | 9C948D922C6FD9A8001AA0D2 /* CleanAsyncObservationUITests */, 233 | ); 234 | }; 235 | /* End PBXProject section */ 236 | 237 | /* Begin PBXResourcesBuildPhase section */ 238 | 9C948D712C6FD9A7001AA0D2 /* Resources */ = { 239 | isa = PBXResourcesBuildPhase; 240 | buildActionMask = 2147483647; 241 | files = ( 242 | ); 243 | runOnlyForDeploymentPostprocessing = 0; 244 | }; 245 | 9C948D872C6FD9A8001AA0D2 /* Resources */ = { 246 | isa = PBXResourcesBuildPhase; 247 | buildActionMask = 2147483647; 248 | files = ( 249 | ); 250 | runOnlyForDeploymentPostprocessing = 0; 251 | }; 252 | 9C948D912C6FD9A8001AA0D2 /* Resources */ = { 253 | isa = PBXResourcesBuildPhase; 254 | buildActionMask = 2147483647; 255 | files = ( 256 | ); 257 | runOnlyForDeploymentPostprocessing = 0; 258 | }; 259 | /* End PBXResourcesBuildPhase section */ 260 | 261 | /* Begin PBXSourcesBuildPhase section */ 262 | 9C948D6F2C6FD9A7001AA0D2 /* Sources */ = { 263 | isa = PBXSourcesBuildPhase; 264 | buildActionMask = 2147483647; 265 | files = ( 266 | ); 267 | runOnlyForDeploymentPostprocessing = 0; 268 | }; 269 | 9C948D852C6FD9A8001AA0D2 /* Sources */ = { 270 | isa = PBXSourcesBuildPhase; 271 | buildActionMask = 2147483647; 272 | files = ( 273 | ); 274 | runOnlyForDeploymentPostprocessing = 0; 275 | }; 276 | 9C948D8F2C6FD9A8001AA0D2 /* Sources */ = { 277 | isa = PBXSourcesBuildPhase; 278 | buildActionMask = 2147483647; 279 | files = ( 280 | ); 281 | runOnlyForDeploymentPostprocessing = 0; 282 | }; 283 | /* End PBXSourcesBuildPhase section */ 284 | 285 | /* Begin PBXTargetDependency section */ 286 | 9C948D8B2C6FD9A8001AA0D2 /* PBXTargetDependency */ = { 287 | isa = PBXTargetDependency; 288 | target = 9C948D722C6FD9A7001AA0D2 /* CleanAsyncObservation */; 289 | targetProxy = 9C948D8A2C6FD9A8001AA0D2 /* PBXContainerItemProxy */; 290 | }; 291 | 9C948D952C6FD9A8001AA0D2 /* PBXTargetDependency */ = { 292 | isa = PBXTargetDependency; 293 | target = 9C948D722C6FD9A7001AA0D2 /* CleanAsyncObservation */; 294 | targetProxy = 9C948D942C6FD9A8001AA0D2 /* PBXContainerItemProxy */; 295 | }; 296 | /* End PBXTargetDependency section */ 297 | 298 | /* Begin XCBuildConfiguration section */ 299 | 9C948D9D2C6FD9A8001AA0D2 /* Debug */ = { 300 | isa = XCBuildConfiguration; 301 | buildSettings = { 302 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 303 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 304 | CODE_SIGN_STYLE = Automatic; 305 | CURRENT_PROJECT_VERSION = 1; 306 | DEVELOPMENT_TEAM = FVH6V77N95; 307 | GENERATE_INFOPLIST_FILE = YES; 308 | INFOPLIST_FILE = CleanAsyncObservation/Info.plist; 309 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 310 | INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; 311 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 312 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 313 | LD_RUNPATH_SEARCH_PATHS = ( 314 | "$(inherited)", 315 | "@executable_path/Frameworks", 316 | ); 317 | MARKETING_VERSION = 1.0; 318 | PRODUCT_BUNDLE_IDENTIFIER = com.cesmejia.CleanAsyncObservation; 319 | PRODUCT_NAME = "$(TARGET_NAME)"; 320 | SWIFT_EMIT_LOC_STRINGS = YES; 321 | SWIFT_STRICT_CONCURRENCY = complete; 322 | SWIFT_VERSION = 6.0; 323 | TARGETED_DEVICE_FAMILY = "1,2"; 324 | }; 325 | name = Debug; 326 | }; 327 | 9C948D9E2C6FD9A8001AA0D2 /* Release */ = { 328 | isa = XCBuildConfiguration; 329 | buildSettings = { 330 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 331 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 332 | CODE_SIGN_STYLE = Automatic; 333 | CURRENT_PROJECT_VERSION = 1; 334 | DEVELOPMENT_TEAM = FVH6V77N95; 335 | GENERATE_INFOPLIST_FILE = YES; 336 | INFOPLIST_FILE = CleanAsyncObservation/Info.plist; 337 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 338 | INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; 339 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 340 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 341 | LD_RUNPATH_SEARCH_PATHS = ( 342 | "$(inherited)", 343 | "@executable_path/Frameworks", 344 | ); 345 | MARKETING_VERSION = 1.0; 346 | PRODUCT_BUNDLE_IDENTIFIER = com.cesmejia.CleanAsyncObservation; 347 | PRODUCT_NAME = "$(TARGET_NAME)"; 348 | SWIFT_EMIT_LOC_STRINGS = YES; 349 | SWIFT_STRICT_CONCURRENCY = complete; 350 | SWIFT_VERSION = 6.0; 351 | TARGETED_DEVICE_FAMILY = "1,2"; 352 | }; 353 | name = Release; 354 | }; 355 | 9C948DA02C6FD9A8001AA0D2 /* Debug */ = { 356 | isa = XCBuildConfiguration; 357 | buildSettings = { 358 | ALWAYS_SEARCH_USER_PATHS = NO; 359 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 360 | CLANG_ANALYZER_NONNULL = YES; 361 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 362 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 363 | CLANG_ENABLE_MODULES = YES; 364 | CLANG_ENABLE_OBJC_ARC = YES; 365 | CLANG_ENABLE_OBJC_WEAK = YES; 366 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 367 | CLANG_WARN_BOOL_CONVERSION = YES; 368 | CLANG_WARN_COMMA = YES; 369 | CLANG_WARN_CONSTANT_CONVERSION = YES; 370 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 371 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 372 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 373 | CLANG_WARN_EMPTY_BODY = YES; 374 | CLANG_WARN_ENUM_CONVERSION = YES; 375 | CLANG_WARN_INFINITE_RECURSION = YES; 376 | CLANG_WARN_INT_CONVERSION = YES; 377 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 378 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 379 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 380 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 381 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 382 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 383 | CLANG_WARN_STRICT_PROTOTYPES = YES; 384 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 385 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 386 | CLANG_WARN_UNREACHABLE_CODE = YES; 387 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 388 | COPY_PHASE_STRIP = NO; 389 | DEBUG_INFORMATION_FORMAT = dwarf; 390 | ENABLE_STRICT_OBJC_MSGSEND = YES; 391 | ENABLE_TESTABILITY = YES; 392 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 393 | GCC_C_LANGUAGE_STANDARD = gnu17; 394 | GCC_DYNAMIC_NO_PIC = NO; 395 | GCC_NO_COMMON_BLOCKS = YES; 396 | GCC_OPTIMIZATION_LEVEL = 0; 397 | GCC_PREPROCESSOR_DEFINITIONS = ( 398 | "DEBUG=1", 399 | "$(inherited)", 400 | ); 401 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 402 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 403 | GCC_WARN_UNDECLARED_SELECTOR = YES; 404 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 405 | GCC_WARN_UNUSED_FUNCTION = YES; 406 | GCC_WARN_UNUSED_VARIABLE = YES; 407 | IPHONEOS_DEPLOYMENT_TARGET = 18.1; 408 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 409 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 410 | MTL_FAST_MATH = YES; 411 | ONLY_ACTIVE_ARCH = YES; 412 | SDKROOT = iphoneos; 413 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 414 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 415 | SWIFT_STRICT_CONCURRENCY = complete; 416 | SWIFT_VERSION = 6.0; 417 | }; 418 | name = Debug; 419 | }; 420 | 9C948DA12C6FD9A8001AA0D2 /* Release */ = { 421 | isa = XCBuildConfiguration; 422 | buildSettings = { 423 | ALWAYS_SEARCH_USER_PATHS = NO; 424 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 425 | CLANG_ANALYZER_NONNULL = YES; 426 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 427 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 428 | CLANG_ENABLE_MODULES = YES; 429 | CLANG_ENABLE_OBJC_ARC = YES; 430 | CLANG_ENABLE_OBJC_WEAK = YES; 431 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 432 | CLANG_WARN_BOOL_CONVERSION = YES; 433 | CLANG_WARN_COMMA = YES; 434 | CLANG_WARN_CONSTANT_CONVERSION = YES; 435 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 436 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 437 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 438 | CLANG_WARN_EMPTY_BODY = YES; 439 | CLANG_WARN_ENUM_CONVERSION = YES; 440 | CLANG_WARN_INFINITE_RECURSION = YES; 441 | CLANG_WARN_INT_CONVERSION = YES; 442 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 443 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 444 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 445 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 446 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 447 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 448 | CLANG_WARN_STRICT_PROTOTYPES = YES; 449 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 450 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 451 | CLANG_WARN_UNREACHABLE_CODE = YES; 452 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 453 | COPY_PHASE_STRIP = NO; 454 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 455 | ENABLE_NS_ASSERTIONS = NO; 456 | ENABLE_STRICT_OBJC_MSGSEND = YES; 457 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 458 | GCC_C_LANGUAGE_STANDARD = gnu17; 459 | GCC_NO_COMMON_BLOCKS = YES; 460 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 461 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 462 | GCC_WARN_UNDECLARED_SELECTOR = YES; 463 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 464 | GCC_WARN_UNUSED_FUNCTION = YES; 465 | GCC_WARN_UNUSED_VARIABLE = YES; 466 | IPHONEOS_DEPLOYMENT_TARGET = 18.1; 467 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 468 | MTL_ENABLE_DEBUG_INFO = NO; 469 | MTL_FAST_MATH = YES; 470 | SDKROOT = iphoneos; 471 | SWIFT_COMPILATION_MODE = wholemodule; 472 | SWIFT_STRICT_CONCURRENCY = complete; 473 | SWIFT_VERSION = 6.0; 474 | VALIDATE_PRODUCT = YES; 475 | }; 476 | name = Release; 477 | }; 478 | 9C948DA32C6FD9A8001AA0D2 /* Debug */ = { 479 | isa = XCBuildConfiguration; 480 | buildSettings = { 481 | BUNDLE_LOADER = "$(TEST_HOST)"; 482 | CODE_SIGN_STYLE = Automatic; 483 | CURRENT_PROJECT_VERSION = 1; 484 | DEVELOPMENT_TEAM = FVH6V77N95; 485 | GENERATE_INFOPLIST_FILE = YES; 486 | IPHONEOS_DEPLOYMENT_TARGET = 18.1; 487 | MARKETING_VERSION = 1.0; 488 | PRODUCT_BUNDLE_IDENTIFIER = com.cesmejia.CleanAsyncObservationTests; 489 | PRODUCT_NAME = "$(TARGET_NAME)"; 490 | SWIFT_EMIT_LOC_STRINGS = NO; 491 | SWIFT_STRICT_CONCURRENCY = complete; 492 | SWIFT_VERSION = 6.0; 493 | TARGETED_DEVICE_FAMILY = "1,2"; 494 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CleanAsyncObservation.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/CleanAsyncObservation"; 495 | }; 496 | name = Debug; 497 | }; 498 | 9C948DA42C6FD9A8001AA0D2 /* Release */ = { 499 | isa = XCBuildConfiguration; 500 | buildSettings = { 501 | BUNDLE_LOADER = "$(TEST_HOST)"; 502 | CODE_SIGN_STYLE = Automatic; 503 | CURRENT_PROJECT_VERSION = 1; 504 | DEVELOPMENT_TEAM = FVH6V77N95; 505 | GENERATE_INFOPLIST_FILE = YES; 506 | IPHONEOS_DEPLOYMENT_TARGET = 18.1; 507 | MARKETING_VERSION = 1.0; 508 | PRODUCT_BUNDLE_IDENTIFIER = com.cesmejia.CleanAsyncObservationTests; 509 | PRODUCT_NAME = "$(TARGET_NAME)"; 510 | SWIFT_EMIT_LOC_STRINGS = NO; 511 | SWIFT_STRICT_CONCURRENCY = complete; 512 | SWIFT_VERSION = 6.0; 513 | TARGETED_DEVICE_FAMILY = "1,2"; 514 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CleanAsyncObservation.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/CleanAsyncObservation"; 515 | }; 516 | name = Release; 517 | }; 518 | 9C948DA62C6FD9A8001AA0D2 /* Debug */ = { 519 | isa = XCBuildConfiguration; 520 | buildSettings = { 521 | CODE_SIGN_STYLE = Automatic; 522 | CURRENT_PROJECT_VERSION = 1; 523 | DEVELOPMENT_TEAM = FVH6V77N95; 524 | GENERATE_INFOPLIST_FILE = YES; 525 | MARKETING_VERSION = 1.0; 526 | PRODUCT_BUNDLE_IDENTIFIER = com.cesmejia.CleanAsyncObservationUITests; 527 | PRODUCT_NAME = "$(TARGET_NAME)"; 528 | SWIFT_EMIT_LOC_STRINGS = NO; 529 | SWIFT_STRICT_CONCURRENCY = complete; 530 | SWIFT_VERSION = 6.0; 531 | TARGETED_DEVICE_FAMILY = "1,2"; 532 | TEST_TARGET_NAME = CleanAsyncObservation; 533 | }; 534 | name = Debug; 535 | }; 536 | 9C948DA72C6FD9A8001AA0D2 /* Release */ = { 537 | isa = XCBuildConfiguration; 538 | buildSettings = { 539 | CODE_SIGN_STYLE = Automatic; 540 | CURRENT_PROJECT_VERSION = 1; 541 | DEVELOPMENT_TEAM = FVH6V77N95; 542 | GENERATE_INFOPLIST_FILE = YES; 543 | MARKETING_VERSION = 1.0; 544 | PRODUCT_BUNDLE_IDENTIFIER = com.cesmejia.CleanAsyncObservationUITests; 545 | PRODUCT_NAME = "$(TARGET_NAME)"; 546 | SWIFT_EMIT_LOC_STRINGS = NO; 547 | SWIFT_STRICT_CONCURRENCY = complete; 548 | SWIFT_VERSION = 6.0; 549 | TARGETED_DEVICE_FAMILY = "1,2"; 550 | TEST_TARGET_NAME = CleanAsyncObservation; 551 | }; 552 | name = Release; 553 | }; 554 | /* End XCBuildConfiguration section */ 555 | 556 | /* Begin XCConfigurationList section */ 557 | 9C948D6E2C6FD9A7001AA0D2 /* Build configuration list for PBXProject "CleanAsyncObservation" */ = { 558 | isa = XCConfigurationList; 559 | buildConfigurations = ( 560 | 9C948DA02C6FD9A8001AA0D2 /* Debug */, 561 | 9C948DA12C6FD9A8001AA0D2 /* Release */, 562 | ); 563 | defaultConfigurationIsVisible = 0; 564 | defaultConfigurationName = Release; 565 | }; 566 | 9C948D9C2C6FD9A8001AA0D2 /* Build configuration list for PBXNativeTarget "CleanAsyncObservation" */ = { 567 | isa = XCConfigurationList; 568 | buildConfigurations = ( 569 | 9C948D9D2C6FD9A8001AA0D2 /* Debug */, 570 | 9C948D9E2C6FD9A8001AA0D2 /* Release */, 571 | ); 572 | defaultConfigurationIsVisible = 0; 573 | defaultConfigurationName = Release; 574 | }; 575 | 9C948DA22C6FD9A8001AA0D2 /* Build configuration list for PBXNativeTarget "CleanAsyncObservationTests" */ = { 576 | isa = XCConfigurationList; 577 | buildConfigurations = ( 578 | 9C948DA32C6FD9A8001AA0D2 /* Debug */, 579 | 9C948DA42C6FD9A8001AA0D2 /* Release */, 580 | ); 581 | defaultConfigurationIsVisible = 0; 582 | defaultConfigurationName = Release; 583 | }; 584 | 9C948DA52C6FD9A8001AA0D2 /* Build configuration list for PBXNativeTarget "CleanAsyncObservationUITests" */ = { 585 | isa = XCConfigurationList; 586 | buildConfigurations = ( 587 | 9C948DA62C6FD9A8001AA0D2 /* Debug */, 588 | 9C948DA72C6FD9A8001AA0D2 /* Release */, 589 | ); 590 | defaultConfigurationIsVisible = 0; 591 | defaultConfigurationName = Release; 592 | }; 593 | /* End XCConfigurationList section */ 594 | }; 595 | rootObject = 9C948D6B2C6FD9A7001AA0D2 /* Project object */; 596 | } 597 | -------------------------------------------------------------------------------- /CleanAsyncObservation.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /CleanAsyncObservation.xcodeproj/project.xcworkspace/xcuserdata/cesmejia.xcuserdatad/IDEFindNavigatorScopes.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /CleanAsyncObservation.xcodeproj/xcshareddata/xcschemes/CleanAsyncObservation.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 31 | 32 | 35 | 36 | 37 | 38 | 41 | 47 | 48 | 49 | 52 | 58 | 59 | 60 | 61 | 62 | 72 | 74 | 80 | 81 | 82 | 83 | 89 | 91 | 97 | 98 | 99 | 100 | 102 | 103 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /CleanAsyncObservation.xcodeproj/xcuserdata/cesmejia.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 9 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /CleanAsyncObservation.xcodeproj/xcuserdata/cesmejia.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | CleanAsyncObservation.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | SuppressBuildableAutocreation 14 | 15 | 9C948D722C6FD9A7001AA0D2 16 | 17 | primary 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /CleanAsyncObservation.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "AC7E41D4-9B10-45D5-952B-804C2233E418", 5 | "name" : "Test Scheme Action", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "targetForVariableExpansion" : { 13 | "containerPath" : "container:CleanAsyncObservation.xcodeproj", 14 | "identifier" : "9C948D722C6FD9A7001AA0D2", 15 | "name" : "CleanAsyncObservation" 16 | } 17 | }, 18 | "testTargets" : [ 19 | { 20 | "parallelizable" : false, 21 | "target" : { 22 | "containerPath" : "container:CleanAsyncObservation.xcodeproj", 23 | "identifier" : "9C948D882C6FD9A8001AA0D2", 24 | "name" : "CleanAsyncObservationTests" 25 | } 26 | }, 27 | { 28 | "target" : { 29 | "containerPath" : "container:CleanAsyncObservation.xcodeproj", 30 | "identifier" : "9C948D922C6FD9A8001AA0D2", 31 | "name" : "CleanAsyncObservationUITests" 32 | } 33 | } 34 | ], 35 | "version" : 1 36 | } 37 | -------------------------------------------------------------------------------- /CleanAsyncObservation/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // CleanAsyncObservation 4 | // 5 | // Created by Ces Mejia on 16/08/24. 6 | // 7 | 8 | import UIKit 9 | 10 | @main 11 | class AppDelegate: UIResponder, UIApplicationDelegate { 12 | 13 | 14 | 15 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 16 | // Override point for customization after application launch. 17 | return true 18 | } 19 | 20 | // MARK: UISceneSession Lifecycle 21 | 22 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 23 | // Called when a new scene session is being created. 24 | // Use this method to select a configuration to create the new scene with. 25 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 26 | } 27 | 28 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 29 | // Called when the user discards a scene session. 30 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 31 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 32 | } 33 | 34 | 35 | } 36 | 37 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | } 30 | ], 31 | "info" : { 32 | "author" : "xcode", 33 | "version" : 1 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /CleanAsyncObservation/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 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Composition Root/Coordinator/AppCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppCoordinator.swift 3 | // CleanRxSwift 4 | // 5 | // Created by Ces Mejia on 04/08/24. 6 | // 7 | 8 | import UIKit 9 | 10 | final class AppTabCoordinator: TabCoordinator { 11 | var tabNavigation: UITabBarController 12 | private let appFactory: AppFactory 13 | var childCoordinators = [Coordinator]() 14 | var childNavigationControllers = [UINavigationController]() 15 | var getTodosUseCase: GetTodosUseCase! 16 | 17 | init(navigation: UITabBarController, appFactory: AppFactory, window: UIWindow?) { 18 | self.tabNavigation = navigation 19 | self.appFactory = appFactory 20 | configWindow(window: window) 21 | } 22 | 23 | func start() { 24 | getTodosUseCase = appFactory.makeTodosUseCase() 25 | 26 | let homeNavigationController = UINavigationController() 27 | childNavigationControllers.append(homeNavigationController) 28 | 29 | let coordinator = appFactory.makeHomeCoordinator( 30 | navigation: homeNavigationController, 31 | getTodosUseCase: getTodosUseCase 32 | ) 33 | childCoordinators.append(coordinator) 34 | coordinator.start() 35 | 36 | let completedTodosNavigationController = UINavigationController() 37 | childNavigationControllers.append(completedTodosNavigationController) 38 | 39 | let completedTodosCoordinator = appFactory.makeCompletedTodosCoordinator( 40 | navigation: completedTodosNavigationController, 41 | getTodosUseCase: getTodosUseCase 42 | ) 43 | childCoordinators.append(completedTodosCoordinator) 44 | completedTodosCoordinator.start() 45 | 46 | tabNavigation.setViewControllers(childNavigationControllers, animated: false) 47 | } 48 | 49 | private func configWindow(window: UIWindow?) { 50 | window?.rootViewController = tabNavigation 51 | window?.makeKeyAndVisible() 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Composition Root/Coordinator/Coordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Coordinator.swift 3 | // CleanRxSwift 4 | // 5 | // Created by Ces Mejia on 04/08/24. 6 | // 7 | 8 | import UIKit 9 | 10 | @MainActor 11 | protocol Coordinator { 12 | var navigation: UINavigationController { get } 13 | func start() 14 | } 15 | 16 | @MainActor 17 | protocol TabCoordinator { 18 | var tabNavigation: UITabBarController { get } 19 | func start() 20 | } 21 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Composition Root/Coordinator/Home/CompletedTodosCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CompletedTodosCoordinator.swift 3 | // CleanAsyncObservation 4 | // 5 | // Created by Ces Mejia on 18/08/24. 6 | // 7 | 8 | import UIKit 9 | 10 | final class CompletedTodosCoordinator: Coordinator { 11 | let navigation: UINavigationController 12 | private let completedTodosFactory: CompletedTodosFactory 13 | private let getTodosUseCase: GetTodosUseCase 14 | 15 | init( 16 | navigation: UINavigationController, 17 | completedTodosFactory: CompletedTodosFactory, 18 | getTodosUseCase: GetTodosUseCase 19 | ) { 20 | self.navigation = navigation 21 | self.completedTodosFactory = completedTodosFactory 22 | self.getTodosUseCase = getTodosUseCase 23 | } 24 | 25 | func start() { 26 | let controller = completedTodosFactory.makeModule(getTodosUseCase: getTodosUseCase, delegate: self) 27 | navigation.navigationBar.prefersLargeTitles = true 28 | navigation.pushViewController(controller, animated: false) 29 | } 30 | } 31 | 32 | extension CompletedTodosCoordinator: CompletedTodosViewActions { 33 | func todoRowTapped(for todo: Todo) { 34 | // Add Navigation 35 | } 36 | } 37 | 38 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Composition Root/Coordinator/Home/HomeCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeCoordinator.swift 3 | // CleanRxSwift 4 | // 5 | // Created by Ces Mejia on 04/08/24. 6 | // 7 | 8 | import UIKit 9 | 10 | final class HomeCoordinator: Coordinator { 11 | let navigation: UINavigationController 12 | private let homeFactory: HomeFactory 13 | private let getTodosUseCase: GetTodosUseCase 14 | 15 | init( 16 | navigation: UINavigationController, 17 | homeFactory: HomeFactory, 18 | getTodosUseCase: GetTodosUseCase 19 | ) { 20 | self.navigation = navigation 21 | self.homeFactory = homeFactory 22 | self.getTodosUseCase = getTodosUseCase 23 | } 24 | 25 | func start() { 26 | let controller = homeFactory.makeModule(getTodosUseCase: getTodosUseCase, delegate: self) 27 | navigation.navigationBar.prefersLargeTitles = true 28 | navigation.pushViewController(controller, animated: false) 29 | } 30 | 31 | private func navigateToDetail(for todo: Todo) { 32 | let controller = homeFactory.makeTodoDetails(with: todo) 33 | navigation.pushViewController(controller, animated: true) 34 | } 35 | } 36 | 37 | extension HomeCoordinator: TodosViewActions { 38 | func todoRowTapped(for todo: Todo) { 39 | navigateToDetail(for: todo) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Composition Root/Factories/AppFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppFactory.swift 3 | // CleanRxSwift 4 | // 5 | // Created by Ces Mejia on 04/08/24. 6 | // 7 | 8 | import UIKit 9 | 10 | @MainActor 11 | protocol AppFactory { 12 | func makeHomeCoordinator( 13 | navigation: UINavigationController, 14 | getTodosUseCase: GetTodosUseCase 15 | ) -> Coordinator 16 | 17 | func makeCompletedTodosCoordinator( 18 | navigation: UINavigationController, 19 | getTodosUseCase: GetTodosUseCase 20 | ) -> Coordinator 21 | 22 | func makeTodosUseCase() -> GetTodosUseCase 23 | } 24 | 25 | struct AppFactoryImp: AppFactory { 26 | func makeHomeCoordinator( 27 | navigation: UINavigationController, 28 | getTodosUseCase: GetTodosUseCase 29 | ) -> Coordinator { 30 | let homeFactory = HomeFactoryImp() 31 | let homeCoordinator = HomeCoordinator( 32 | navigation: navigation, 33 | homeFactory: homeFactory, 34 | getTodosUseCase: getTodosUseCase 35 | ) 36 | return homeCoordinator 37 | } 38 | 39 | func makeCompletedTodosCoordinator( 40 | navigation: UINavigationController, 41 | getTodosUseCase: GetTodosUseCase 42 | ) -> Coordinator { 43 | let completedTodosFactory = CompletedTodosFactoryImp() 44 | let completedTodosCoordinator = CompletedTodosCoordinator( 45 | navigation: navigation, 46 | completedTodosFactory: completedTodosFactory, 47 | getTodosUseCase: getTodosUseCase 48 | ) 49 | return completedTodosCoordinator 50 | } 51 | } 52 | 53 | extension AppFactoryImp { 54 | func makeTodosUseCase() -> GetTodosUseCase { 55 | let todosService = TodosServiceImp() 56 | let todosDatabase = TodosDatabaseImp() 57 | let todosMemoryDatabase = TodosMemoryDatabaseImp() 58 | let getTodosUseCaseRemote = TodosRemoteDataGateway( 59 | todosService: todosService, 60 | todosDatabase: todosDatabase, 61 | todosMemoryDatabase: todosMemoryDatabase 62 | ) 63 | let getTodosUseCaseLocal = TodosLocalDataGateway( 64 | todosDatabase: todosDatabase, 65 | todosMemoryDatabase: todosMemoryDatabase 66 | ) 67 | let getTodosSource = GetTodosRepository(todosRemoteDataSource: getTodosUseCaseRemote, todosLocalDataSource: getTodosUseCaseLocal) 68 | let getTodosUseCase = GetTodosUseCase(todosDataSource: getTodosSource) 69 | return getTodosUseCase 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Composition Root/Factories/CompletedTodosFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CompletedTodosFactory.swift 3 | // CleanAsyncObservation 4 | // 5 | // Created by Ces Mejia on 18/08/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @MainActor 11 | protocol CompletedTodosFactory { 12 | func makeModule(getTodosUseCase: GetTodosUseCase, delegate: CompletedTodosViewActions?) -> UIViewController 13 | } 14 | 15 | struct CompletedTodosFactoryImp: CompletedTodosFactory { 16 | func makeModule(getTodosUseCase: GetTodosUseCase, delegate: CompletedTodosViewActions?) -> UIViewController { 17 | let view = makeCompletedTodosView(getTodosUseCase: getTodosUseCase, delegate: delegate) 18 | let viewController = UIHostingController(rootView: view) 19 | viewController.title = "Completed Todos" 20 | viewController.tabBarItem.image = UIImage(systemName: "checklist.checked") 21 | return viewController 22 | } 23 | 24 | func makeCompletedTodosView(getTodosUseCase: GetTodosUseCase, delegate: CompletedTodosViewActions?) -> CompletedTodosView { 25 | let completedTodosViewModel = CompletedTodosViewModel(getTodosUseCase: getTodosUseCase, delegate: delegate) 26 | let view = CompletedTodosView(viewModel: completedTodosViewModel) 27 | return view 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Composition Root/Factories/HomeFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeFactory.swift 3 | // CleanRxSwift 4 | // 5 | // Created by Ces Mejia on 04/08/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @MainActor 11 | protocol HomeFactory { 12 | func makeModule(getTodosUseCase: GetTodosUseCase, delegate: TodosViewActions?) -> UIViewController 13 | func makeTodoDetails(with todo: Todo) -> UIViewController 14 | } 15 | 16 | struct HomeFactoryImp: HomeFactory { 17 | func makeModule(getTodosUseCase: GetTodosUseCase, delegate: TodosViewActions?) -> UIViewController { 18 | let view = makeTodosView(getTodosUseCase: getTodosUseCase, delegate: delegate) 19 | let viewController = UIHostingController(rootView: view) 20 | viewController.title = "Todos" 21 | viewController.tabBarItem.image = UIImage(systemName: "checklist") 22 | return viewController 23 | } 24 | 25 | func makeTodosView(getTodosUseCase: GetTodosUseCase, delegate: TodosViewActions?) -> TodosView { 26 | let todosViewModel = TodosViewModel(getTodosUseCase: getTodosUseCase, delegate: delegate) 27 | let view = TodosView(viewModel: todosViewModel) 28 | return view 29 | } 30 | 31 | func makeTodoDetails(with todo: Todo) -> UIViewController { 32 | let view = makeTodoDetailsView(with: todo) 33 | let viewController = UIHostingController(rootView: view) 34 | return viewController 35 | } 36 | 37 | func makeTodoDetailsView(with todo: Todo) -> TodoDetailsView { 38 | let detailsViewModel = TodoDetailsViewModel(todo: todo) 39 | let view = TodoDetailsView(viewModel: detailsViewModel) 40 | return view 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Data/Local/TodosLocalDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodosLocalDataSource.swift 3 | // CleanAsyncObservation 4 | // 5 | // Created by Ces Mejia on 16/08/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol TodosLocalDataSource { 11 | func queryTodos() async throws(LocalDataSourceError) -> [Todo] 12 | } 13 | 14 | class TodosLocalDataSourceStub: TodosLocalDataSource { 15 | let result: Result<[Todo], LocalDataSourceError> 16 | 17 | init(result: Result<[Todo], LocalDataSourceError>) { 18 | self.result = result 19 | } 20 | 21 | func queryTodos() async throws(LocalDataSourceError) -> [Todo] { 22 | switch result { 23 | case .success(let todos): 24 | return todos 25 | case .failure(let error): 26 | throw error 27 | } 28 | } 29 | } 30 | 31 | enum LocalDataSourceError: Error { 32 | case unknown 33 | } 34 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Data/Remote/TodosRemoteDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodosRemoteDataSource.swift 3 | // CleanAsyncObservation 4 | // 5 | // Created by Ces Mejia on 03/08/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol TodosRemoteDataSource { 11 | func fetchTodos() async throws(RemoteDataSourceError) -> [Todo] 12 | } 13 | 14 | class TodosRemoteDataSourceStub: TodosRemoteDataSource { 15 | let result: Result<[Todo], RemoteDataSourceError> 16 | 17 | init(result: Result<[Todo], RemoteDataSourceError>) { 18 | self.result = result 19 | } 20 | 21 | func fetchTodos() async throws(RemoteDataSourceError) -> [Todo] { 22 | switch result { 23 | case .success(let todos): 24 | return todos 25 | case .failure(let error): 26 | throw error 27 | } 28 | } 29 | } 30 | 31 | enum RemoteDataSourceError: Error { 32 | case networkError 33 | case decodingError 34 | case unknownError 35 | case noData 36 | case serverError 37 | case unauthorized 38 | case forbidden 39 | case notFound 40 | case methodNotAllowed 41 | case notAcceptable 42 | case timeout 43 | case invalidResponse 44 | case not200Response 45 | } 46 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Data/Repository/GetTodosRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GetTodosRepository.swift 3 | // CleanRxSwift 4 | // 5 | // Created by Ces Mejia on 03/08/24. 6 | // 7 | 8 | import OSLog 9 | import Combine 10 | 11 | private let logger = Logger(subsystem: "Data Repository", category: "Todos Repository") 12 | 13 | class GetTodosRepository: TodosDataSource { 14 | let todosRemoteDataSource: TodosRemoteDataSource 15 | let todosLocalDataSource: TodosLocalDataSource 16 | 17 | var todos = CurrentValueSubject<[Todo], Never>([]) 18 | 19 | init(todosRemoteDataSource: TodosRemoteDataSource, todosLocalDataSource: TodosLocalDataSource) { 20 | self.todosRemoteDataSource = todosRemoteDataSource 21 | self.todosLocalDataSource = todosLocalDataSource 22 | } 23 | 24 | func getTodos() async throws { 25 | do { 26 | let localTodos = try await todosLocalDataSource.queryTodos() 27 | if localTodos.isEmpty { 28 | logger.info("No local todos found, refreshing...") 29 | do { 30 | try await refreshTodos() 31 | } catch { 32 | throw error 33 | } 34 | } else { 35 | todos.send(localTodos) 36 | } 37 | } catch { 38 | try await refreshTodos() 39 | } 40 | } 41 | 42 | func refreshTodos() async throws { 43 | let remoteTodos = try await todosRemoteDataSource.fetchTodos() 44 | todos.send(remoteTodos) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Domain/Entities/Todo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Todo.swift 3 | // CleanObservation 4 | // 5 | // Created by Ces Mejia on 09/08/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Todo: Codable, Equatable, Identifiable { 11 | let userId: Int 12 | let id: Int 13 | let title: String 14 | let completed: Bool 15 | } 16 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Domain/Use Cases/Data Sources/TodosDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodosDataSource.swift 3 | // CleanObservation 4 | // 5 | // Created by Ces Mejia on 14/08/24. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | protocol TodosDataSource { 12 | var todos: CurrentValueSubject<[Todo], Never> { get } 13 | func getTodos() async throws 14 | func refreshTodos() async throws 15 | } 16 | 17 | class TodosDataSourceStub: TodosDataSource { 18 | let todos = CurrentValueSubject<[Todo], Never>([]) 19 | 20 | private let result: Result<[Todo], Error> 21 | 22 | init(result: Result<[Todo], Error>) { 23 | self.result = result 24 | } 25 | 26 | func getTodos() async throws { 27 | try await manageResult() 28 | } 29 | 30 | func refreshTodos() async throws { 31 | try await manageResult() 32 | } 33 | 34 | private func manageResult() async throws { 35 | switch result { 36 | case .success(let todos): 37 | self.todos.send(todos) 38 | case .failure(let error): 39 | throw error 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Domain/Use Cases/GetTodosUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GetTodosUseCase.swift 3 | // CleanObservation 4 | // 5 | // Created by Ces Mejia on 14/08/24. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | @preconcurrency 12 | class GetTodosUseCase { 13 | private let todosDataSource: TodosDataSource 14 | let todos = CurrentValueSubject<[Todo], Never>([]) 15 | 16 | private var cancellables = Set() 17 | 18 | init(todosDataSource: TodosDataSource) { 19 | self.todosDataSource = todosDataSource 20 | 21 | todosDataSource.todos 22 | .dropFirst() 23 | .sink(receiveValue: { [unowned self] todos in 24 | self.todos.send(todos) 25 | }).store(in: &cancellables) 26 | } 27 | 28 | func getTodos() async throws { 29 | try await todosDataSource.getTodos() 30 | } 31 | 32 | func refreshTodos() async throws { 33 | try await todosDataSource.refreshTodos() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Framework/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // CleanRxSwift 4 | // 5 | // Created by Ces Mejia on 04/08/24. 6 | // 7 | 8 | import Foundation 9 | struct Constants { 10 | struct TODO { 11 | static let baseURL = "https://jsonplaceholder.typicode.com" 12 | static let todosEndpoint = "/todos" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Framework/Local/TodosDatabase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodosDatabase.swift 3 | // CleanAsyncObservation 4 | // 5 | // Created by Ces Mejia on 16/08/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol TodosDatabase { 11 | func queryTodos() async throws(LocalDataSourceError) -> [Todo] 12 | func updateTodos(with todos: [Todo]) async throws(LocalDataSourceError) 13 | } 14 | 15 | class TodosDatabaseStub: TodosDatabase { 16 | let getTodosResult: Result<[Todo], LocalDataSourceError> 17 | let updateTodosResult: Result 18 | 19 | init( 20 | getTodosResult: Result<[Todo], LocalDataSourceError>, 21 | updateTodosResult: Result 22 | ) { 23 | self.getTodosResult = getTodosResult 24 | self.updateTodosResult = updateTodosResult 25 | } 26 | 27 | func queryTodos() async throws(LocalDataSourceError) -> [Todo] { 28 | switch getTodosResult { 29 | case .success(let todos): 30 | return todos 31 | case .failure(let error): 32 | throw error 33 | } 34 | } 35 | 36 | func updateTodos(with todos: [Todo]) async throws(LocalDataSourceError) { 37 | switch updateTodosResult { 38 | case .success: 39 | break 40 | case .failure(let error): 41 | throw error 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Framework/Local/TodosDatabaseImp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodosDatabaseImp.swift 3 | // CleanAsyncObservation 4 | // 5 | // Created by Ces Mejia on 16/08/24. 6 | // 7 | 8 | import Foundation 9 | import OSLog 10 | 11 | private let logger = Logger(subsystem: "Framework Local", category: "Todos Database") 12 | 13 | class TodosDatabaseImp: TodosDatabase { 14 | var directoryURL: URL? 15 | 16 | init(directoryURL: URL? = nil) { 17 | self.directoryURL = directoryURL 18 | } 19 | 20 | func queryTodos() async throws(LocalDataSourceError) -> [Todo] { 21 | do { 22 | let todos = try await loadTodos() 23 | logger.info("Succesfully queried TODOs from FileManager database: \(todos.count)") 24 | return todos 25 | } catch { 26 | logger.error("Error querying FileManager TODOs: \(error)") 27 | throw .unknown 28 | } 29 | } 30 | 31 | func updateTodos(with todos: [Todo]) async throws(LocalDataSourceError) { 32 | do { 33 | try await save(todos: todos) 34 | logger.info("Succesfully updated FileManager TODOs: \(todos.count)") 35 | } catch { 36 | logger.error("Error updating FileManager TODOs: \(error)") 37 | throw .unknown 38 | } 39 | } 40 | 41 | // MARK: - FileManager 42 | 43 | private func fileURL() throws -> URL { 44 | try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) 45 | .appendingPathComponent("todos.data") 46 | } 47 | 48 | private func loadTodos() async throws -> [Todo] { 49 | let fileURL = try directoryURL ?? fileURL() 50 | guard let data = try? Data(contentsOf: fileURL) else { return [] } 51 | let todos = try JSONDecoder().decode([Todo].self, from: data) 52 | return todos 53 | } 54 | 55 | private func save(todos: [Todo]) async throws { 56 | let data = try JSONEncoder().encode(todos) 57 | let outfile = try directoryURL ?? fileURL() 58 | try data.write(to: outfile) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Framework/Local/TodosLocalDataGateway.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodosLocalDataGateway.swift 3 | // CleanAsyncObservation 4 | // 5 | // Created by Ces Mejia on 16/08/24. 6 | // 7 | 8 | import Foundation 9 | 10 | class TodosLocalDataGateway: TodosLocalDataSource { 11 | let todosDatabase: TodosDatabase 12 | let todosMemoryDatabase: TodosDatabase 13 | 14 | init( 15 | todosDatabase: TodosDatabase, 16 | todosMemoryDatabase: TodosDatabase 17 | ) { 18 | self.todosDatabase = todosDatabase 19 | self.todosMemoryDatabase = todosMemoryDatabase 20 | } 21 | 22 | func queryTodos() async throws(LocalDataSourceError) -> [Todo] { 23 | let memoryTodos = try await todosMemoryDatabase.queryTodos() 24 | 25 | if memoryTodos.isEmpty { 26 | let databaseTodos = try await todosDatabase.queryTodos() 27 | try? await todosMemoryDatabase.updateTodos(with: databaseTodos) 28 | return databaseTodos 29 | } 30 | 31 | return memoryTodos 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Framework/Local/TodosMemoryDatabaseImp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodosMemoryImp.swift 3 | // CleanAsyncObservation 4 | // 5 | // Created by Ces Mejia on 16/08/24. 6 | // 7 | 8 | import Foundation 9 | import OSLog 10 | 11 | private let logger = Logger(subsystem: "Framework Local", category: "Todos Memory Database") 12 | 13 | class TodosMemoryDatabaseImp: TodosDatabase { 14 | var todos: [Todo] = [] 15 | 16 | func queryTodos() async throws(LocalDataSourceError) -> [Todo] { 17 | logger.info("Succesfully queried TODOs from memory database: \(self.todos.count)") 18 | return todos 19 | } 20 | 21 | func updateTodos(with todos: [Todo]) async throws(LocalDataSourceError) { 22 | logger.info("Succesfully updated memory TODOs: \(todos.count)") 23 | self.todos = todos 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Framework/Remote/TodosRemoteDataGateway.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodosRemoteDataGateway.swift 3 | // CleanAsyncObservation 4 | // 5 | // Created by Ces Mejia on 16/08/24. 6 | // 7 | 8 | import Foundation 9 | 10 | class TodosRemoteDataGateway: TodosRemoteDataSource { 11 | let todosService: TodosService 12 | let todosDatabase: TodosDatabase 13 | let todosMemoryDatabase: TodosDatabase 14 | 15 | init( 16 | todosService: TodosService, 17 | todosDatabase: TodosDatabase, 18 | todosMemoryDatabase: TodosDatabase 19 | ) { 20 | self.todosService = todosService 21 | self.todosDatabase = todosDatabase 22 | self.todosMemoryDatabase = todosMemoryDatabase 23 | } 24 | 25 | func fetchTodos() async throws(RemoteDataSourceError) -> [Todo] { 26 | let todos = try await todosService.fetchTodos() 27 | try? await todosDatabase.updateTodos(with: todos) 28 | try? await todosMemoryDatabase.updateTodos(with: todos) 29 | return todos 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Framework/Remote/TodosService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodosService.swift 3 | // CleanAsyncObservation 4 | // 5 | // Created by Ces Mejia on 16/08/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol TodosService { 11 | func fetchTodos() async throws(RemoteDataSourceError) -> [Todo] 12 | } 13 | 14 | class TodosServiceStub: TodosService { 15 | let result: Result<[Todo], RemoteDataSourceError> 16 | 17 | init(result: Result<[Todo], RemoteDataSourceError>) { 18 | self.result = result 19 | } 20 | 21 | func fetchTodos() async throws(RemoteDataSourceError) -> [Todo] { 22 | switch result { 23 | case .success(let todos): 24 | return todos 25 | case .failure(let error): 26 | throw error 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Framework/Remote/TodosServiceImp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodosServiceImp.swift 3 | // CleanAsyncObservation 4 | // 5 | // Created by Ces Mejia on 16/08/24. 6 | // 7 | 8 | import Foundation 9 | import OSLog 10 | 11 | private let logger = Logger(subsystem: "Framework Remote", category: "Todos Service") 12 | 13 | class TodosServiceImp: TodosService { 14 | private let urlSession: URLSession 15 | 16 | init(urlSession: URLSession = .shared) { 17 | self.urlSession = urlSession 18 | } 19 | 20 | func fetchTodos() async throws(RemoteDataSourceError) -> [Todo] { 21 | logger.info("Fetching TODOs") 22 | let (data, urlResponse) = try! await urlSession.data(from: URL(string: Constants.TODO.baseURL + Constants.TODO.todosEndpoint)!) 23 | 24 | guard let urlResponse = urlResponse as? HTTPURLResponse else { throw .invalidResponse } 25 | guard urlResponse.statusCode == 200 else { throw .not200Response } 26 | 27 | do { 28 | let todos = try JSONDecoder().decode([Todo].self, from: data) 29 | logger.info("Succesfully fetched TODOs: \(todos.count)") 30 | return todos 31 | } catch { 32 | logger.error("Error Fetching TODOs: \(error.localizedDescription)") 33 | throw .decodingError 34 | } 35 | } 36 | } 37 | 38 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Framework/Views/AllTodos/TodoDetailsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodoDetailsView.swift 3 | // CleanAsyncObservation 4 | // 5 | // Created by Ces Mejia on 16/08/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TodoDetailsView: View { 11 | let viewModel: TodoDetailsViewModel 12 | 13 | var body: some View { 14 | List { 15 | Section("Title") { 16 | Text(viewModel.todo.title) 17 | } 18 | 19 | Section("User Id") { 20 | Text(viewModel.todo.userId.description) 21 | } 22 | 23 | Section("Id") { 24 | Text(viewModel.todo.id.description) 25 | } 26 | 27 | Section("Completed") { 28 | Text(viewModel.todo.completed ? "Completed" : "Not Completed") 29 | } 30 | } 31 | .navigationTitle(viewModel.title) 32 | } 33 | } 34 | 35 | #Preview { 36 | let factory = HomeFactoryImp() 37 | let todo = Todo(userId: 1, id: 1, title: "Hello", completed: false) 38 | let view = factory.makeTodoDetailsView(with: todo) 39 | return NavigationView { 40 | view 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Framework/Views/AllTodos/TodosView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodosView.swift 3 | // CleanObservation 4 | // 5 | // Created by Ces Mejia on 14/08/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TodosView: View { 11 | let viewModel: TodosViewModel 12 | 13 | var body: some View { 14 | List { 15 | ForEach(viewModel.todos) { todo in 16 | Button { 17 | viewModel.todoRowTapped(for: todo) 18 | } label: { 19 | HStack { 20 | Text(todo.title) 21 | Spacer() 22 | Text(todo.completed ? "✅" : "❌") 23 | } 24 | } 25 | .buttonStyle(.plain) 26 | } 27 | } 28 | .navigationTitle(viewModel.title) 29 | .onViewDidLoadTask { 30 | await viewModel.getTodos() 31 | } 32 | .toolbar { 33 | ToolbarItem { 34 | Button(viewModel.toolbarButtonText) { 35 | Task { 36 | await viewModel.refreshTodos() 37 | } 38 | } 39 | } 40 | } 41 | } 42 | } 43 | 44 | #Preview { 45 | let factory = HomeFactoryImp() 46 | let todo = Todo(userId: 1, id: 1, title: "Hello", completed: false) 47 | let todosDataSource = TodosDataSourceStub(result: .success([todo])) 48 | let getTodosUseCase = GetTodosUseCase(todosDataSource: todosDataSource) 49 | let view = factory.makeTodosView(getTodosUseCase: getTodosUseCase, delegate: nil) 50 | return NavigationView { 51 | view 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Framework/Views/CompletedTodos/CompletedTodosView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CompletedTodosView.swift 3 | // CleanAsyncObservation 4 | // 5 | // Created by Ces Mejia on 16/08/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CompletedTodosView: View { 11 | let viewModel: CompletedTodosViewModel 12 | 13 | var body: some View { 14 | List { 15 | ForEach(viewModel.todos) { todo in 16 | Button { 17 | viewModel.todoRowTapped(for: todo) 18 | } label: { 19 | HStack { 20 | Text(todo.title) 21 | Spacer() 22 | Text(todo.completed ? "✅" : "❌") 23 | } 24 | } 25 | .buttonStyle(.plain) 26 | 27 | } 28 | } 29 | .navigationTitle(viewModel.title) 30 | .onViewDidLoadTask { 31 | await viewModel.getTodos() 32 | } 33 | } 34 | } 35 | 36 | #Preview { 37 | let factory = CompletedTodosFactoryImp() 38 | let todo1 = Todo(userId: 1, id: 1, title: "Hello", completed: false) 39 | let todo2 = Todo(userId: 2, id: 2, title: "Hello", completed: true) 40 | let todosDataSource = TodosDataSourceStub(result: .success([todo1, todo2])) 41 | let getTodosUseCase = GetTodosUseCase(todosDataSource: todosDataSource) 42 | let view = factory.makeCompletedTodosView(getTodosUseCase: getTodosUseCase, delegate: nil) 43 | return NavigationView { 44 | view 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Framework/Views/Extensions/ViewDidLoadModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewDidLoadModifier.swift 3 | // CleanAsyncObservation 4 | // 5 | // Created by Ces Mejia on 16/08/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ViewDidLoadModifier: ViewModifier { 11 | @State private var viewDidLoad = false 12 | let action: (() async -> Void)? 13 | 14 | func body(content: Content) -> some View { 15 | content 16 | .task { 17 | if viewDidLoad == false { 18 | viewDidLoad = true 19 | await action?() 20 | } 21 | } 22 | } 23 | } 24 | 25 | extension View { 26 | func onViewDidLoadTask(perform action: (() async -> Void)? = nil) -> some View { 27 | self.modifier(ViewDidLoadModifier(action: action)) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIApplicationSceneManifest 6 | 7 | UIApplicationSupportsMultipleScenes 8 | 9 | UISceneConfigurations 10 | 11 | UIWindowSceneSessionRoleApplication 12 | 13 | 14 | UISceneConfigurationName 15 | Default Configuration 16 | UISceneDelegateClassName 17 | $(PRODUCT_MODULE_NAME).SceneDelegate 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Presentation/AllTodos/TodoDetailsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodoDetailsViewModel.swift 3 | // CleanAsyncObservation 4 | // 5 | // Created by Ces Mejia on 16/08/24. 6 | // 7 | 8 | import Foundation 9 | 10 | @MainActor 11 | @Observable class TodoDetailsViewModel { 12 | let title = "Details" 13 | var todo: Todo 14 | 15 | init( 16 | todo: Todo 17 | ) { 18 | self.todo = todo 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Presentation/AllTodos/TodosViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodosViewModel.swift 3 | // CleanObservation 4 | // 5 | // Created by Ces Mejia on 14/08/24. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | @MainActor 12 | @Observable class TodosViewModel { 13 | private let getTodosUseCase: GetTodosUseCase 14 | weak var delegate: TodosViewActions? 15 | 16 | let title = "Todos" 17 | let toolbarButtonText = "Refresh" 18 | var todos = [Todo]() 19 | var errorText: String? 20 | 21 | private var cancellables = Set() 22 | 23 | init( 24 | getTodosUseCase: GetTodosUseCase, 25 | delegate: TodosViewActions? 26 | ) { 27 | self.getTodosUseCase = getTodosUseCase 28 | self.delegate = delegate 29 | 30 | getTodosUseCase.todos 31 | .dropFirst() 32 | .receive(on: DispatchQueue.main) 33 | .sink(receiveValue: { [unowned self] todos in 34 | self.todos = todos 35 | }).store(in: &cancellables) 36 | } 37 | 38 | func getTodos() async { 39 | do { 40 | try await getTodosUseCase.getTodos() 41 | } catch { 42 | errorText = error.localizedDescription 43 | } 44 | } 45 | 46 | func refreshTodos() async { 47 | do { 48 | try await getTodosUseCase.refreshTodos() 49 | } catch { 50 | errorText = error.localizedDescription 51 | } 52 | } 53 | 54 | func todoRowTapped(for todo: Todo) { 55 | delegate?.todoRowTapped(for: todo) 56 | } 57 | } 58 | 59 | @MainActor 60 | protocol TodosViewActions: AnyObject { 61 | func todoRowTapped(for todo: Todo) 62 | } 63 | -------------------------------------------------------------------------------- /CleanAsyncObservation/Presentation/CompletedTodos/CompletedTodosViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CompletedTodosViewModel.swift 3 | // CleanAsyncObservation 4 | // 5 | // Created by Ces Mejia on 16/08/24. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | @MainActor 12 | @Observable class CompletedTodosViewModel { 13 | private let getTodosUseCase: GetTodosUseCase 14 | weak var delegate: CompletedTodosViewActions? 15 | 16 | let title = "Completed" 17 | var todos = [Todo]() 18 | var errorText: String? 19 | 20 | private var cancellables = Set() 21 | 22 | init( 23 | getTodosUseCase: GetTodosUseCase, 24 | delegate: CompletedTodosViewActions? 25 | ) { 26 | self.getTodosUseCase = getTodosUseCase 27 | self.delegate = delegate 28 | 29 | getTodosUseCase.todos 30 | .dropFirst() 31 | .receive(on: DispatchQueue.main) 32 | .sink(receiveValue: { [unowned self] todos in 33 | self.todos = todos.filter(\.completed) 34 | }).store(in: &cancellables) 35 | } 36 | 37 | func getTodos() async { 38 | do { 39 | try await getTodosUseCase.getTodos() 40 | } catch { 41 | errorText = error.localizedDescription 42 | } 43 | } 44 | 45 | func refreshTodos() async { 46 | do { 47 | try await getTodosUseCase.refreshTodos() 48 | } catch { 49 | errorText = error.localizedDescription 50 | } 51 | } 52 | 53 | func todoRowTapped(for todo: Todo) { 54 | delegate?.todoRowTapped(for: todo) 55 | } 56 | } 57 | 58 | @MainActor 59 | protocol CompletedTodosViewActions: AnyObject { 60 | func todoRowTapped(for todo: Todo) 61 | } 62 | 63 | -------------------------------------------------------------------------------- /CleanAsyncObservation/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // CleanAsyncObservation 4 | // 5 | // Created by Ces Mejia on 16/08/24. 6 | // 7 | 8 | import UIKit 9 | 10 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 11 | 12 | var window: UIWindow? 13 | var appCoordinator: TabCoordinator! 14 | var appFactory: AppFactory! 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 | guard let scene = (scene as? UIWindowScene) else { return } 22 | 23 | let uiTabBarController = UITabBarController() 24 | 25 | appFactory = AppFactoryImp() 26 | window = UIWindow(windowScene: scene) 27 | appCoordinator = AppTabCoordinator(navigation: uiTabBarController, appFactory: appFactory, window: window) 28 | appCoordinator.start() 29 | 30 | window?.makeKeyAndVisible() 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 necessarily 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 | -------------------------------------------------------------------------------- /CleanAsyncObservationTests/Data/Repository/GetTodosRepositoryTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GetTodosRepositoryTests.swift 3 | // CleanAsyncObservationTests 4 | // 5 | // Created by Ces Mejia on 16/08/24. 6 | // 7 | 8 | import Testing 9 | @testable import CleanAsyncObservation 10 | 11 | struct GetTodosRepositoryTests { 12 | 13 | static let localTodo: Todo = .init(userId: 1, id: 1, title: "Todo 1", completed: false) 14 | static let remoteTodo: Todo = .init(userId: 2, id: 2, title: "Todo 2", completed: false) 15 | 16 | @Test func testGetTodosWithLocalSuccessfulResponse_todosValueIsUpdatedWithLocalTodos() async throws { 17 | let sut = makeSUT() 18 | try await sut.getTodos() 19 | #expect(sut.todos.value == [Self.localTodo]) 20 | } 21 | 22 | @Test func testGetTodosWithLocalEmptySuccessfulResponse_todosValueIsUpdatedWithRemoteTodos() async throws { 23 | let sut = makeSUT(localResult: .success([])) 24 | try await sut.getTodos() 25 | #expect(sut.todos.value == [Self.remoteTodo]) 26 | } 27 | 28 | @Test func testGetTodosWithLocalFailureResponse_todosValueIsUpdatedWithRemoteTodos() async throws { 29 | let sut = makeSUT(localResult: .failure(.unknown)) 30 | try await sut.getTodos() 31 | #expect(sut.todos.value == [Self.remoteTodo]) 32 | } 33 | 34 | @Test func testGetTodosWithLocalAndRemoteFailureResponse_throwsError() async { 35 | let networkError = RemoteDataSourceError.networkError 36 | let sut = makeSUT(localResult: .failure(.unknown), remoteResult: .failure(networkError)) 37 | do { 38 | try await sut.getTodos() 39 | #expect(Bool(false)) 40 | } catch { 41 | #expect(error as? RemoteDataSourceError == networkError) 42 | } 43 | } 44 | 45 | @Test func testRefreshTodosWithLocalSuccessfulResponse_todosValueIsUpdatedWithLocalTodos() async throws { 46 | let sut = makeSUT() 47 | try await sut.refreshTodos() 48 | #expect(sut.todos.value == [Self.remoteTodo]) 49 | } 50 | 51 | private func makeSUT( 52 | localResult: Result<[Todo], LocalDataSourceError> = .success([Self.localTodo]), 53 | remoteResult: Result<[Todo], RemoteDataSourceError> = .success([Self.remoteTodo]) 54 | ) -> GetTodosRepository { 55 | let localDataSource = TodosLocalDataSourceStub(result: localResult) 56 | let remoteDataSource = TodosRemoteDataSourceStub(result: remoteResult) 57 | return GetTodosRepository( 58 | todosRemoteDataSource: remoteDataSource, 59 | todosLocalDataSource: localDataSource 60 | ) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /CleanAsyncObservationTests/Domain/GetTodosUseCaseTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GetTodosUseCaseTests.swift 3 | // CleanObservationTests 4 | // 5 | // Created by Ces Mejia on 14/08/24. 6 | // 7 | 8 | import Testing 9 | import Foundation 10 | @testable import CleanAsyncObservation 11 | 12 | struct GetTodosUseCaseTests { 13 | 14 | static let todo = Todo(userId: 1, id: 1, title: "Hello", completed: false) 15 | 16 | @Test func whenGettingTodosIsSuccessful_todosAreUpdated() async throws { 17 | let sut = makeSUT(result: .success([Self.todo])) 18 | try await sut.getTodos() 19 | #expect(sut.todos.value == [Self.todo]) 20 | } 21 | 22 | @Test func whenGettingTodosIsNotSuccessful_getTodosThrowsAnError() async throws { 23 | let passedError = NSError(domain: "", code: 0) 24 | let sut = makeSUT(result: .failure(passedError)) 25 | do { 26 | try await sut.getTodos() 27 | #expect(Bool(false)) 28 | } catch { 29 | #expect(error as NSError == passedError) 30 | } 31 | } 32 | 33 | @Test func whenRefreshingTodosIsSuccessful_todosAreUpdated() async throws { 34 | let sut = makeSUT(result: .success([Self.todo])) 35 | try await sut.refreshTodos() 36 | #expect(sut.todos.value == [Self.todo]) 37 | } 38 | 39 | @Test func whenRefreshingTodosIsNotSuccessful_refreshTodosThrowsAnError() async { 40 | let passedError = NSError(domain: "", code: 0) 41 | let sut = makeSUT(result: .failure(passedError)) 42 | do { 43 | try await sut.refreshTodos() 44 | #expect(Bool(false)) 45 | } catch { 46 | #expect(error as NSError == passedError) 47 | } 48 | } 49 | 50 | private func makeSUT(result: Result<[Todo], Error>) -> GetTodosUseCase { 51 | let todosDataSource = TodosDataSourceStub(result: result) 52 | return GetTodosUseCase(todosDataSource: todosDataSource) 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /CleanAsyncObservationTests/Framework/Local/TodosDatabaseImpTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodosDatabaseImpTests.swift 3 | // CleanAsyncObservationTests 4 | // 5 | // Created by Ces Mejia on 16/08/24. 6 | // 7 | 8 | import Testing 9 | import Foundation 10 | @testable import CleanAsyncObservation 11 | 12 | final class TodosDatabaseImpTests { 13 | 14 | static let todo = Todo(userId: 1, id: 1, title: "title", completed: true) 15 | static let todo2 = Todo(userId: 2, id: 2, title: "title", completed: true) 16 | 17 | static let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) 18 | 19 | static let url = URL(string: Constants.TODO.baseURL + Constants.TODO.todosEndpoint)! 20 | 21 | deinit { 22 | try! FileManager.default.removeItem(at: Self.temporaryDirectoryURL) 23 | } 24 | 25 | @Test func whenRequestingQueryingTodosInitially_getsAnEmptyTodoList() async throws { 26 | let sut = makeSUT() 27 | let todos = try await sut.queryTodos() 28 | #expect(todos.isEmpty) 29 | } 30 | 31 | @Test func whenUpdatingTodos_todosAreSavedInADirectory() async throws { 32 | let todoList = [Self.todo, Self.todo2] 33 | let sut = makeSUT() 34 | _ = try await sut.updateTodos(with: todoList) 35 | let todos = try await sut.queryTodos() 36 | #expect(todos == todoList) 37 | } 38 | 39 | private func makeSUT() -> TodosDatabaseImp { 40 | return TodosDatabaseImp(directoryURL: Self.temporaryDirectoryURL) 41 | } 42 | 43 | } 44 | 45 | -------------------------------------------------------------------------------- /CleanAsyncObservationTests/Framework/Remote/Helpers/URLProtocolStub.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLProtocolStub.swift 3 | // CleanRxSwiftTests 4 | // 5 | // Created by Ces Mejia on 04/08/24. 6 | // 7 | 8 | import Foundation 9 | 10 | class URLProtocolStub: URLProtocol { 11 | private struct Stub { 12 | let data: Data? 13 | let response: URLResponse? 14 | let error: Error? 15 | let requestObserver: ((URLRequest) -> Void)? 16 | } 17 | 18 | nonisolated(unsafe) private static var _stub: Stub? 19 | private static var stub: Stub? { 20 | get { return queue.sync { _stub } } 21 | set { queue.sync { _stub = newValue } } 22 | } 23 | 24 | private static let queue = DispatchQueue(label: "URLProtocolStub.queue") 25 | 26 | static func stub(data: Data?, response: URLResponse?, error: Error?) { 27 | stub = Stub(data: data, response: response, error: error, requestObserver: nil) 28 | } 29 | 30 | static func observeRequests(observer: @escaping (URLRequest) -> Void) { 31 | stub = Stub(data: nil, response: nil, error: nil, requestObserver: observer) 32 | } 33 | 34 | static func removeStub() { 35 | stub = nil 36 | } 37 | 38 | override class func canInit(with request: URLRequest) -> Bool { 39 | return true 40 | } 41 | 42 | override class func canonicalRequest(for request: URLRequest) -> URLRequest { 43 | return request 44 | } 45 | 46 | override func startLoading() { 47 | guard let stub = URLProtocolStub.stub else { return } 48 | 49 | if let data = stub.data { 50 | client?.urlProtocol(self, didLoad: data) 51 | } 52 | 53 | if let response = stub.response { 54 | client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) 55 | } 56 | 57 | if let error = stub.error { 58 | client?.urlProtocol(self, didFailWithError: error) 59 | } else { 60 | client?.urlProtocolDidFinishLoading(self) 61 | } 62 | 63 | stub.requestObserver?(request) 64 | } 65 | 66 | override func stopLoading() {} 67 | } 68 | -------------------------------------------------------------------------------- /CleanAsyncObservationTests/Framework/Remote/TodosServiceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodosServiceTests.swift 3 | // CleanAsyncObservationTests 4 | // 5 | // Created by Ces Mejia on 04/08/24. 6 | // 7 | 8 | import Testing 9 | import Foundation 10 | @testable import CleanAsyncObservation 11 | 12 | final class TodosServiceTests { 13 | 14 | static let todo = Todo(userId: 1, id: 1, title: "Todo 1", completed: false) 15 | static let todo2 = Todo(userId: 2, id: 2, title: "Todo 2", completed: true) 16 | 17 | static let url = URL(string: Constants.TODO.baseURL + Constants.TODO.todosEndpoint)! 18 | 19 | deinit { 20 | URLProtocolStub.removeStub() 21 | } 22 | 23 | @Test func whenFetchingTodosIsSuccessful_getsTodos() async throws { 24 | let todoList = [Self.todo, Self.todo2] 25 | let encodedTodoList = try? JSONEncoder().encode(todoList.self) 26 | let urlResponse = HTTPURLResponse(url: Self.url, statusCode: 200, httpVersion: nil, headerFields: nil)! 27 | URLProtocolStub.stub(data: encodedTodoList, response: urlResponse, error: nil) 28 | 29 | let sut = makeSUT() 30 | let todos = try await sut.fetchTodos() 31 | #expect(todos == [Self.todo, Self.todo2]) 32 | } 33 | 34 | @Test func whenFetchingTodosResponseStatusIsNot200_throwsNot200ResponseError() async { 35 | let todoList = [Self.todo, Self.todo2] 36 | let encodedTodoList = try? JSONEncoder().encode(todoList.self) 37 | let urlResponse = HTTPURLResponse(url: Self.url, statusCode: 500, httpVersion: nil, headerFields: nil)! 38 | URLProtocolStub.stub(data: encodedTodoList, response: urlResponse, error: nil) 39 | 40 | let sut = makeSUT() 41 | do { 42 | let _ = try await sut.fetchTodos() 43 | #expect(Bool(false)) 44 | } catch { 45 | #expect(error == .not200Response) 46 | } 47 | } 48 | 49 | @Test func whenFetchingTodosResponseCannotBeDecoded_throwsDecodingError() async { 50 | let encodedTodoListFaulty = Data("Other Data".utf8) 51 | let urlResponse = HTTPURLResponse(url: Self.url, statusCode: 200, httpVersion: nil, headerFields: nil)! 52 | URLProtocolStub.stub(data: encodedTodoListFaulty, response: urlResponse, error: nil) 53 | 54 | let sut = makeSUT() 55 | do { 56 | let _ = try await sut.fetchTodos() 57 | #expect(Bool(false)) 58 | } catch { 59 | #expect(error == .decodingError) 60 | } 61 | } 62 | 63 | private func makeSUT() -> TodosServiceImp { 64 | let configuration = URLSessionConfiguration.ephemeral 65 | configuration.protocolClasses = [URLProtocolStub.self] 66 | let urlSession = URLSession(configuration: configuration) 67 | 68 | let todosService = TodosServiceImp(urlSession: urlSession) 69 | return todosService 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /CleanAsyncObservationTests/Presentation/TodosViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodosViewModelTests.swift 3 | // CleanObservationTests 4 | // 5 | // Created by Ces Mejia on 14/08/24. 6 | // 7 | 8 | import Testing 9 | import Foundation 10 | @testable import CleanAsyncObservation 11 | 12 | @MainActor 13 | struct TodosViewModelTests { 14 | 15 | nonisolated static let todo = Todo(userId: 1, id: 1, title: "Hello", completed: false) 16 | 17 | @Test func whenGettingTodosIsSuccessful_todosAreUpdated() async { 18 | let sut = makeSUT() 19 | await sut.getTodos() 20 | #expect(sut.todos == [Self.todo]) 21 | } 22 | 23 | @Test func whenGettingTodosIsNotSuccessful_updatesErrorText() async throws { 24 | let passedError = NSError(domain: "", code: 0) 25 | let sut = makeSUT(result: .failure(passedError)) 26 | await sut.getTodos() 27 | #expect(sut.errorText == passedError.localizedDescription) 28 | } 29 | 30 | @Test func whenRefreshingTodosIsSuccessful_todosAreUpdated() async throws { 31 | let sut = makeSUT() 32 | await sut.refreshTodos() 33 | #expect(sut.todos == [Self.todo]) 34 | } 35 | 36 | @Test func whenRefreshingTodosIsNotSuccessful_updatesErrorText() async { 37 | let passedError = NSError(domain: "", code: 0) 38 | let sut = makeSUT(result: .failure(passedError)) 39 | await sut.refreshTodos() 40 | #expect(sut.errorText == passedError.localizedDescription) 41 | } 42 | 43 | @Test func whenCallingTodoRowTapped_delegateIsCalled() async { 44 | let delegate = TodosViewActionsSpy() 45 | let sut = makeSUT(delegate: delegate) 46 | sut.todoRowTapped(for: Self.todo) 47 | #expect(delegate.todoRowTappedCalled == true) 48 | } 49 | 50 | private func makeSUT(result: Result<[Todo], Error> = .success([Self.todo]), delegate: TodosViewActionsSpy? = nil) -> TodosViewModel { 51 | let todosDataSource = TodosDataSourceStub(result: result) 52 | let getTodosUseCase = GetTodosUseCase(todosDataSource: todosDataSource) 53 | return TodosViewModel(getTodosUseCase: getTodosUseCase, delegate: delegate) 54 | } 55 | 56 | class TodosViewActionsSpy: TodosViewActions { 57 | var todoRowTappedCalled = false 58 | 59 | func todoRowTapped(for todo: Todo) { 60 | todoRowTappedCalled = true 61 | } 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /CleanAsyncObservationUITests/CleanAsyncObservationUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CleanAsyncObservationUITests.swift 3 | // CleanAsyncObservationUITests 4 | // 5 | // Created by Ces Mejia on 16/08/24. 6 | // 7 | 8 | import XCTest 9 | 10 | final class CleanAsyncObservationUITests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | 15 | // In UI tests it is usually best to stop immediately when a failure occurs. 16 | continueAfterFailure = false 17 | 18 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | } 24 | 25 | @MainActor 26 | func testExample() throws { 27 | // UI tests must launch the application that they test. 28 | let app = XCUIApplication() 29 | app.launch() 30 | 31 | // Use XCTAssert and related functions to verify your tests produce the correct results. 32 | } 33 | 34 | @MainActor 35 | func testLaunchPerformance() throws { 36 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 37 | // This measures how long it takes to launch your application. 38 | measure(metrics: [XCTApplicationLaunchMetric()]) { 39 | XCUIApplication().launch() 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /CleanAsyncObservationUITests/CleanAsyncObservationUITestsLaunchTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CleanAsyncObservationUITestsLaunchTests.swift 3 | // CleanAsyncObservationUITests 4 | // 5 | // Created by Ces Mejia on 16/08/24. 6 | // 7 | 8 | import XCTest 9 | 10 | final class CleanAsyncObservationUITestsLaunchTests: XCTestCase { 11 | 12 | override class var runsForEachTargetApplicationUIConfiguration: Bool { 13 | true 14 | } 15 | 16 | override func setUpWithError() throws { 17 | continueAfterFailure = false 18 | } 19 | 20 | @MainActor 21 | func testLaunch() throws { 22 | let app = XCUIApplication() 23 | app.launch() 24 | 25 | // Insert steps here to perform after app launch but before taking a screenshot, 26 | // such as logging into a test account or navigating somewhere in the app 27 | 28 | let attachment = XCTAttachment(screenshot: app.screenshot()) 29 | attachment.name = "Launch Screen" 30 | attachment.lifetime = .keepAlways 31 | add(attachment) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftUI Reactive Clean Architecture using MVVM with Unit Tests 2 | 3 | This is a simple project that gets a TODO list from the network, stores it within Filemanager, creates a memory cache and shows it in a SwiftUI list. 4 | The project has 3 screens and it leverages Combine, Observation and async/await to reactively update every screen from a single source of truth. 5 | This project leverages all the latest 2024 Apple technologies. 6 | 7 | This project can be used as a template for Enterprise grade projects 8 | 9 | Note: An older version of this project (without reactivity and with a single coordinator) can be found here: https://github.com/cesmejia/SwiftUI-Clean-Architecture-example-with-unit-tests.git 10 | 11 | ## Overview 12 | 13 | The project was developed with the following concepts in mind: 14 | 15 | - ``No external libraries`` 16 | - ``SOLID principles`` 17 | - ``Clean Architecture`` 18 | - ``MVVM Architecture pattern`` 19 | - ``Use of Composition root`` 20 | - ``Coordinator Pattern: Uses UIKit UINavigationController + UITabBarController + UIHostingController for navigation`` 21 | - ``Factory Pattern`` 22 | - ``Repository Pattern`` 23 | - ``Use Cases`` 24 | - ``Reactivity: Combine CurrentValueSubject + Observation Framework`` 25 | - ``Communication with Composition Root: Via Delegates (Closures could be used too)`` 26 | - ``Async Await + Typed Throws`` 27 | - ``Swift 6 + Complete Strict Concurrency Checking`` 28 | - ``Dependency Injection`` 29 | - ``Unit tests: Use of New Swift Testing Framework (Although TDD was not used, tests were created after each instance creation)`` 30 | - ``Test doubles: Use of Stubs, Spys and Mocks`` 31 | - ``Folder structure: Domain, Data, Presentation and Framework`` 32 | 33 | ### Dependency Diagram: 34 | 35 | ![233222696-eddef548-90d9-4930-b7bb-83eec2c9fdb4-2](https://github.com/user-attachments/assets/7e1f4897-6c28-4e5d-abd0-f5828a4265be) 36 | 37 | ### Disclaimer: 38 | 39 | This is a very basic project to serve as guide for a tested Clean Architecture approach with SwiftUI. 40 | 41 | - Feedback is welcomed. 42 | - I might add some more use cases and features in the near future. 43 | - TODO entity was used throughout the app for simplification sake. True modularity would be achieved by mapping it between layers. 44 | 45 | ### Discussion: 46 | - **Why UIKit?** I tried using pure SwiftUI but the Composition Root + SwiftUI Tab Navigation escalated the complexity quickly. 47 | - **Why CurrentValueSubject?** Using it versus PassthroughSubject made simple unit test possible by using the .value parameter. But you could use PassthroughSubject without much issue. 48 | - **Why Combine?** I tried using pure Async/Await (AsyncStream) or Observation or Combine-@Published but Main Thread issues and Protocol handling escalated the complexity quickly. 49 | 50 | ### Useful resources that made this possible: 51 | 52 | - Essential developer course: [Essential Developer](https://www.essentialdeveloper.com) 53 | - Hacking with swift: [HWS](https://www.hackingwithswift.com) 54 | - Clean Mobile Architecture Book by Petros Efthymiou: [Clean Mobile Architecture](https://www.petrosefthymiou.com/product-page/clean-mobile-architecture) 55 | - Dependency Injection Principles, Practices, and Patterns by Mark Seemann and Steven van Deursen [Dependency Injection](https://www.goodreads.com/en/book/show/44416307-dependency-injection-principles-practices-and-patterns) 56 | - Clean Architecture Book by Robert C. Martin (Uncle Bob) [Clean Architecture](https://www.goodreads.com/book/show/18043011-clean-architecture?ref=nav_sb_ss_1_11) 57 | --------------------------------------------------------------------------------