├── .gitignore ├── README.md ├── README_FILES ├── CleanArchitecture+MVVM.png └── CleanArchitectureDependencies.png └── SkillsTest ├── SkillsTest.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ └── SkillsTest.xcscheme ├── SkillsTest ├── Application │ ├── AppConfigurations.swift │ ├── AppDIContainer.swift │ ├── AppDelegate.swift │ ├── AppFlowCoordinator.swift │ └── SceneDelegate.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── Contents.json │ └── star_icon.imageset │ │ ├── Contents.json │ │ ├── baseline_star_black_18pt_2x.png │ │ └── baseline_star_black_18pt_3x.png ├── Base.lproj │ └── LaunchScreen.storyboard ├── Common │ ├── Cancellable.swift │ └── DispatchQueueType.swift ├── Data │ ├── Network │ │ ├── APIEndpoints.swift │ │ └── DataMapping │ │ │ └── Repositories │ │ │ ├── Request │ │ │ ├── ImagesRequestQuery.swift │ │ │ └── TrendingRepositoriesRequestQuery.swift │ │ │ └── Response │ │ │ ├── OwnerDto+Mapping.swift │ │ │ ├── OwnerDto.swift │ │ │ ├── Repository+Mapping.swift │ │ │ ├── RepositoryDto.swift │ │ │ ├── TrendingRepositoriesPageDto+Mapping.swift │ │ │ └── TrendingRepositoriesPageDto.swift │ ├── PersistentStorages │ │ ├── CoreDataStorage │ │ │ ├── CoreDataStorage.swift │ │ │ └── CoreDataStorage.xcdatamodeld │ │ │ │ └── CoreDataStorage.xcdatamodel │ │ │ │ └── contents │ │ ├── ImagesStorage │ │ │ ├── CoreDataImagesStorage.swift │ │ │ └── ImagesStorage.swift │ │ └── TrendingRepositoriesStorage │ │ │ ├── CoreDataTrendingRepositoriesStorage.swift │ │ │ ├── EntityMapping │ │ │ ├── OwnerEntity+Mapping.swift │ │ │ ├── RepositoryEntity+Mapping.swift │ │ │ └── TrendingRepositoriesPageEntity+Mapping.swift │ │ │ └── TrendingRepositoriesStorage.swift │ └── Repositories │ │ ├── DefaultImagesRepository.swift │ │ ├── DefaultTrendingRepositoriesRepository.swift │ │ └── Utils │ │ └── RepositoryTask.swift ├── Domain │ ├── Entities │ │ ├── Owner.swift │ │ ├── Repository.swift │ │ └── TrendingRepositoriesPage.swift │ ├── Interfaces │ │ └── Repositories │ │ │ ├── ImagesRepository.swift │ │ │ └── TrendingRepositoriesRepository.swift │ └── UseCases │ │ └── FetchTrendingRepositoriesUseCase.swift ├── Info.plist ├── Infrastructure │ └── Network │ │ ├── DataTransferService.swift │ │ ├── Endpoint.swift │ │ ├── NetworkConfig.swift │ │ └── NetworkService.swift └── Presentation │ ├── TrendingRepositoriesFeature │ ├── Flows │ │ └── TrendingRepositoriesFlowCoordinator.swift │ ├── Images+TrendingRepositoriesFeature.swift │ ├── Localizations+TrendingRepositoriesFeature.swift │ ├── TradingRepositoriesList │ │ ├── View │ │ │ ├── TrendingRepositoriesListTableView │ │ │ │ ├── Cells │ │ │ │ │ ├── TrendingRepositoryListEmptyItemCell.swift │ │ │ │ │ ├── TrendingRepositoryListItemCell.swift │ │ │ │ │ └── TrendingRepositoryListLoadingItemCell.swift │ │ │ │ └── TrendingRepositoriesListTableViewController.swift │ │ │ └── TrendingRepositoriesListViewController.swift │ │ └── ViewModel │ │ │ ├── TrendingRepositoriesDisplayModeltems │ │ │ ├── TrendingRepositoriesListContentViewModel.swift │ │ │ └── TrendingRepositoriesListItemViewModel.swift │ │ │ └── TrendingRepositoriesListViewModel.swift │ └── TrendingRepositoriesFeatureDIContainer.swift │ └── Utils │ ├── Extensions │ ├── Error+CancelError.swift │ ├── Error+ConnectionError.swift │ ├── Error+UIError.swift │ ├── UITableView+DequeueReusableCell.swift │ └── UIViewController+AddChild.swift │ ├── Observable.swift │ └── SkeletonLoadable.swift └── SkillsTestTests ├── Domain └── UseCases │ └── FetchTrendingRepositoriesUseCaseTests.swift ├── Infrastructure └── Network │ ├── DataTransferServiceTests.swift │ ├── Mocks │ ├── NetworkConfigurableMock.swift │ └── NetworkSessionManagerMock.swift │ └── NetworkServiceTests.swift ├── Presentation ├── Mocks │ └── DispatchQueueTypeMock.swift └── TrendingRepositoriesFeature │ └── TrendingRepositoriesListViewModelTests.swift └── Stubs └── Repository+Stub.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Template iOS App using Clean Architecture and MVVM with Views written in Code 3 | 4 | iOS Project implemented with Clean Layered Architecture and MVVM. (Can be used as Template project by replacing item name “Repository”). **More information in medium post**: Medium Post about Clean Architecture + MVVM 5 | 6 | 7 | ![Alt text](README_FILES/CleanArchitecture+MVVM.png?raw=true "Clean Architecture Layers") 8 | 9 | ## Layers 10 | * **Domain Layer** = Entities + Use Cases + Repositories Interfaces 11 | * **Data Repositories Layer** = Repositories Implementations + API (Network) + Persistence DB 12 | * **Presentation Layer (MVVM)** = ViewModels + Views 13 | 14 | ### Dependency Direction 15 | ![Alt text](README_FILES/CleanArchitectureDependencies.png?raw=true "Modules Dependencies") 16 | 17 | **Note:** **Domain Layer** should not include anything from other layers(e.g Presentation — UIKit or SwiftUI or Data Layer — Mapping Codable) 18 | 19 | ## Architecture concepts used here 20 | * Clean Architecture https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html 21 | * Advanced iOS App Architecture https://www.raywenderlich.com/8477-introducing-advanced-ios-app-architecture 22 | * [MVVM](ExampleMVVM/Presentation/MoviesScene/MoviesQueriesList) 23 | * Observable 24 | * Dependency Injection 25 | * Flow Coordinator 26 | * Data Transfer Object (DTO) 27 | * Response Data Caching 28 | * ViewController Lifecycle Behavior 29 | * SwiftUI and UIKit Views in code 30 | * Use of UITableViewDiffableDataSource 31 | * Shimmering Loading 32 | * Error handling 33 | * CI Pipeline ([Travis CI + Fastlane](.travis.yml)) 34 | 35 | ## Includes 36 | * Unit Tests for Use Cases(Domain Layer), ViewModels(Presentation Layer), NetworkService(Infrastructure Layer) 37 | * Dark Mode 38 | * SwiftUI example, demostration that presentation layer does not change, only UI (at least Xcode 11 required) 39 | 40 | ## Networking 41 | If you would like to use Networking from this example project as repo I made it availabe [here](https://github.com/kudoleh/SENetworking) 42 | 43 | ## Requirements 44 | * Xcode Version 11.2.1+ Min iOS version 13 Swift 5.0+ 45 | 46 | # How to use app 47 | Tap on repository cell to expand cell and see its details. 48 | 49 | 50 | https://user-images.githubusercontent.com/6785311/236620282-5cebc95e-cc02-421c-a981-23b6a02d3f1d.mp4 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /README_FILES/CleanArchitecture+MVVM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kudoleh/iOS-Clean-Architecture-MVVM-Views-In-Code/f80dfe41575de18ec1429e5f09c38d1fbf1d793f/README_FILES/CleanArchitecture+MVVM.png -------------------------------------------------------------------------------- /README_FILES/CleanArchitectureDependencies.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kudoleh/iOS-Clean-Architecture-MVVM-Views-In-Code/f80dfe41575de18ec1429e5f09c38d1fbf1d793f/README_FILES/CleanArchitectureDependencies.png -------------------------------------------------------------------------------- /SkillsTest/SkillsTest.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 6C1D572F2999C81A00CB7185 /* NetworkConfigurableMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1D572B2999C81900CB7185 /* NetworkConfigurableMock.swift */; }; 11 | 6C1D57302999C81A00CB7185 /* NetworkSessionManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1D572C2999C81900CB7185 /* NetworkSessionManagerMock.swift */; }; 12 | 6C1D57312999C81A00CB7185 /* NetworkServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1D572D2999C81900CB7185 /* NetworkServiceTests.swift */; }; 13 | 6C1D57322999C81A00CB7185 /* DataTransferServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1D572E2999C81900CB7185 /* DataTransferServiceTests.swift */; }; 14 | 6C1D57362999C8EB00CB7185 /* FetchTrendingRepositoriesUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1D57352999C8EB00CB7185 /* FetchTrendingRepositoriesUseCaseTests.swift */; }; 15 | 6C1D57392999C93E00CB7185 /* Repository+Stub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1D57382999C93E00CB7185 /* Repository+Stub.swift */; }; 16 | 6C1D573D2999C97100CB7185 /* TrendingRepositoriesListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1D573C2999C97000CB7185 /* TrendingRepositoriesListViewModelTests.swift */; }; 17 | 6C39852F2999C1AE00CD2B99 /* AppFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3984E12999C1AD00CD2B99 /* AppFlowCoordinator.swift */; }; 18 | 6C3985302999C1AE00CD2B99 /* AppConfigurations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3984E22999C1AD00CD2B99 /* AppConfigurations.swift */; }; 19 | 6C3985312999C1AE00CD2B99 /* AppDIContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3984E42999C1AD00CD2B99 /* AppDIContainer.swift */; }; 20 | 6C3985322999C1AE00CD2B99 /* TrendingRepositoriesFeatureDIContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3984E52999C1AD00CD2B99 /* TrendingRepositoriesFeatureDIContainer.swift */; }; 21 | 6C3985332999C1AE00CD2B99 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3984E62999C1AD00CD2B99 /* AppDelegate.swift */; }; 22 | 6C3985342999C1AE00CD2B99 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3984E72999C1AD00CD2B99 /* SceneDelegate.swift */; }; 23 | 6C3985352999C1AE00CD2B99 /* DataTransferService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3984EA2999C1AD00CD2B99 /* DataTransferService.swift */; }; 24 | 6C3985362999C1AE00CD2B99 /* NetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3984EB2999C1AD00CD2B99 /* NetworkService.swift */; }; 25 | 6C3985372999C1AE00CD2B99 /* Endpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3984EC2999C1AD00CD2B99 /* Endpoint.swift */; }; 26 | 6C3985382999C1AE00CD2B99 /* NetworkConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3984ED2999C1AD00CD2B99 /* NetworkConfig.swift */; }; 27 | 6C3985392999C1AE00CD2B99 /* APIEndpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3984F02999C1AD00CD2B99 /* APIEndpoints.swift */; }; 28 | 6C39853A2999C1AE00CD2B99 /* OwnerDto+Mapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3984F42999C1AD00CD2B99 /* OwnerDto+Mapping.swift */; }; 29 | 6C39853B2999C1AE00CD2B99 /* OwnerDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3984F52999C1AD00CD2B99 /* OwnerDto.swift */; }; 30 | 6C39853C2999C1AE00CD2B99 /* Repository+Mapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3984F62999C1AD00CD2B99 /* Repository+Mapping.swift */; }; 31 | 6C39853D2999C1AE00CD2B99 /* RepositoryDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3984F72999C1AD00CD2B99 /* RepositoryDto.swift */; }; 32 | 6C39853F2999C1AE00CD2B99 /* DefaultTrendingRepositoriesRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3984FB2999C1AD00CD2B99 /* DefaultTrendingRepositoriesRepository.swift */; }; 33 | 6C3985402999C1AE00CD2B99 /* RepositoryTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3984FD2999C1AD00CD2B99 /* RepositoryTask.swift */; }; 34 | 6C3985482999C1AE00CD2B99 /* Cancellable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C39850B2999C1AD00CD2B99 /* Cancellable.swift */; }; 35 | 6C3985492999C1AE00CD2B99 /* FetchTrendingRepositoriesUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C39850E2999C1AD00CD2B99 /* FetchTrendingRepositoriesUseCase.swift */; }; 36 | 6C39854A2999C1AE00CD2B99 /* Owner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3985102999C1AD00CD2B99 /* Owner.swift */; }; 37 | 6C39854B2999C1AE00CD2B99 /* TrendingRepositoriesPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3985112999C1AD00CD2B99 /* TrendingRepositoriesPage.swift */; }; 38 | 6C39854C2999C1AE00CD2B99 /* TrendingRepositoriesRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3985142999C1AD00CD2B99 /* TrendingRepositoriesRepository.swift */; }; 39 | 6C39854D2999C1AE00CD2B99 /* Localizations+TrendingRepositoriesFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3985162999C1AD00CD2B99 /* Localizations+TrendingRepositoriesFeature.swift */; }; 40 | 6C39854E2999C1AE00CD2B99 /* TrendingRepositoriesListItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C39851B2999C1AD00CD2B99 /* TrendingRepositoriesListItemViewModel.swift */; }; 41 | 6C39854F2999C1AE00CD2B99 /* TrendingRepositoriesListContentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C39851C2999C1AD00CD2B99 /* TrendingRepositoriesListContentViewModel.swift */; }; 42 | 6C3985502999C1AE00CD2B99 /* TrendingRepositoriesListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C39851D2999C1AD00CD2B99 /* TrendingRepositoriesListViewModel.swift */; }; 43 | 6C3985512999C1AE00CD2B99 /* TrendingRepositoryListItemCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3985212999C1AD00CD2B99 /* TrendingRepositoryListItemCell.swift */; }; 44 | 6C3985522999C1AE00CD2B99 /* TrendingRepositoryListEmptyItemCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3985222999C1AD00CD2B99 /* TrendingRepositoryListEmptyItemCell.swift */; }; 45 | 6C3985532999C1AE00CD2B99 /* TrendingRepositoryListLoadingItemCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3985232999C1AD00CD2B99 /* TrendingRepositoryListLoadingItemCell.swift */; }; 46 | 6C3985542999C1AE00CD2B99 /* TrendingRepositoriesListTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3985242999C1AD00CD2B99 /* TrendingRepositoriesListTableViewController.swift */; }; 47 | 6C3985552999C1AE00CD2B99 /* TrendingRepositoriesListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3985252999C1AD00CD2B99 /* TrendingRepositoriesListViewController.swift */; }; 48 | 6C3985562999C1AE00CD2B99 /* TrendingRepositoriesFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3985272999C1AD00CD2B99 /* TrendingRepositoriesFlowCoordinator.swift */; }; 49 | 6C3985572999C1AE00CD2B99 /* SkeletonLoadable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3985292999C1AE00CD2B99 /* SkeletonLoadable.swift */; }; 50 | 6C3985582999C1AE00CD2B99 /* UIViewController+AddChild.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C39852B2999C1AE00CD2B99 /* UIViewController+AddChild.swift */; }; 51 | 6C39855A2999C1AE00CD2B99 /* UITableView+DequeueReusableCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C39852D2999C1AE00CD2B99 /* UITableView+DequeueReusableCell.swift */; }; 52 | 6C39855B2999C1AE00CD2B99 /* Observable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C39852E2999C1AE00CD2B99 /* Observable.swift */; }; 53 | 6C54723E2996D2E400EE2F1F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6C54723D2996D2E400EE2F1F /* Assets.xcassets */; }; 54 | 6C5472412996D2E400EE2F1F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 6C54723F2996D2E400EE2F1F /* LaunchScreen.storyboard */; }; 55 | 6C67BD6429D831750071141A /* Repository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C67BD6329D831750071141A /* Repository.swift */; }; 56 | 6C67BD6629D8445E0071141A /* TrendingRepositoriesPageDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C67BD6529D8445E0071141A /* TrendingRepositoriesPageDto.swift */; }; 57 | 6C67BD6829D8446F0071141A /* TrendingRepositoriesPageDto+Mapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C67BD6729D8446F0071141A /* TrendingRepositoriesPageDto+Mapping.swift */; }; 58 | 6C67BD6B29D84A5B0071141A /* TrendingRepositoriesRequestQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C67BD6A29D84A5B0071141A /* TrendingRepositoriesRequestQuery.swift */; }; 59 | 6C67BD6D29D88FB10071141A /* DispatchQueueType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C67BD6C29D88FB10071141A /* DispatchQueueType.swift */; }; 60 | 6C78884029D8E03900D1D7F6 /* ImagesRequestQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C78883F29D8E03900D1D7F6 /* ImagesRequestQuery.swift */; }; 61 | 6C7B05CA29D987FE009ABC1A /* Images+TrendingRepositoriesFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7B05C929D987FE009ABC1A /* Images+TrendingRepositoriesFeature.swift */; }; 62 | 6C7B05CE29D9CDDA009ABC1A /* CoreDataStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7B05CD29D9CDDA009ABC1A /* CoreDataStorage.swift */; }; 63 | 6C7B05D129D9CE38009ABC1A /* CoreDataStorage.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 6C7B05CF29D9CE38009ABC1A /* CoreDataStorage.xcdatamodeld */; }; 64 | 6C7B05D429D9CF5D009ABC1A /* ImagesStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7B05D329D9CF5D009ABC1A /* ImagesStorage.swift */; }; 65 | 6C7B05D629D9CFC6009ABC1A /* CoreDataImagesStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7B05D529D9CFC6009ABC1A /* CoreDataImagesStorage.swift */; }; 66 | 6C7C43ED29DA017A00FFDC85 /* CoreDataTrendingRepositoriesStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7C43EC29DA017A00FFDC85 /* CoreDataTrendingRepositoriesStorage.swift */; }; 67 | 6C7C43EF29DA018500FFDC85 /* TrendingRepositoriesStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7C43EE29DA018500FFDC85 /* TrendingRepositoriesStorage.swift */; }; 68 | 6C7C43F129DA02ED00FFDC85 /* OwnerEntity+Mapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7C43F029DA02ED00FFDC85 /* OwnerEntity+Mapping.swift */; }; 69 | 6C7C43F329DA055100FFDC85 /* RepositoryEntity+Mapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7C43F229DA055100FFDC85 /* RepositoryEntity+Mapping.swift */; }; 70 | 6C7C43F529DA070300FFDC85 /* TrendingRepositoriesPageEntity+Mapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7C43F429DA070300FFDC85 /* TrendingRepositoriesPageEntity+Mapping.swift */; }; 71 | 6C872C2129A57E98008C7593 /* DispatchQueueTypeMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C872C2029A57E98008C7593 /* DispatchQueueTypeMock.swift */; }; 72 | 6CA700A829D8B2AF006A97CC /* ImagesRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CA700A729D8B2AE006A97CC /* ImagesRepository.swift */; }; 73 | 6CA700AA29D8B2D9006A97CC /* DefaultImagesRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CA700A929D8B2D9006A97CC /* DefaultImagesRepository.swift */; }; 74 | 6CCA59FB299C2A7500894081 /* Error+UIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CCA59FA299C2A7500894081 /* Error+UIError.swift */; }; 75 | /* End PBXBuildFile section */ 76 | 77 | /* Begin PBXContainerItemProxy section */ 78 | 6C5472482996D2E600EE2F1F /* PBXContainerItemProxy */ = { 79 | isa = PBXContainerItemProxy; 80 | containerPortal = 6C5472262996D2E000EE2F1F /* Project object */; 81 | proxyType = 1; 82 | remoteGlobalIDString = 6C54722D2996D2E000EE2F1F; 83 | remoteInfo = SkillsTest; 84 | }; 85 | /* End PBXContainerItemProxy section */ 86 | 87 | /* Begin PBXFileReference section */ 88 | 6C1D572B2999C81900CB7185 /* NetworkConfigurableMock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkConfigurableMock.swift; sourceTree = ""; }; 89 | 6C1D572C2999C81900CB7185 /* NetworkSessionManagerMock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkSessionManagerMock.swift; sourceTree = ""; }; 90 | 6C1D572D2999C81900CB7185 /* NetworkServiceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkServiceTests.swift; sourceTree = ""; }; 91 | 6C1D572E2999C81900CB7185 /* DataTransferServiceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataTransferServiceTests.swift; sourceTree = ""; }; 92 | 6C1D57352999C8EB00CB7185 /* FetchTrendingRepositoriesUseCaseTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FetchTrendingRepositoriesUseCaseTests.swift; sourceTree = ""; }; 93 | 6C1D57382999C93E00CB7185 /* Repository+Stub.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Repository+Stub.swift"; sourceTree = ""; }; 94 | 6C1D573C2999C97000CB7185 /* TrendingRepositoriesListViewModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrendingRepositoriesListViewModelTests.swift; sourceTree = ""; }; 95 | 6C3984E12999C1AD00CD2B99 /* AppFlowCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppFlowCoordinator.swift; sourceTree = ""; }; 96 | 6C3984E22999C1AD00CD2B99 /* AppConfigurations.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppConfigurations.swift; sourceTree = ""; }; 97 | 6C3984E42999C1AD00CD2B99 /* AppDIContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDIContainer.swift; sourceTree = ""; }; 98 | 6C3984E52999C1AD00CD2B99 /* TrendingRepositoriesFeatureDIContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrendingRepositoriesFeatureDIContainer.swift; sourceTree = ""; }; 99 | 6C3984E62999C1AD00CD2B99 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 100 | 6C3984E72999C1AD00CD2B99 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 101 | 6C3984EA2999C1AD00CD2B99 /* DataTransferService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataTransferService.swift; sourceTree = ""; }; 102 | 6C3984EB2999C1AD00CD2B99 /* NetworkService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkService.swift; sourceTree = ""; }; 103 | 6C3984EC2999C1AD00CD2B99 /* Endpoint.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Endpoint.swift; sourceTree = ""; }; 104 | 6C3984ED2999C1AD00CD2B99 /* NetworkConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkConfig.swift; sourceTree = ""; }; 105 | 6C3984F02999C1AD00CD2B99 /* APIEndpoints.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIEndpoints.swift; sourceTree = ""; }; 106 | 6C3984F42999C1AD00CD2B99 /* OwnerDto+Mapping.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "OwnerDto+Mapping.swift"; sourceTree = ""; }; 107 | 6C3984F52999C1AD00CD2B99 /* OwnerDto.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OwnerDto.swift; sourceTree = ""; }; 108 | 6C3984F62999C1AD00CD2B99 /* Repository+Mapping.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Repository+Mapping.swift"; sourceTree = ""; }; 109 | 6C3984F72999C1AD00CD2B99 /* RepositoryDto.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RepositoryDto.swift; sourceTree = ""; }; 110 | 6C3984FB2999C1AD00CD2B99 /* DefaultTrendingRepositoriesRepository.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DefaultTrendingRepositoriesRepository.swift; sourceTree = ""; }; 111 | 6C3984FD2999C1AD00CD2B99 /* RepositoryTask.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RepositoryTask.swift; sourceTree = ""; }; 112 | 6C39850B2999C1AD00CD2B99 /* Cancellable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Cancellable.swift; sourceTree = ""; }; 113 | 6C39850E2999C1AD00CD2B99 /* FetchTrendingRepositoriesUseCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FetchTrendingRepositoriesUseCase.swift; sourceTree = ""; }; 114 | 6C3985102999C1AD00CD2B99 /* Owner.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Owner.swift; sourceTree = ""; }; 115 | 6C3985112999C1AD00CD2B99 /* TrendingRepositoriesPage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrendingRepositoriesPage.swift; sourceTree = ""; }; 116 | 6C3985142999C1AD00CD2B99 /* TrendingRepositoriesRepository.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrendingRepositoriesRepository.swift; sourceTree = ""; }; 117 | 6C3985162999C1AD00CD2B99 /* Localizations+TrendingRepositoriesFeature.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Localizations+TrendingRepositoriesFeature.swift"; sourceTree = ""; }; 118 | 6C39851B2999C1AD00CD2B99 /* TrendingRepositoriesListItemViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrendingRepositoriesListItemViewModel.swift; sourceTree = ""; }; 119 | 6C39851C2999C1AD00CD2B99 /* TrendingRepositoriesListContentViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrendingRepositoriesListContentViewModel.swift; sourceTree = ""; }; 120 | 6C39851D2999C1AD00CD2B99 /* TrendingRepositoriesListViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrendingRepositoriesListViewModel.swift; sourceTree = ""; }; 121 | 6C3985212999C1AD00CD2B99 /* TrendingRepositoryListItemCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrendingRepositoryListItemCell.swift; sourceTree = ""; }; 122 | 6C3985222999C1AD00CD2B99 /* TrendingRepositoryListEmptyItemCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrendingRepositoryListEmptyItemCell.swift; sourceTree = ""; }; 123 | 6C3985232999C1AD00CD2B99 /* TrendingRepositoryListLoadingItemCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrendingRepositoryListLoadingItemCell.swift; sourceTree = ""; }; 124 | 6C3985242999C1AD00CD2B99 /* TrendingRepositoriesListTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrendingRepositoriesListTableViewController.swift; sourceTree = ""; }; 125 | 6C3985252999C1AD00CD2B99 /* TrendingRepositoriesListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrendingRepositoriesListViewController.swift; sourceTree = ""; }; 126 | 6C3985272999C1AD00CD2B99 /* TrendingRepositoriesFlowCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrendingRepositoriesFlowCoordinator.swift; sourceTree = ""; }; 127 | 6C3985292999C1AE00CD2B99 /* SkeletonLoadable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SkeletonLoadable.swift; sourceTree = ""; }; 128 | 6C39852B2999C1AE00CD2B99 /* UIViewController+AddChild.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIViewController+AddChild.swift"; sourceTree = ""; }; 129 | 6C39852D2999C1AE00CD2B99 /* UITableView+DequeueReusableCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITableView+DequeueReusableCell.swift"; sourceTree = ""; }; 130 | 6C39852E2999C1AE00CD2B99 /* Observable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Observable.swift; sourceTree = ""; }; 131 | 6C54722E2996D2E000EE2F1F /* SkillsTest.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SkillsTest.app; sourceTree = BUILT_PRODUCTS_DIR; }; 132 | 6C54723D2996D2E400EE2F1F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 133 | 6C5472402996D2E400EE2F1F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 134 | 6C5472422996D2E400EE2F1F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 135 | 6C5472472996D2E600EE2F1F /* SkillsTestTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SkillsTestTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 136 | 6C67BD6329D831750071141A /* Repository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Repository.swift; sourceTree = ""; }; 137 | 6C67BD6529D8445E0071141A /* TrendingRepositoriesPageDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingRepositoriesPageDto.swift; sourceTree = ""; }; 138 | 6C67BD6729D8446F0071141A /* TrendingRepositoriesPageDto+Mapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrendingRepositoriesPageDto+Mapping.swift"; sourceTree = ""; }; 139 | 6C67BD6A29D84A5B0071141A /* TrendingRepositoriesRequestQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingRepositoriesRequestQuery.swift; sourceTree = ""; }; 140 | 6C67BD6C29D88FB10071141A /* DispatchQueueType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DispatchQueueType.swift; sourceTree = ""; }; 141 | 6C78883F29D8E03900D1D7F6 /* ImagesRequestQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesRequestQuery.swift; sourceTree = ""; }; 142 | 6C7B05C929D987FE009ABC1A /* Images+TrendingRepositoriesFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Images+TrendingRepositoriesFeature.swift"; sourceTree = ""; }; 143 | 6C7B05CD29D9CDDA009ABC1A /* CoreDataStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStorage.swift; sourceTree = ""; }; 144 | 6C7B05D029D9CE38009ABC1A /* CoreDataStorage.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CoreDataStorage.xcdatamodel; sourceTree = ""; }; 145 | 6C7B05D329D9CF5D009ABC1A /* ImagesStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesStorage.swift; sourceTree = ""; }; 146 | 6C7B05D529D9CFC6009ABC1A /* CoreDataImagesStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataImagesStorage.swift; sourceTree = ""; }; 147 | 6C7C43EC29DA017A00FFDC85 /* CoreDataTrendingRepositoriesStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataTrendingRepositoriesStorage.swift; sourceTree = ""; }; 148 | 6C7C43EE29DA018500FFDC85 /* TrendingRepositoriesStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingRepositoriesStorage.swift; sourceTree = ""; }; 149 | 6C7C43F029DA02ED00FFDC85 /* OwnerEntity+Mapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OwnerEntity+Mapping.swift"; sourceTree = ""; }; 150 | 6C7C43F229DA055100FFDC85 /* RepositoryEntity+Mapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RepositoryEntity+Mapping.swift"; sourceTree = ""; }; 151 | 6C7C43F429DA070300FFDC85 /* TrendingRepositoriesPageEntity+Mapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrendingRepositoriesPageEntity+Mapping.swift"; sourceTree = ""; }; 152 | 6C872C2029A57E98008C7593 /* DispatchQueueTypeMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DispatchQueueTypeMock.swift; sourceTree = ""; }; 153 | 6CA700A729D8B2AE006A97CC /* ImagesRepository.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagesRepository.swift; sourceTree = ""; }; 154 | 6CA700A929D8B2D9006A97CC /* DefaultImagesRepository.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DefaultImagesRepository.swift; sourceTree = ""; }; 155 | 6CCA59FA299C2A7500894081 /* Error+UIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Error+UIError.swift"; sourceTree = ""; }; 156 | /* End PBXFileReference section */ 157 | 158 | /* Begin PBXFrameworksBuildPhase section */ 159 | 6C54722B2996D2E000EE2F1F /* Frameworks */ = { 160 | isa = PBXFrameworksBuildPhase; 161 | buildActionMask = 2147483647; 162 | files = ( 163 | ); 164 | runOnlyForDeploymentPostprocessing = 0; 165 | }; 166 | 6C5472442996D2E600EE2F1F /* Frameworks */ = { 167 | isa = PBXFrameworksBuildPhase; 168 | buildActionMask = 2147483647; 169 | files = ( 170 | ); 171 | runOnlyForDeploymentPostprocessing = 0; 172 | }; 173 | /* End PBXFrameworksBuildPhase section */ 174 | 175 | /* Begin PBXGroup section */ 176 | 6C1D57282999C81900CB7185 /* Infrastructure */ = { 177 | isa = PBXGroup; 178 | children = ( 179 | 6C1D57292999C81900CB7185 /* Network */, 180 | ); 181 | path = Infrastructure; 182 | sourceTree = ""; 183 | }; 184 | 6C1D57292999C81900CB7185 /* Network */ = { 185 | isa = PBXGroup; 186 | children = ( 187 | 6C1D572A2999C81900CB7185 /* Mocks */, 188 | 6C1D572D2999C81900CB7185 /* NetworkServiceTests.swift */, 189 | 6C1D572E2999C81900CB7185 /* DataTransferServiceTests.swift */, 190 | ); 191 | path = Network; 192 | sourceTree = ""; 193 | }; 194 | 6C1D572A2999C81900CB7185 /* Mocks */ = { 195 | isa = PBXGroup; 196 | children = ( 197 | 6C1D572B2999C81900CB7185 /* NetworkConfigurableMock.swift */, 198 | 6C1D572C2999C81900CB7185 /* NetworkSessionManagerMock.swift */, 199 | ); 200 | path = Mocks; 201 | sourceTree = ""; 202 | }; 203 | 6C1D57332999C8EB00CB7185 /* Domain */ = { 204 | isa = PBXGroup; 205 | children = ( 206 | 6C1D57342999C8EB00CB7185 /* UseCases */, 207 | ); 208 | path = Domain; 209 | sourceTree = ""; 210 | }; 211 | 6C1D57342999C8EB00CB7185 /* UseCases */ = { 212 | isa = PBXGroup; 213 | children = ( 214 | 6C1D57352999C8EB00CB7185 /* FetchTrendingRepositoriesUseCaseTests.swift */, 215 | ); 216 | path = UseCases; 217 | sourceTree = ""; 218 | }; 219 | 6C1D57372999C93E00CB7185 /* Stubs */ = { 220 | isa = PBXGroup; 221 | children = ( 222 | 6C1D57382999C93E00CB7185 /* Repository+Stub.swift */, 223 | ); 224 | path = Stubs; 225 | sourceTree = ""; 226 | }; 227 | 6C1D573A2999C97000CB7185 /* Presentation */ = { 228 | isa = PBXGroup; 229 | children = ( 230 | 6C872C1F29A57E8A008C7593 /* Mocks */, 231 | 6C1D573B2999C97000CB7185 /* TrendingRepositoriesFeature */, 232 | ); 233 | path = Presentation; 234 | sourceTree = ""; 235 | }; 236 | 6C1D573B2999C97000CB7185 /* TrendingRepositoriesFeature */ = { 237 | isa = PBXGroup; 238 | children = ( 239 | 6C1D573C2999C97000CB7185 /* TrendingRepositoriesListViewModelTests.swift */, 240 | ); 241 | path = TrendingRepositoriesFeature; 242 | sourceTree = ""; 243 | }; 244 | 6C3984E02999C1AD00CD2B99 /* Application */ = { 245 | isa = PBXGroup; 246 | children = ( 247 | 6C3984E62999C1AD00CD2B99 /* AppDelegate.swift */, 248 | 6C3984E72999C1AD00CD2B99 /* SceneDelegate.swift */, 249 | 6C3984E22999C1AD00CD2B99 /* AppConfigurations.swift */, 250 | 6C3984E42999C1AD00CD2B99 /* AppDIContainer.swift */, 251 | 6C3984E12999C1AD00CD2B99 /* AppFlowCoordinator.swift */, 252 | ); 253 | path = Application; 254 | sourceTree = ""; 255 | }; 256 | 6C3984E82999C1AD00CD2B99 /* Infrastructure */ = { 257 | isa = PBXGroup; 258 | children = ( 259 | 6C3984E92999C1AD00CD2B99 /* Network */, 260 | ); 261 | path = Infrastructure; 262 | sourceTree = ""; 263 | }; 264 | 6C3984E92999C1AD00CD2B99 /* Network */ = { 265 | isa = PBXGroup; 266 | children = ( 267 | 6C3984EA2999C1AD00CD2B99 /* DataTransferService.swift */, 268 | 6C3984EB2999C1AD00CD2B99 /* NetworkService.swift */, 269 | 6C3984EC2999C1AD00CD2B99 /* Endpoint.swift */, 270 | 6C3984ED2999C1AD00CD2B99 /* NetworkConfig.swift */, 271 | ); 272 | path = Network; 273 | sourceTree = ""; 274 | }; 275 | 6C3984EE2999C1AD00CD2B99 /* Data */ = { 276 | isa = PBXGroup; 277 | children = ( 278 | 6C3984EF2999C1AD00CD2B99 /* Network */, 279 | 6C3984FA2999C1AD00CD2B99 /* Repositories */, 280 | 6C7B05CB29D9CD9D009ABC1A /* PersistentStorages */, 281 | ); 282 | path = Data; 283 | sourceTree = ""; 284 | }; 285 | 6C3984EF2999C1AD00CD2B99 /* Network */ = { 286 | isa = PBXGroup; 287 | children = ( 288 | 6C3984F02999C1AD00CD2B99 /* APIEndpoints.swift */, 289 | 6C3984F12999C1AD00CD2B99 /* DataMapping */, 290 | ); 291 | path = Network; 292 | sourceTree = ""; 293 | }; 294 | 6C3984F12999C1AD00CD2B99 /* DataMapping */ = { 295 | isa = PBXGroup; 296 | children = ( 297 | 6C3984F22999C1AD00CD2B99 /* Repositories */, 298 | ); 299 | path = DataMapping; 300 | sourceTree = ""; 301 | }; 302 | 6C3984F22999C1AD00CD2B99 /* Repositories */ = { 303 | isa = PBXGroup; 304 | children = ( 305 | 6C67BD6929D84A4F0071141A /* Request */, 306 | 6C3984F32999C1AD00CD2B99 /* Response */, 307 | ); 308 | path = Repositories; 309 | sourceTree = ""; 310 | }; 311 | 6C3984F32999C1AD00CD2B99 /* Response */ = { 312 | isa = PBXGroup; 313 | children = ( 314 | 6C3984F42999C1AD00CD2B99 /* OwnerDto+Mapping.swift */, 315 | 6C3984F52999C1AD00CD2B99 /* OwnerDto.swift */, 316 | 6C3984F62999C1AD00CD2B99 /* Repository+Mapping.swift */, 317 | 6C3984F72999C1AD00CD2B99 /* RepositoryDto.swift */, 318 | 6C67BD6529D8445E0071141A /* TrendingRepositoriesPageDto.swift */, 319 | 6C67BD6729D8446F0071141A /* TrendingRepositoriesPageDto+Mapping.swift */, 320 | ); 321 | path = Response; 322 | sourceTree = ""; 323 | }; 324 | 6C3984FA2999C1AD00CD2B99 /* Repositories */ = { 325 | isa = PBXGroup; 326 | children = ( 327 | 6C3984FB2999C1AD00CD2B99 /* DefaultTrendingRepositoriesRepository.swift */, 328 | 6CA700A929D8B2D9006A97CC /* DefaultImagesRepository.swift */, 329 | 6C3984FC2999C1AD00CD2B99 /* Utils */, 330 | ); 331 | path = Repositories; 332 | sourceTree = ""; 333 | }; 334 | 6C3984FC2999C1AD00CD2B99 /* Utils */ = { 335 | isa = PBXGroup; 336 | children = ( 337 | 6C3984FD2999C1AD00CD2B99 /* RepositoryTask.swift */, 338 | ); 339 | path = Utils; 340 | sourceTree = ""; 341 | }; 342 | 6C3985092999C1AD00CD2B99 /* Common */ = { 343 | isa = PBXGroup; 344 | children = ( 345 | 6C67BD6C29D88FB10071141A /* DispatchQueueType.swift */, 346 | 6C39850B2999C1AD00CD2B99 /* Cancellable.swift */, 347 | ); 348 | path = Common; 349 | sourceTree = ""; 350 | }; 351 | 6C39850C2999C1AD00CD2B99 /* Domain */ = { 352 | isa = PBXGroup; 353 | children = ( 354 | 6C39850D2999C1AD00CD2B99 /* UseCases */, 355 | 6C39850F2999C1AD00CD2B99 /* Entities */, 356 | 6C3985122999C1AD00CD2B99 /* Interfaces */, 357 | ); 358 | path = Domain; 359 | sourceTree = ""; 360 | }; 361 | 6C39850D2999C1AD00CD2B99 /* UseCases */ = { 362 | isa = PBXGroup; 363 | children = ( 364 | 6C39850E2999C1AD00CD2B99 /* FetchTrendingRepositoriesUseCase.swift */, 365 | ); 366 | path = UseCases; 367 | sourceTree = ""; 368 | }; 369 | 6C39850F2999C1AD00CD2B99 /* Entities */ = { 370 | isa = PBXGroup; 371 | children = ( 372 | 6C3985102999C1AD00CD2B99 /* Owner.swift */, 373 | 6C67BD6329D831750071141A /* Repository.swift */, 374 | 6C3985112999C1AD00CD2B99 /* TrendingRepositoriesPage.swift */, 375 | ); 376 | path = Entities; 377 | sourceTree = ""; 378 | }; 379 | 6C3985122999C1AD00CD2B99 /* Interfaces */ = { 380 | isa = PBXGroup; 381 | children = ( 382 | 6C3985132999C1AD00CD2B99 /* Repositories */, 383 | ); 384 | path = Interfaces; 385 | sourceTree = ""; 386 | }; 387 | 6C3985132999C1AD00CD2B99 /* Repositories */ = { 388 | isa = PBXGroup; 389 | children = ( 390 | 6C3985142999C1AD00CD2B99 /* TrendingRepositoriesRepository.swift */, 391 | 6CA700A729D8B2AE006A97CC /* ImagesRepository.swift */, 392 | ); 393 | path = Repositories; 394 | sourceTree = ""; 395 | }; 396 | 6C3985152999C1AD00CD2B99 /* Presentation */ = { 397 | isa = PBXGroup; 398 | children = ( 399 | 6C3985172999C1AD00CD2B99 /* TrendingRepositoriesFeature */, 400 | 6C3985282999C1AE00CD2B99 /* Utils */, 401 | ); 402 | path = Presentation; 403 | sourceTree = ""; 404 | }; 405 | 6C3985172999C1AD00CD2B99 /* TrendingRepositoriesFeature */ = { 406 | isa = PBXGroup; 407 | children = ( 408 | 6C3984E52999C1AD00CD2B99 /* TrendingRepositoriesFeatureDIContainer.swift */, 409 | 6C3985182999C1AD00CD2B99 /* TradingRepositoriesList */, 410 | 6C3985262999C1AD00CD2B99 /* Flows */, 411 | 6C3985162999C1AD00CD2B99 /* Localizations+TrendingRepositoriesFeature.swift */, 412 | 6C7B05C929D987FE009ABC1A /* Images+TrendingRepositoriesFeature.swift */, 413 | ); 414 | path = TrendingRepositoriesFeature; 415 | sourceTree = ""; 416 | }; 417 | 6C3985182999C1AD00CD2B99 /* TradingRepositoriesList */ = { 418 | isa = PBXGroup; 419 | children = ( 420 | 6C3985192999C1AD00CD2B99 /* ViewModel */, 421 | 6C39851E2999C1AD00CD2B99 /* View */, 422 | ); 423 | path = TradingRepositoriesList; 424 | sourceTree = ""; 425 | }; 426 | 6C3985192999C1AD00CD2B99 /* ViewModel */ = { 427 | isa = PBXGroup; 428 | children = ( 429 | 6C39851A2999C1AD00CD2B99 /* TrendingRepositoriesDisplayModeltems */, 430 | 6C39851D2999C1AD00CD2B99 /* TrendingRepositoriesListViewModel.swift */, 431 | ); 432 | path = ViewModel; 433 | sourceTree = ""; 434 | }; 435 | 6C39851A2999C1AD00CD2B99 /* TrendingRepositoriesDisplayModeltems */ = { 436 | isa = PBXGroup; 437 | children = ( 438 | 6C39851B2999C1AD00CD2B99 /* TrendingRepositoriesListItemViewModel.swift */, 439 | 6C39851C2999C1AD00CD2B99 /* TrendingRepositoriesListContentViewModel.swift */, 440 | ); 441 | path = TrendingRepositoriesDisplayModeltems; 442 | sourceTree = ""; 443 | }; 444 | 6C39851E2999C1AD00CD2B99 /* View */ = { 445 | isa = PBXGroup; 446 | children = ( 447 | 6C3985252999C1AD00CD2B99 /* TrendingRepositoriesListViewController.swift */, 448 | 6C39851F2999C1AD00CD2B99 /* TrendingRepositoriesListTableView */, 449 | ); 450 | path = View; 451 | sourceTree = ""; 452 | }; 453 | 6C39851F2999C1AD00CD2B99 /* TrendingRepositoriesListTableView */ = { 454 | isa = PBXGroup; 455 | children = ( 456 | 6C3985202999C1AD00CD2B99 /* Cells */, 457 | 6C3985242999C1AD00CD2B99 /* TrendingRepositoriesListTableViewController.swift */, 458 | ); 459 | path = TrendingRepositoriesListTableView; 460 | sourceTree = ""; 461 | }; 462 | 6C3985202999C1AD00CD2B99 /* Cells */ = { 463 | isa = PBXGroup; 464 | children = ( 465 | 6C3985212999C1AD00CD2B99 /* TrendingRepositoryListItemCell.swift */, 466 | 6C3985232999C1AD00CD2B99 /* TrendingRepositoryListLoadingItemCell.swift */, 467 | 6C3985222999C1AD00CD2B99 /* TrendingRepositoryListEmptyItemCell.swift */, 468 | ); 469 | path = Cells; 470 | sourceTree = ""; 471 | }; 472 | 6C3985262999C1AD00CD2B99 /* Flows */ = { 473 | isa = PBXGroup; 474 | children = ( 475 | 6C3985272999C1AD00CD2B99 /* TrendingRepositoriesFlowCoordinator.swift */, 476 | ); 477 | path = Flows; 478 | sourceTree = ""; 479 | }; 480 | 6C3985282999C1AE00CD2B99 /* Utils */ = { 481 | isa = PBXGroup; 482 | children = ( 483 | 6C39852A2999C1AE00CD2B99 /* Extensions */, 484 | 6C39852E2999C1AE00CD2B99 /* Observable.swift */, 485 | 6C3985292999C1AE00CD2B99 /* SkeletonLoadable.swift */, 486 | ); 487 | path = Utils; 488 | sourceTree = ""; 489 | }; 490 | 6C39852A2999C1AE00CD2B99 /* Extensions */ = { 491 | isa = PBXGroup; 492 | children = ( 493 | 6CCA59FA299C2A7500894081 /* Error+UIError.swift */, 494 | 6C39852B2999C1AE00CD2B99 /* UIViewController+AddChild.swift */, 495 | 6C39852D2999C1AE00CD2B99 /* UITableView+DequeueReusableCell.swift */, 496 | ); 497 | path = Extensions; 498 | sourceTree = ""; 499 | }; 500 | 6C5472252996D2E000EE2F1F = { 501 | isa = PBXGroup; 502 | children = ( 503 | 6C5472302996D2E000EE2F1F /* SkillsTest */, 504 | 6C54724A2996D2E600EE2F1F /* SkillsTestTests */, 505 | 6C54722F2996D2E000EE2F1F /* Products */, 506 | ); 507 | sourceTree = ""; 508 | }; 509 | 6C54722F2996D2E000EE2F1F /* Products */ = { 510 | isa = PBXGroup; 511 | children = ( 512 | 6C54722E2996D2E000EE2F1F /* SkillsTest.app */, 513 | 6C5472472996D2E600EE2F1F /* SkillsTestTests.xctest */, 514 | ); 515 | name = Products; 516 | sourceTree = ""; 517 | }; 518 | 6C5472302996D2E000EE2F1F /* SkillsTest */ = { 519 | isa = PBXGroup; 520 | children = ( 521 | 6C3984E02999C1AD00CD2B99 /* Application */, 522 | 6C3985152999C1AD00CD2B99 /* Presentation */, 523 | 6C39850C2999C1AD00CD2B99 /* Domain */, 524 | 6C3984EE2999C1AD00CD2B99 /* Data */, 525 | 6C3984E82999C1AD00CD2B99 /* Infrastructure */, 526 | 6C3985092999C1AD00CD2B99 /* Common */, 527 | 6C54723D2996D2E400EE2F1F /* Assets.xcassets */, 528 | 6C54723F2996D2E400EE2F1F /* LaunchScreen.storyboard */, 529 | 6C5472422996D2E400EE2F1F /* Info.plist */, 530 | ); 531 | path = SkillsTest; 532 | sourceTree = ""; 533 | }; 534 | 6C54724A2996D2E600EE2F1F /* SkillsTestTests */ = { 535 | isa = PBXGroup; 536 | children = ( 537 | 6C1D573A2999C97000CB7185 /* Presentation */, 538 | 6C1D57332999C8EB00CB7185 /* Domain */, 539 | 6C1D57282999C81900CB7185 /* Infrastructure */, 540 | 6C1D57372999C93E00CB7185 /* Stubs */, 541 | ); 542 | path = SkillsTestTests; 543 | sourceTree = ""; 544 | }; 545 | 6C67BD6929D84A4F0071141A /* Request */ = { 546 | isa = PBXGroup; 547 | children = ( 548 | 6C67BD6A29D84A5B0071141A /* TrendingRepositoriesRequestQuery.swift */, 549 | 6C78883F29D8E03900D1D7F6 /* ImagesRequestQuery.swift */, 550 | ); 551 | path = Request; 552 | sourceTree = ""; 553 | }; 554 | 6C7B05CB29D9CD9D009ABC1A /* PersistentStorages */ = { 555 | isa = PBXGroup; 556 | children = ( 557 | 6C7BC80529D9FFF000145432 /* TrendingRepositoriesStorage */, 558 | 6C7B05D229D9CF2A009ABC1A /* ImagesStorage */, 559 | 6C7B05CC29D9CDC6009ABC1A /* CoreDataStorage */, 560 | ); 561 | path = PersistentStorages; 562 | sourceTree = ""; 563 | }; 564 | 6C7B05CC29D9CDC6009ABC1A /* CoreDataStorage */ = { 565 | isa = PBXGroup; 566 | children = ( 567 | 6C7B05CD29D9CDDA009ABC1A /* CoreDataStorage.swift */, 568 | 6C7B05CF29D9CE38009ABC1A /* CoreDataStorage.xcdatamodeld */, 569 | ); 570 | path = CoreDataStorage; 571 | sourceTree = ""; 572 | }; 573 | 6C7B05D229D9CF2A009ABC1A /* ImagesStorage */ = { 574 | isa = PBXGroup; 575 | children = ( 576 | 6C7B05D329D9CF5D009ABC1A /* ImagesStorage.swift */, 577 | 6C7B05D529D9CFC6009ABC1A /* CoreDataImagesStorage.swift */, 578 | ); 579 | path = ImagesStorage; 580 | sourceTree = ""; 581 | }; 582 | 6C7BC80529D9FFF000145432 /* TrendingRepositoriesStorage */ = { 583 | isa = PBXGroup; 584 | children = ( 585 | 6C7C43EB29DA015A00FFDC85 /* EntityMapping */, 586 | 6C7C43EE29DA018500FFDC85 /* TrendingRepositoriesStorage.swift */, 587 | 6C7C43EC29DA017A00FFDC85 /* CoreDataTrendingRepositoriesStorage.swift */, 588 | ); 589 | path = TrendingRepositoriesStorage; 590 | sourceTree = ""; 591 | }; 592 | 6C7C43EB29DA015A00FFDC85 /* EntityMapping */ = { 593 | isa = PBXGroup; 594 | children = ( 595 | 6C7C43F029DA02ED00FFDC85 /* OwnerEntity+Mapping.swift */, 596 | 6C7C43F229DA055100FFDC85 /* RepositoryEntity+Mapping.swift */, 597 | 6C7C43F429DA070300FFDC85 /* TrendingRepositoriesPageEntity+Mapping.swift */, 598 | ); 599 | path = EntityMapping; 600 | sourceTree = ""; 601 | }; 602 | 6C872C1F29A57E8A008C7593 /* Mocks */ = { 603 | isa = PBXGroup; 604 | children = ( 605 | 6C872C2029A57E98008C7593 /* DispatchQueueTypeMock.swift */, 606 | ); 607 | path = Mocks; 608 | sourceTree = ""; 609 | }; 610 | /* End PBXGroup section */ 611 | 612 | /* Begin PBXNativeTarget section */ 613 | 6C54722D2996D2E000EE2F1F /* SkillsTest */ = { 614 | isa = PBXNativeTarget; 615 | buildConfigurationList = 6C54725B2996D2E600EE2F1F /* Build configuration list for PBXNativeTarget "SkillsTest" */; 616 | buildPhases = ( 617 | 6C54722A2996D2E000EE2F1F /* Sources */, 618 | 6C54722B2996D2E000EE2F1F /* Frameworks */, 619 | 6C54722C2996D2E000EE2F1F /* Resources */, 620 | ); 621 | buildRules = ( 622 | ); 623 | dependencies = ( 624 | ); 625 | name = SkillsTest; 626 | productName = SkillsTest; 627 | productReference = 6C54722E2996D2E000EE2F1F /* SkillsTest.app */; 628 | productType = "com.apple.product-type.application"; 629 | }; 630 | 6C5472462996D2E600EE2F1F /* SkillsTestTests */ = { 631 | isa = PBXNativeTarget; 632 | buildConfigurationList = 6C54725E2996D2E600EE2F1F /* Build configuration list for PBXNativeTarget "SkillsTestTests" */; 633 | buildPhases = ( 634 | 6C5472432996D2E600EE2F1F /* Sources */, 635 | 6C5472442996D2E600EE2F1F /* Frameworks */, 636 | 6C5472452996D2E600EE2F1F /* Resources */, 637 | ); 638 | buildRules = ( 639 | ); 640 | dependencies = ( 641 | 6C5472492996D2E600EE2F1F /* PBXTargetDependency */, 642 | ); 643 | name = SkillsTestTests; 644 | productName = SkillsTestTests; 645 | productReference = 6C5472472996D2E600EE2F1F /* SkillsTestTests.xctest */; 646 | productType = "com.apple.product-type.bundle.unit-test"; 647 | }; 648 | /* End PBXNativeTarget section */ 649 | 650 | /* Begin PBXProject section */ 651 | 6C5472262996D2E000EE2F1F /* Project object */ = { 652 | isa = PBXProject; 653 | attributes = { 654 | BuildIndependentTargetsInParallel = 1; 655 | LastSwiftUpdateCheck = 1420; 656 | LastUpgradeCheck = 1420; 657 | TargetAttributes = { 658 | 6C54722D2996D2E000EE2F1F = { 659 | CreatedOnToolsVersion = 14.2; 660 | }; 661 | 6C5472462996D2E600EE2F1F = { 662 | CreatedOnToolsVersion = 14.2; 663 | TestTargetID = 6C54722D2996D2E000EE2F1F; 664 | }; 665 | }; 666 | }; 667 | buildConfigurationList = 6C5472292996D2E000EE2F1F /* Build configuration list for PBXProject "SkillsTest" */; 668 | compatibilityVersion = "Xcode 14.0"; 669 | developmentRegion = en; 670 | hasScannedForEncodings = 0; 671 | knownRegions = ( 672 | en, 673 | Base, 674 | ); 675 | mainGroup = 6C5472252996D2E000EE2F1F; 676 | productRefGroup = 6C54722F2996D2E000EE2F1F /* Products */; 677 | projectDirPath = ""; 678 | projectRoot = ""; 679 | targets = ( 680 | 6C54722D2996D2E000EE2F1F /* SkillsTest */, 681 | 6C5472462996D2E600EE2F1F /* SkillsTestTests */, 682 | ); 683 | }; 684 | /* End PBXProject section */ 685 | 686 | /* Begin PBXResourcesBuildPhase section */ 687 | 6C54722C2996D2E000EE2F1F /* Resources */ = { 688 | isa = PBXResourcesBuildPhase; 689 | buildActionMask = 2147483647; 690 | files = ( 691 | 6C5472412996D2E400EE2F1F /* LaunchScreen.storyboard in Resources */, 692 | 6C54723E2996D2E400EE2F1F /* Assets.xcassets in Resources */, 693 | ); 694 | runOnlyForDeploymentPostprocessing = 0; 695 | }; 696 | 6C5472452996D2E600EE2F1F /* Resources */ = { 697 | isa = PBXResourcesBuildPhase; 698 | buildActionMask = 2147483647; 699 | files = ( 700 | ); 701 | runOnlyForDeploymentPostprocessing = 0; 702 | }; 703 | /* End PBXResourcesBuildPhase section */ 704 | 705 | /* Begin PBXSourcesBuildPhase section */ 706 | 6C54722A2996D2E000EE2F1F /* Sources */ = { 707 | isa = PBXSourcesBuildPhase; 708 | buildActionMask = 2147483647; 709 | files = ( 710 | 6C39854E2999C1AE00CD2B99 /* TrendingRepositoriesListItemViewModel.swift in Sources */, 711 | 6C3985482999C1AE00CD2B99 /* Cancellable.swift in Sources */, 712 | 6C78884029D8E03900D1D7F6 /* ImagesRequestQuery.swift in Sources */, 713 | 6C3985392999C1AE00CD2B99 /* APIEndpoints.swift in Sources */, 714 | 6C67BD6D29D88FB10071141A /* DispatchQueueType.swift in Sources */, 715 | 6C39855B2999C1AE00CD2B99 /* Observable.swift in Sources */, 716 | 6C3985492999C1AE00CD2B99 /* FetchTrendingRepositoriesUseCase.swift in Sources */, 717 | 6C67BD6629D8445E0071141A /* TrendingRepositoriesPageDto.swift in Sources */, 718 | 6C3985542999C1AE00CD2B99 /* TrendingRepositoriesListTableViewController.swift in Sources */, 719 | 6CCA59FB299C2A7500894081 /* Error+UIError.swift in Sources */, 720 | 6C39854F2999C1AE00CD2B99 /* TrendingRepositoriesListContentViewModel.swift in Sources */, 721 | 6C39853F2999C1AE00CD2B99 /* DefaultTrendingRepositoriesRepository.swift in Sources */, 722 | 6C39854A2999C1AE00CD2B99 /* Owner.swift in Sources */, 723 | 6C3985572999C1AE00CD2B99 /* SkeletonLoadable.swift in Sources */, 724 | 6CA700A829D8B2AF006A97CC /* ImagesRepository.swift in Sources */, 725 | 6C3985382999C1AE00CD2B99 /* NetworkConfig.swift in Sources */, 726 | 6C3985532999C1AE00CD2B99 /* TrendingRepositoryListLoadingItemCell.swift in Sources */, 727 | 6C39854B2999C1AE00CD2B99 /* TrendingRepositoriesPage.swift in Sources */, 728 | 6C3985312999C1AE00CD2B99 /* AppDIContainer.swift in Sources */, 729 | 6C3985372999C1AE00CD2B99 /* Endpoint.swift in Sources */, 730 | 6C67BD6429D831750071141A /* Repository.swift in Sources */, 731 | 6C3985512999C1AE00CD2B99 /* TrendingRepositoryListItemCell.swift in Sources */, 732 | 6C39853C2999C1AE00CD2B99 /* Repository+Mapping.swift in Sources */, 733 | 6C3985562999C1AE00CD2B99 /* TrendingRepositoriesFlowCoordinator.swift in Sources */, 734 | 6C3985332999C1AE00CD2B99 /* AppDelegate.swift in Sources */, 735 | 6C7B05CE29D9CDDA009ABC1A /* CoreDataStorage.swift in Sources */, 736 | 6C39852F2999C1AE00CD2B99 /* AppFlowCoordinator.swift in Sources */, 737 | 6C7C43ED29DA017A00FFDC85 /* CoreDataTrendingRepositoriesStorage.swift in Sources */, 738 | 6C3985582999C1AE00CD2B99 /* UIViewController+AddChild.swift in Sources */, 739 | 6C7B05D129D9CE38009ABC1A /* CoreDataStorage.xcdatamodeld in Sources */, 740 | 6C39853B2999C1AE00CD2B99 /* OwnerDto.swift in Sources */, 741 | 6C7B05D429D9CF5D009ABC1A /* ImagesStorage.swift in Sources */, 742 | 6C3985342999C1AE00CD2B99 /* SceneDelegate.swift in Sources */, 743 | 6C39854C2999C1AE00CD2B99 /* TrendingRepositoriesRepository.swift in Sources */, 744 | 6C3985352999C1AE00CD2B99 /* DataTransferService.swift in Sources */, 745 | 6C7B05CA29D987FE009ABC1A /* Images+TrendingRepositoriesFeature.swift in Sources */, 746 | 6C39854D2999C1AE00CD2B99 /* Localizations+TrendingRepositoriesFeature.swift in Sources */, 747 | 6C3985502999C1AE00CD2B99 /* TrendingRepositoriesListViewModel.swift in Sources */, 748 | 6C3985302999C1AE00CD2B99 /* AppConfigurations.swift in Sources */, 749 | 6C39853D2999C1AE00CD2B99 /* RepositoryDto.swift in Sources */, 750 | 6C67BD6B29D84A5B0071141A /* TrendingRepositoriesRequestQuery.swift in Sources */, 751 | 6C7C43F529DA070300FFDC85 /* TrendingRepositoriesPageEntity+Mapping.swift in Sources */, 752 | 6C3985552999C1AE00CD2B99 /* TrendingRepositoriesListViewController.swift in Sources */, 753 | 6C7C43F329DA055100FFDC85 /* RepositoryEntity+Mapping.swift in Sources */, 754 | 6C3985322999C1AE00CD2B99 /* TrendingRepositoriesFeatureDIContainer.swift in Sources */, 755 | 6C3985522999C1AE00CD2B99 /* TrendingRepositoryListEmptyItemCell.swift in Sources */, 756 | 6C3985402999C1AE00CD2B99 /* RepositoryTask.swift in Sources */, 757 | 6C39853A2999C1AE00CD2B99 /* OwnerDto+Mapping.swift in Sources */, 758 | 6C7C43F129DA02ED00FFDC85 /* OwnerEntity+Mapping.swift in Sources */, 759 | 6C67BD6829D8446F0071141A /* TrendingRepositoriesPageDto+Mapping.swift in Sources */, 760 | 6C39855A2999C1AE00CD2B99 /* UITableView+DequeueReusableCell.swift in Sources */, 761 | 6C3985362999C1AE00CD2B99 /* NetworkService.swift in Sources */, 762 | 6C7C43EF29DA018500FFDC85 /* TrendingRepositoriesStorage.swift in Sources */, 763 | 6C7B05D629D9CFC6009ABC1A /* CoreDataImagesStorage.swift in Sources */, 764 | 6CA700AA29D8B2D9006A97CC /* DefaultImagesRepository.swift in Sources */, 765 | ); 766 | runOnlyForDeploymentPostprocessing = 0; 767 | }; 768 | 6C5472432996D2E600EE2F1F /* Sources */ = { 769 | isa = PBXSourcesBuildPhase; 770 | buildActionMask = 2147483647; 771 | files = ( 772 | 6C1D573D2999C97100CB7185 /* TrendingRepositoriesListViewModelTests.swift in Sources */, 773 | 6C1D572F2999C81A00CB7185 /* NetworkConfigurableMock.swift in Sources */, 774 | 6C1D57312999C81A00CB7185 /* NetworkServiceTests.swift in Sources */, 775 | 6C1D57392999C93E00CB7185 /* Repository+Stub.swift in Sources */, 776 | 6C872C2129A57E98008C7593 /* DispatchQueueTypeMock.swift in Sources */, 777 | 6C1D57362999C8EB00CB7185 /* FetchTrendingRepositoriesUseCaseTests.swift in Sources */, 778 | 6C1D57322999C81A00CB7185 /* DataTransferServiceTests.swift in Sources */, 779 | 6C1D57302999C81A00CB7185 /* NetworkSessionManagerMock.swift in Sources */, 780 | ); 781 | runOnlyForDeploymentPostprocessing = 0; 782 | }; 783 | /* End PBXSourcesBuildPhase section */ 784 | 785 | /* Begin PBXTargetDependency section */ 786 | 6C5472492996D2E600EE2F1F /* PBXTargetDependency */ = { 787 | isa = PBXTargetDependency; 788 | target = 6C54722D2996D2E000EE2F1F /* SkillsTest */; 789 | targetProxy = 6C5472482996D2E600EE2F1F /* PBXContainerItemProxy */; 790 | }; 791 | /* End PBXTargetDependency section */ 792 | 793 | /* Begin PBXVariantGroup section */ 794 | 6C54723F2996D2E400EE2F1F /* LaunchScreen.storyboard */ = { 795 | isa = PBXVariantGroup; 796 | children = ( 797 | 6C5472402996D2E400EE2F1F /* Base */, 798 | ); 799 | name = LaunchScreen.storyboard; 800 | sourceTree = ""; 801 | }; 802 | /* End PBXVariantGroup section */ 803 | 804 | /* Begin XCBuildConfiguration section */ 805 | 6C5472592996D2E600EE2F1F /* Debug */ = { 806 | isa = XCBuildConfiguration; 807 | buildSettings = { 808 | ALWAYS_SEARCH_USER_PATHS = NO; 809 | CLANG_ANALYZER_NONNULL = YES; 810 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 811 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 812 | CLANG_ENABLE_MODULES = YES; 813 | CLANG_ENABLE_OBJC_ARC = YES; 814 | CLANG_ENABLE_OBJC_WEAK = YES; 815 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 816 | CLANG_WARN_BOOL_CONVERSION = YES; 817 | CLANG_WARN_COMMA = YES; 818 | CLANG_WARN_CONSTANT_CONVERSION = YES; 819 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 820 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 821 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 822 | CLANG_WARN_EMPTY_BODY = YES; 823 | CLANG_WARN_ENUM_CONVERSION = YES; 824 | CLANG_WARN_INFINITE_RECURSION = YES; 825 | CLANG_WARN_INT_CONVERSION = YES; 826 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 827 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 828 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 829 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 830 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 831 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 832 | CLANG_WARN_STRICT_PROTOTYPES = YES; 833 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 834 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 835 | CLANG_WARN_UNREACHABLE_CODE = YES; 836 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 837 | COPY_PHASE_STRIP = NO; 838 | DEBUG_INFORMATION_FORMAT = dwarf; 839 | ENABLE_STRICT_OBJC_MSGSEND = YES; 840 | ENABLE_TESTABILITY = YES; 841 | GCC_C_LANGUAGE_STANDARD = gnu11; 842 | GCC_DYNAMIC_NO_PIC = NO; 843 | GCC_NO_COMMON_BLOCKS = YES; 844 | GCC_OPTIMIZATION_LEVEL = 0; 845 | GCC_PREPROCESSOR_DEFINITIONS = ( 846 | "DEBUG=1", 847 | "$(inherited)", 848 | ); 849 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 850 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 851 | GCC_WARN_UNDECLARED_SELECTOR = YES; 852 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 853 | GCC_WARN_UNUSED_FUNCTION = YES; 854 | GCC_WARN_UNUSED_VARIABLE = YES; 855 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 856 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 857 | MTL_FAST_MATH = YES; 858 | ONLY_ACTIVE_ARCH = YES; 859 | SDKROOT = iphoneos; 860 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 861 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 862 | }; 863 | name = Debug; 864 | }; 865 | 6C54725A2996D2E600EE2F1F /* Release */ = { 866 | isa = XCBuildConfiguration; 867 | buildSettings = { 868 | ALWAYS_SEARCH_USER_PATHS = NO; 869 | CLANG_ANALYZER_NONNULL = YES; 870 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 871 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 872 | CLANG_ENABLE_MODULES = YES; 873 | CLANG_ENABLE_OBJC_ARC = YES; 874 | CLANG_ENABLE_OBJC_WEAK = YES; 875 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 876 | CLANG_WARN_BOOL_CONVERSION = YES; 877 | CLANG_WARN_COMMA = YES; 878 | CLANG_WARN_CONSTANT_CONVERSION = YES; 879 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 880 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 881 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 882 | CLANG_WARN_EMPTY_BODY = YES; 883 | CLANG_WARN_ENUM_CONVERSION = YES; 884 | CLANG_WARN_INFINITE_RECURSION = YES; 885 | CLANG_WARN_INT_CONVERSION = YES; 886 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 887 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 888 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 889 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 890 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 891 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 892 | CLANG_WARN_STRICT_PROTOTYPES = YES; 893 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 894 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 895 | CLANG_WARN_UNREACHABLE_CODE = YES; 896 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 897 | COPY_PHASE_STRIP = NO; 898 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 899 | ENABLE_NS_ASSERTIONS = NO; 900 | ENABLE_STRICT_OBJC_MSGSEND = YES; 901 | GCC_C_LANGUAGE_STANDARD = gnu11; 902 | GCC_NO_COMMON_BLOCKS = YES; 903 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 904 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 905 | GCC_WARN_UNDECLARED_SELECTOR = YES; 906 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 907 | GCC_WARN_UNUSED_FUNCTION = YES; 908 | GCC_WARN_UNUSED_VARIABLE = YES; 909 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 910 | MTL_ENABLE_DEBUG_INFO = NO; 911 | MTL_FAST_MATH = YES; 912 | SDKROOT = iphoneos; 913 | SWIFT_COMPILATION_MODE = wholemodule; 914 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 915 | VALIDATE_PRODUCT = YES; 916 | }; 917 | name = Release; 918 | }; 919 | 6C54725C2996D2E600EE2F1F /* Debug */ = { 920 | isa = XCBuildConfiguration; 921 | buildSettings = { 922 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 923 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 924 | CODE_SIGN_STYLE = Automatic; 925 | CURRENT_PROJECT_VERSION = 1; 926 | GENERATE_INFOPLIST_FILE = YES; 927 | INFOPLIST_FILE = SkillsTest/Info.plist; 928 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 929 | INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; 930 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 931 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 932 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 933 | LD_RUNPATH_SEARCH_PATHS = ( 934 | "$(inherited)", 935 | "@executable_path/Frameworks", 936 | ); 937 | MARKETING_VERSION = 1.0; 938 | PRODUCT_BUNDLE_IDENTIFIER = com.skills.SkillsTest; 939 | PRODUCT_NAME = "$(TARGET_NAME)"; 940 | SWIFT_EMIT_LOC_STRINGS = YES; 941 | SWIFT_VERSION = 5.0; 942 | TARGETED_DEVICE_FAMILY = "1,2"; 943 | }; 944 | name = Debug; 945 | }; 946 | 6C54725D2996D2E600EE2F1F /* Release */ = { 947 | isa = XCBuildConfiguration; 948 | buildSettings = { 949 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 950 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 951 | CODE_SIGN_STYLE = Automatic; 952 | CURRENT_PROJECT_VERSION = 1; 953 | GENERATE_INFOPLIST_FILE = YES; 954 | INFOPLIST_FILE = SkillsTest/Info.plist; 955 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 956 | INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; 957 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 958 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 959 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 960 | LD_RUNPATH_SEARCH_PATHS = ( 961 | "$(inherited)", 962 | "@executable_path/Frameworks", 963 | ); 964 | MARKETING_VERSION = 1.0; 965 | PRODUCT_BUNDLE_IDENTIFIER = com.skills.SkillsTest; 966 | PRODUCT_NAME = "$(TARGET_NAME)"; 967 | SWIFT_EMIT_LOC_STRINGS = YES; 968 | SWIFT_VERSION = 5.0; 969 | TARGETED_DEVICE_FAMILY = "1,2"; 970 | }; 971 | name = Release; 972 | }; 973 | 6C54725F2996D2E600EE2F1F /* Debug */ = { 974 | isa = XCBuildConfiguration; 975 | buildSettings = { 976 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 977 | BUNDLE_LOADER = "$(TEST_HOST)"; 978 | CODE_SIGN_STYLE = Automatic; 979 | CURRENT_PROJECT_VERSION = 1; 980 | GENERATE_INFOPLIST_FILE = YES; 981 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 982 | MARKETING_VERSION = 1.0; 983 | PRODUCT_BUNDLE_IDENTIFIER = com.skills.SkillsTestTests; 984 | PRODUCT_NAME = "$(TARGET_NAME)"; 985 | SWIFT_EMIT_LOC_STRINGS = NO; 986 | SWIFT_VERSION = 5.0; 987 | TARGETED_DEVICE_FAMILY = "1,2"; 988 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SkillsTest.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SkillsTest"; 989 | }; 990 | name = Debug; 991 | }; 992 | 6C5472602996D2E600EE2F1F /* Release */ = { 993 | isa = XCBuildConfiguration; 994 | buildSettings = { 995 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 996 | BUNDLE_LOADER = "$(TEST_HOST)"; 997 | CODE_SIGN_STYLE = Automatic; 998 | CURRENT_PROJECT_VERSION = 1; 999 | GENERATE_INFOPLIST_FILE = YES; 1000 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 1001 | MARKETING_VERSION = 1.0; 1002 | PRODUCT_BUNDLE_IDENTIFIER = com.skills.SkillsTestTests; 1003 | PRODUCT_NAME = "$(TARGET_NAME)"; 1004 | SWIFT_EMIT_LOC_STRINGS = NO; 1005 | SWIFT_VERSION = 5.0; 1006 | TARGETED_DEVICE_FAMILY = "1,2"; 1007 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SkillsTest.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SkillsTest"; 1008 | }; 1009 | name = Release; 1010 | }; 1011 | /* End XCBuildConfiguration section */ 1012 | 1013 | /* Begin XCConfigurationList section */ 1014 | 6C5472292996D2E000EE2F1F /* Build configuration list for PBXProject "SkillsTest" */ = { 1015 | isa = XCConfigurationList; 1016 | buildConfigurations = ( 1017 | 6C5472592996D2E600EE2F1F /* Debug */, 1018 | 6C54725A2996D2E600EE2F1F /* Release */, 1019 | ); 1020 | defaultConfigurationIsVisible = 0; 1021 | defaultConfigurationName = Release; 1022 | }; 1023 | 6C54725B2996D2E600EE2F1F /* Build configuration list for PBXNativeTarget "SkillsTest" */ = { 1024 | isa = XCConfigurationList; 1025 | buildConfigurations = ( 1026 | 6C54725C2996D2E600EE2F1F /* Debug */, 1027 | 6C54725D2996D2E600EE2F1F /* Release */, 1028 | ); 1029 | defaultConfigurationIsVisible = 0; 1030 | defaultConfigurationName = Release; 1031 | }; 1032 | 6C54725E2996D2E600EE2F1F /* Build configuration list for PBXNativeTarget "SkillsTestTests" */ = { 1033 | isa = XCConfigurationList; 1034 | buildConfigurations = ( 1035 | 6C54725F2996D2E600EE2F1F /* Debug */, 1036 | 6C5472602996D2E600EE2F1F /* Release */, 1037 | ); 1038 | defaultConfigurationIsVisible = 0; 1039 | defaultConfigurationName = Release; 1040 | }; 1041 | /* End XCConfigurationList section */ 1042 | 1043 | /* Begin XCVersionGroup section */ 1044 | 6C7B05CF29D9CE38009ABC1A /* CoreDataStorage.xcdatamodeld */ = { 1045 | isa = XCVersionGroup; 1046 | children = ( 1047 | 6C7B05D029D9CE38009ABC1A /* CoreDataStorage.xcdatamodel */, 1048 | ); 1049 | currentVersion = 6C7B05D029D9CE38009ABC1A /* CoreDataStorage.xcdatamodel */; 1050 | path = CoreDataStorage.xcdatamodeld; 1051 | sourceTree = ""; 1052 | versionGroupType = wrapper.xcdatamodel; 1053 | }; 1054 | /* End XCVersionGroup section */ 1055 | }; 1056 | rootObject = 6C5472262996D2E000EE2F1F /* Project object */; 1057 | } 1058 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest.xcodeproj/xcshareddata/xcschemes/SkillsTest.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 34 | 40 | 41 | 42 | 43 | 44 | 54 | 56 | 62 | 63 | 64 | 65 | 71 | 73 | 79 | 80 | 81 | 82 | 84 | 85 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Application/AppConfigurations.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class AppConfiguration { 4 | lazy var apiBaseURL: URL = URL(string: "https://api.github.com")! 5 | 6 | /// Ideally this parameter should be moved into remote config, or receive it in keep alive cache police in header 7 | let trendingRepositoriesCacheMaxAliveTimeInSeconds: TimeInterval = 24 * 60 * 60 8 | 9 | /// if false it does not print logs if true it prints logs in debug mode only 10 | let shouldLogNetworkRequests = false 11 | } 12 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Application/AppDIContainer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class AppDIContainer { 4 | 5 | lazy var appConfiguration = AppConfiguration() 6 | 7 | // MARK: - Network 8 | lazy var apiDataTransferService: DataTransferService = { 9 | let config = ApiDataNetworkConfig( 10 | baseURL: appConfiguration.apiBaseURL 11 | ) 12 | 13 | let apiDataNetwork = DefaultNetworkService( 14 | config: config, 15 | logger: DefaultNetworkLogger( 16 | shouldLogRequests: appConfiguration.shouldLogNetworkRequests 17 | ) 18 | ) 19 | return DefaultDataTransferService( 20 | with: apiDataNetwork 21 | ) 22 | }() 23 | 24 | lazy var imagesDataTransferService: DataTransferService = { 25 | let imagesDataNetwork = DefaultNetworkService( 26 | logger: DefaultNetworkLogger( 27 | shouldLogRequests: appConfiguration.shouldLogNetworkRequests 28 | ) 29 | ) 30 | return DefaultDataTransferService(with: imagesDataNetwork) 31 | }() 32 | 33 | // MARK: - DIContainers of features 34 | func makeTrendingRepositoriesFeatureDIContainer() -> TrendingRepositoriesFeatureDIContainer { 35 | let dependencies = TrendingRepositoriesFeatureDIContainer.Dependencies( 36 | apiDataTransferService: apiDataTransferService, 37 | imagesDataTransferService: imagesDataTransferService, 38 | appConfiguration: appConfiguration 39 | ) 40 | return TrendingRepositoriesFeatureDIContainer(dependencies: dependencies) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Application/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import CoreData 3 | 4 | @main 5 | class AppDelegate: UIResponder, UIApplicationDelegate { 6 | 7 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 8 | // Override point for customization after application launch. 9 | return true 10 | } 11 | 12 | // MARK: UISceneSession Lifecycle 13 | 14 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 15 | // Called when a new scene session is being created. 16 | // Use this method to select a configuration to create the new scene with. 17 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Application/AppFlowCoordinator.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class AppFlowCoordinator { 4 | 5 | var navigationController: UINavigationController 6 | private let appDIContainer: AppDIContainer 7 | 8 | init( 9 | navigationController: UINavigationController, 10 | appDIContainer: AppDIContainer 11 | ) { 12 | self.navigationController = navigationController 13 | self.appDIContainer = appDIContainer 14 | } 15 | 16 | func start() { 17 | let trendingTrendingRepositoriesFeatureDIContainer = appDIContainer.makeTrendingRepositoriesFeatureDIContainer() 18 | let flow = trendingTrendingRepositoriesFeatureDIContainer.makeTrendingRepositoriesFlowCoordinator( 19 | navigationController: navigationController 20 | ) 21 | flow.start() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Application/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 4 | 5 | let appDIContainer = AppDIContainer() 6 | var appFlowCoordinator: AppFlowCoordinator? 7 | var window: UIWindow? 8 | 9 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 10 | 11 | guard let windowScene = (scene as? UIWindowScene) else { return } 12 | 13 | let window = UIWindow(windowScene: windowScene) 14 | 15 | let navigationController = UINavigationController() 16 | 17 | window.rootViewController = navigationController 18 | appFlowCoordinator = AppFlowCoordinator( 19 | navigationController: navigationController, 20 | appDIContainer: appDIContainer 21 | ) 22 | appFlowCoordinator?.start() 23 | 24 | self.window = window 25 | window.makeKeyAndVisible() 26 | } 27 | 28 | func sceneDidEnterBackground(_ scene: UIScene) { 29 | CoreDataStorage.shared.saveContext() 30 | CoreDataImagesStorage.removeLeastRecentlyUsedImages() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/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 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Assets.xcassets/star_icon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "baseline_star_black_18pt_2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "baseline_star_black_18pt_3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Assets.xcassets/star_icon.imageset/baseline_star_black_18pt_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kudoleh/iOS-Clean-Architecture-MVVM-Views-In-Code/f80dfe41575de18ec1429e5f09c38d1fbf1d793f/SkillsTest/SkillsTest/Assets.xcassets/star_icon.imageset/baseline_star_black_18pt_2x.png -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Assets.xcassets/star_icon.imageset/baseline_star_black_18pt_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kudoleh/iOS-Clean-Architecture-MVVM-Views-In-Code/f80dfe41575de18ec1429e5f09c38d1fbf1d793f/SkillsTest/SkillsTest/Assets.xcassets/star_icon.imageset/baseline_star_black_18pt_3x.png -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/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 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Common/Cancellable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol Cancellable { 4 | func cancel() 5 | } 6 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Common/DispatchQueueType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol DispatchQueueType { 4 | func async(execute work: @escaping () -> Void) 5 | } 6 | 7 | extension DispatchQueue: DispatchQueueType { 8 | func async(execute work: @escaping () -> Void) { 9 | async(group: nil, execute: work) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Data/Network/APIEndpoints.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct APIEndpoints { 4 | 5 | static func getTrendingRepositories( 6 | requestQuery: TrendingRepositoriesRequestQuery 7 | ) -> Endpoint { 8 | 9 | let responseDecoder = JSONResponseDecoder() 10 | responseDecoder.jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase 11 | return Endpoint( 12 | path: "search/repositories", 13 | method: .get, 14 | queryParametersEncodable: requestQuery, 15 | responseDecoder: responseDecoder 16 | ) 17 | } 18 | 19 | static func getImage( 20 | path: String, 21 | requestQuery: ImagesRequestQuery 22 | ) -> Endpoint { 23 | 24 | Endpoint( 25 | path: path, 26 | method: .get, 27 | queryParametersEncodable: requestQuery, 28 | responseDecoder: RawDataResponseDecoder() 29 | ) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Data/Network/DataMapping/Repositories/Request/ImagesRequestQuery.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct ImagesRequestQuery: Encodable { 4 | 5 | private enum CodingKeys : String, CodingKey { 6 | case size = "s" 7 | } 8 | 9 | let size: Int 10 | } 11 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Data/Network/DataMapping/Repositories/Request/TrendingRepositoriesRequestQuery.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct TrendingRepositoriesRequestQuery: Encodable { 4 | 5 | private enum CodingKeys : String, CodingKey { 6 | case query = "q" 7 | } 8 | 9 | let query = "language=+sort:stars" 10 | } 11 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Data/Network/DataMapping/Repositories/Response/OwnerDto+Mapping.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension OwnerDto { 4 | func toDomain() -> Owner { 5 | return .init( 6 | id: id, 7 | name: login, 8 | avatarUrl: avatarUrl 9 | ) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Data/Network/DataMapping/Repositories/Response/OwnerDto.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct OwnerDto: Decodable { 4 | let id: Int 5 | let login: String 6 | let avatarUrl: String 7 | } 8 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Data/Network/DataMapping/Repositories/Response/Repository+Mapping.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension RepositoryDto { 4 | func toDomain() -> Repository { 5 | .init( 6 | id: id, 7 | owner: owner.toDomain(), 8 | name: name, 9 | description: description, 10 | stargazersCount: stargazersCount, 11 | language: language 12 | ) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Data/Network/DataMapping/Repositories/Response/RepositoryDto.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct RepositoryDto: Decodable { 4 | let id: Int 5 | let owner: OwnerDto 6 | let name: String 7 | let description: String 8 | let stargazersCount: Int 9 | let language: String? 10 | } 11 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Data/Network/DataMapping/Repositories/Response/TrendingRepositoriesPageDto+Mapping.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension TrendingRepositoriesPageDto { 4 | func toDomain() -> TrendingRepositoriesPage { 5 | .init( 6 | items: items.map { $0.toDomain() } 7 | ) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Data/Network/DataMapping/Repositories/Response/TrendingRepositoriesPageDto.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct TrendingRepositoriesPageDto: Decodable { 4 | let items: [RepositoryDto] 5 | } 6 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Data/PersistentStorages/CoreDataStorage/CoreDataStorage.swift: -------------------------------------------------------------------------------- 1 | import CoreData 2 | 3 | enum CoreDataStorageError: Error { 4 | case readError(Error) 5 | case saveError(Error) 6 | case deleteError(Error) 7 | } 8 | 9 | final class CoreDataStorage { 10 | 11 | static let shared = CoreDataStorage() 12 | 13 | // MARK: - Core Data stack 14 | private lazy var persistentContainer: NSPersistentContainer = { 15 | let container = NSPersistentContainer(name: "CoreDataStorage") 16 | container.loadPersistentStores { _, error in 17 | if let error = error as NSError? { 18 | assertionFailure("CoreDataStorage Unresolved error \(error), \(error.userInfo)") 19 | } 20 | } 21 | return container 22 | }() 23 | 24 | // MARK: - Core Data Saving support 25 | func saveContext() { 26 | let context = persistentContainer.viewContext 27 | if context.hasChanges { 28 | do { 29 | try context.save() 30 | } catch { 31 | assertionFailure("CoreDataStorage Unresolved error \(error), \((error as NSError).userInfo)") 32 | } 33 | } 34 | } 35 | 36 | func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void) { 37 | persistentContainer.performBackgroundTask(block) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Data/PersistentStorages/CoreDataStorage/CoreDataStorage.xcdatamodeld/CoreDataStorage.xcdatamodel/contents: -------------------------------------------------------------------------------- 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 | 27 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Data/PersistentStorages/ImagesStorage/CoreDataImagesStorage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreData 3 | 4 | final class CoreDataImagesStorage { 5 | 6 | /// Using LRU logic 7 | static let maxSizeInBytes = 200 * 1000000 // 200 MB 8 | 9 | private let coreDataStorage: CoreDataStorage 10 | private let currentTime: () -> Date 11 | 12 | init( 13 | coreDataStorage: CoreDataStorage = .shared, 14 | currentTime: @escaping () -> Date 15 | ) { 16 | self.coreDataStorage = coreDataStorage 17 | self.currentTime = currentTime 18 | } 19 | 20 | // MARK: - Private 21 | 22 | private func fetchRequest( 23 | for pathUrl: String 24 | ) -> NSFetchRequest { 25 | 26 | let request: NSFetchRequest = ImageEntity.fetchRequest() 27 | request.predicate = NSPredicate( 28 | format: "%K = %@", 29 | #keyPath(ImageEntity.pathUrl), pathUrl 30 | ) 31 | return request 32 | } 33 | 34 | private func deleteImage( 35 | for urlPath: String, 36 | in context: NSManagedObjectContext 37 | ) { 38 | let request = fetchRequest(for: urlPath) 39 | 40 | do { 41 | if let result = try context.fetch(request).first { 42 | context.delete(result) 43 | } 44 | } catch { 45 | print(error) 46 | } 47 | } 48 | 49 | static func removeLeastRecentlyUsedImages() { 50 | CoreDataStorage.shared.performBackgroundTask { context in 51 | do { 52 | let fetchRequest: NSFetchRequest = ImageEntity.fetchRequest() 53 | 54 | let sort = NSSortDescriptor( 55 | key: #keyPath(ImageEntity.lastUsedAt), 56 | ascending: false 57 | ) 58 | fetchRequest.sortDescriptors = [sort] 59 | 60 | let entities = try context.fetch(fetchRequest) 61 | 62 | var totalSizeUsed = 0 63 | entities.forEach { entity in 64 | totalSizeUsed += (entity.data as? NSData)?.length ?? 0 65 | print(totalSizeUsed) 66 | if totalSizeUsed > self.maxSizeInBytes { 67 | context.delete(entity) 68 | } 69 | } 70 | 71 | try context.save() 72 | } catch { 73 | assertionFailure("CoreDataStorage Unresolved error \(error), \((error as NSError).userInfo)") 74 | } 75 | } 76 | } 77 | } 78 | 79 | extension CoreDataImagesStorage: ImagesStorage { 80 | 81 | func getImageData( 82 | for urlPath: String, 83 | completion: @escaping (Result) -> Void 84 | ) { 85 | coreDataStorage.performBackgroundTask { context in 86 | do { 87 | let fetchRequest = self.fetchRequest(for: urlPath) 88 | let entity = try context.fetch(fetchRequest).first 89 | 90 | entity?.lastUsedAt = self.currentTime() 91 | 92 | try context.save() 93 | 94 | completion(.success(entity?.data)) 95 | } catch { 96 | completion(.failure(CoreDataStorageError.readError(error))) 97 | } 98 | } 99 | } 100 | 101 | func save(imageData: Data, for urlPath: String) { 102 | coreDataStorage.performBackgroundTask { context in 103 | do { 104 | self.deleteImage(for: urlPath, in: context) 105 | 106 | let entity: ImageEntity = .init(context: context) 107 | entity.data = imageData 108 | entity.pathUrl = urlPath 109 | entity.lastUsedAt = self.currentTime() 110 | 111 | try context.save() 112 | } catch { 113 | assertionFailure("CoreDataUsersStorage Unresolved error \(error), \((error as NSError).userInfo)") 114 | } 115 | } 116 | } 117 | } 118 | 119 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Data/PersistentStorages/ImagesStorage/ImagesStorage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol ImagesStorage { 4 | func getImageData( 5 | for urlPath: String, 6 | completion: @escaping (Result) -> Void 7 | ) 8 | func save(imageData: Data, for urlPath: String) 9 | } 10 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Data/PersistentStorages/TrendingRepositoriesStorage/CoreDataTrendingRepositoriesStorage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreData 3 | 4 | final class CoreDataTrendingRepositoriesStorage { 5 | 6 | struct Config { 7 | let maxAliveTimeInSeconds: TimeInterval 8 | } 9 | 10 | private let coreDataStorage: CoreDataStorage 11 | private let currentTime: () -> Date 12 | private let config: Config 13 | 14 | init( 15 | coreDataStorage: CoreDataStorage = .shared, 16 | currentTime: @escaping () -> Date, 17 | config: Config 18 | ) { 19 | self.coreDataStorage = coreDataStorage 20 | self.currentTime = currentTime 21 | self.config = config 22 | } 23 | 24 | // MARK: - Private 25 | 26 | private func fetchTrendingRepositoriesPageRequest() -> NSFetchRequest { 27 | 28 | let request: NSFetchRequest = TrendingRepositoriesPageEntity.fetchRequest() 29 | 30 | return request 31 | } 32 | 33 | private func deleteTrendingRepositoriesPageDto( 34 | in context: NSManagedObjectContext 35 | ) { 36 | let request = fetchTrendingRepositoriesPageRequest() 37 | 38 | do { 39 | if let result = try context.fetch(request).first { 40 | context.delete(result) 41 | } 42 | } catch { 43 | print(error) 44 | } 45 | } 46 | } 47 | 48 | extension CoreDataTrendingRepositoriesStorage: TrendingRepositoriesStorage { 49 | 50 | func getTrendingRepositoriesPageDto( 51 | completion: @escaping (Result) -> Void 52 | ) { 53 | coreDataStorage.performBackgroundTask { context in 54 | do { 55 | let fetchRequest: NSFetchRequest = self.fetchTrendingRepositoriesPageRequest() 56 | let entity = try context.fetch(fetchRequest).first 57 | let entityDto = try entity?.toDto() 58 | 59 | if let entity, let savedAt = entity.savedAt, 60 | self.currentTime().timeIntervalSince(savedAt) > self.config.maxAliveTimeInSeconds { 61 | completion(.success(entityDto.map { .outdated($0) })) 62 | } else { 63 | completion(.success(entityDto.map { .upToDate($0) })) 64 | } 65 | 66 | } catch { 67 | completion(.failure(CoreDataStorageError.readError(error))) 68 | } 69 | } 70 | } 71 | 72 | func save(trendingRepositoriesPageDto: TrendingRepositoriesPageDto) { 73 | coreDataStorage.performBackgroundTask { context in 74 | do { 75 | self.deleteTrendingRepositoriesPageDto(in: context) 76 | 77 | let entity = trendingRepositoriesPageDto.toEntity(in: context) 78 | entity.savedAt = self.currentTime() 79 | 80 | try context.save() 81 | } catch { 82 | assertionFailure("CoreDataUsersStorage Unresolved error \(error), \((error as NSError).userInfo)") 83 | } 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Data/PersistentStorages/TrendingRepositoriesStorage/EntityMapping/OwnerEntity+Mapping.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreData 3 | 4 | enum MappingOwnerEntityError: Error { 5 | case missingData 6 | } 7 | 8 | // MARK: - Mapping To Dto 9 | 10 | extension OwnerEntity { 11 | func toDto() throws -> OwnerDto { 12 | guard let login = login, let avatarUrl = avatarUrl else { 13 | throw MappingOwnerEntityError.missingData 14 | } 15 | return .init( 16 | id: Int(id), 17 | login: login, 18 | avatarUrl: avatarUrl 19 | ) 20 | } 21 | } 22 | 23 | // MARK: - Mapping To CoreData Entity 24 | 25 | extension OwnerDto { 26 | func toEntity(in context: NSManagedObjectContext) -> OwnerEntity { 27 | let entity: OwnerEntity = .init(context: context) 28 | entity.id = Int64(id) 29 | entity.login = login 30 | entity.avatarUrl = avatarUrl 31 | 32 | return entity 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Data/PersistentStorages/TrendingRepositoriesStorage/EntityMapping/RepositoryEntity+Mapping.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreData 3 | 4 | enum MappingRepositoryEntityError: Error { 5 | case missingOwnerData 6 | case missingData 7 | } 8 | 9 | // MARK: - Mapping To Dto 10 | 11 | extension RepositoryEntity { 12 | func toDto() throws -> RepositoryDto { 13 | guard let ownerDto = try owner?.toDto() else { 14 | throw MappingRepositoryEntityError.missingOwnerData 15 | } 16 | guard let name, let description = descriptionText 17 | else { 18 | throw MappingRepositoryEntityError.missingData 19 | } 20 | return .init( 21 | id: Int(id), 22 | owner: ownerDto, 23 | name: name, 24 | description: description, 25 | stargazersCount: Int(stargazersCount), 26 | language: language 27 | ) 28 | } 29 | } 30 | 31 | // MARK: - Mapping To CoreData Entity 32 | 33 | extension RepositoryDto { 34 | func toEntity(in context: NSManagedObjectContext) -> RepositoryEntity { 35 | 36 | let entity: RepositoryEntity = .init(context: context) 37 | entity.id = Int64(id) 38 | entity.owner = owner.toEntity(in: context) 39 | entity.name = name 40 | entity.descriptionText = description 41 | entity.stargazersCount = Int64(stargazersCount) 42 | entity.language = language 43 | 44 | return entity 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Data/PersistentStorages/TrendingRepositoriesStorage/EntityMapping/TrendingRepositoriesPageEntity+Mapping.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreData 3 | 4 | enum MappingUsersPageEntityError: Error { 5 | case incorrectRepositoryEntity 6 | } 7 | 8 | // MARK: - Mapping To Dto 9 | 10 | extension TrendingRepositoriesPageEntity { 11 | func toDto() throws -> TrendingRepositoriesPageDto { 12 | return .init( 13 | items: try items? 14 | .array 15 | .map { 16 | guard let entity = $0 as? RepositoryEntity else { 17 | throw MappingUsersPageEntityError.incorrectRepositoryEntity 18 | } 19 | return try entity.toDto() 20 | } ?? [] 21 | ) 22 | } 23 | } 24 | 25 | // MARK: - Mapping To CoreData Entity 26 | 27 | extension TrendingRepositoriesPageDto { 28 | func toEntity( 29 | in context: NSManagedObjectContext 30 | ) -> TrendingRepositoriesPageEntity { 31 | let entity: TrendingRepositoriesPageEntity = .init(context: context) 32 | entity.items = NSOrderedSet( 33 | array: items.map { $0.toEntity(in: context) } 34 | ) 35 | return entity 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Data/PersistentStorages/TrendingRepositoriesStorage/TrendingRepositoriesStorage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum TrendingRepositoriesStorageItem { 4 | case upToDate(TrendingRepositoriesPageDto) 5 | case outdated(TrendingRepositoriesPageDto) 6 | } 7 | 8 | protocol TrendingRepositoriesStorage { 9 | func getTrendingRepositoriesPageDto( 10 | completion: @escaping (Result) -> Void 11 | ) 12 | func save(trendingRepositoriesPageDto: TrendingRepositoriesPageDto) 13 | } 14 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Data/Repositories/DefaultImagesRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum ImagesRepositoryError: Error { 4 | case invalidPathUrl 5 | } 6 | 7 | final class DefaultImagesRepository { 8 | 9 | private let dataTransferService: DataTransferService 10 | private let imagesCache: ImagesStorage 11 | private let backgroundQueue: DataTransferDispatchQueue 12 | 13 | init( 14 | dataTransferService: DataTransferService, 15 | imagesCache: ImagesStorage, 16 | backgroundQueue: DataTransferDispatchQueue = DispatchQueue.global(qos: .userInitiated) 17 | ) { 18 | self.dataTransferService = dataTransferService 19 | self.imagesCache = imagesCache 20 | self.backgroundQueue = backgroundQueue 21 | } 22 | } 23 | 24 | extension DefaultImagesRepository: ImagesRepository { 25 | 26 | func fetchImage( 27 | with imagePath: String, 28 | size: Int, 29 | completion: @escaping (Result) -> Void 30 | ) -> Cancellable? { 31 | 32 | let task = RepositoryTask() 33 | 34 | let endpoint = APIEndpoints.getImage( 35 | path: imagePath, 36 | requestQuery: ImagesRequestQuery(size: size) 37 | ) 38 | 39 | guard let pathUrlWithPath = try? dataTransferService.url(for: endpoint).absoluteString else { 40 | completion(.failure(ImagesRepositoryError.invalidPathUrl)) 41 | return task 42 | } 43 | 44 | imagesCache.getImageData( 45 | for: pathUrlWithPath 46 | ) { [dataTransferService, backgroundQueue, imagesCache] result in 47 | 48 | if case let .success(imageData?) = result { 49 | completion(.success(imageData)) 50 | return 51 | } 52 | guard !task.isCancelled else { return } 53 | 54 | task.networkTask = dataTransferService.request( 55 | with: endpoint, 56 | on: backgroundQueue 57 | ) { [imagesCache] (result: Result) in 58 | if case let .success(imageData) = result { 59 | imagesCache.save(imageData: imageData, for: pathUrlWithPath) 60 | } 61 | let result = result.mapError { $0 as Error } 62 | completion(result) 63 | } 64 | } 65 | return task 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Data/Repositories/DefaultTrendingRepositoriesRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class DefaultTrendingRepositoriesRepository { 4 | 5 | private let dataTransferService: DataTransferService 6 | private let cache: TrendingRepositoriesStorage 7 | 8 | init( 9 | dataTransferService: DataTransferService, 10 | cache: TrendingRepositoriesStorage 11 | ) { 12 | self.dataTransferService = dataTransferService 13 | self.cache = cache 14 | } 15 | } 16 | 17 | extension DefaultTrendingRepositoriesRepository: TrendingRepositoriesRepository { 18 | 19 | func fetchTrendingRepositoriesList( 20 | cached: @escaping (TrendingRepositoriesPage) -> Void, 21 | completion: @escaping (Result) -> Void 22 | ) -> Cancellable? { 23 | 24 | let task = RepositoryTask() 25 | 26 | cache.getTrendingRepositoriesPageDto { [cache, dataTransferService] cacheResult in 27 | 28 | let shouldFetchData: Bool 29 | if case let .success(value) = cacheResult { 30 | switch value { 31 | case .upToDate(let pageDto): 32 | cached(pageDto.toDomain()) 33 | completion(.success(pageDto.toDomain())) 34 | shouldFetchData = false 35 | case .outdated(let pageDto): 36 | cached(pageDto.toDomain()) 37 | shouldFetchData = true 38 | case .none: 39 | shouldFetchData = true 40 | } 41 | } else { 42 | shouldFetchData = true 43 | } 44 | 45 | /// fetch new data if cache is outdated or nil 46 | guard shouldFetchData, 47 | !task.isCancelled else { return } 48 | 49 | let endpoint = APIEndpoints.getTrendingRepositories( 50 | requestQuery: TrendingRepositoriesRequestQuery() 51 | ) 52 | task.networkTask = dataTransferService.request( 53 | with: endpoint 54 | ) { result in 55 | switch result { 56 | case .success(let trendingRepositoriesPageDto): 57 | cache.save(trendingRepositoriesPageDto: trendingRepositoriesPageDto) 58 | let page = trendingRepositoriesPageDto.toDomain() 59 | completion(.success(page)) 60 | case .failure(let error): 61 | completion(.failure(error)) 62 | } 63 | } 64 | } 65 | 66 | return task 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Data/Repositories/Utils/RepositoryTask.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class RepositoryTask: Cancellable { 4 | var networkTask: NetworkCancellable? 5 | var isCancelled: Bool = false 6 | 7 | func cancel() { 8 | networkTask?.cancel() 9 | isCancelled = true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Domain/Entities/Owner.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Owner: Equatable { 4 | let id: Int 5 | let name: String 6 | let avatarUrl: String 7 | } 8 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Domain/Entities/Repository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Repository: Equatable { 4 | let id: Int 5 | let owner: Owner 6 | let name: String 7 | let description: String 8 | let stargazersCount: Int 9 | let language: String? 10 | } 11 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Domain/Entities/TrendingRepositoriesPage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct TrendingRepositoriesPage: Equatable { 4 | let items: [Repository] 5 | } 6 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Domain/Interfaces/Repositories/ImagesRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol ImagesRepository { 4 | func fetchImage( 5 | with imagePath: String, 6 | size: Int, 7 | completion: @escaping (Result) -> Void 8 | ) -> Cancellable? 9 | } 10 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Domain/Interfaces/Repositories/TrendingRepositoriesRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol TrendingRepositoriesRepository { 4 | @discardableResult 5 | func fetchTrendingRepositoriesList( 6 | cached: @escaping (TrendingRepositoriesPage) -> Void, 7 | completion: @escaping (Result) -> Void 8 | ) -> Cancellable? 9 | } 10 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Domain/UseCases/FetchTrendingRepositoriesUseCase.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol FetchTrendingRepositoriesUseCase { 4 | func fetch( 5 | cached: @escaping (TrendingRepositoriesPage) -> Void, 6 | completion: @escaping (Result) -> Void 7 | ) -> Cancellable? 8 | } 9 | 10 | final class DefaultFetchTrendingRepositoriesUseCase: FetchTrendingRepositoriesUseCase { 11 | 12 | private let trendingRepositoriesRepository: TrendingRepositoriesRepository 13 | 14 | init( 15 | trendingRepositoriesRepository: TrendingRepositoriesRepository 16 | ) { 17 | self.trendingRepositoriesRepository = trendingRepositoriesRepository 18 | } 19 | 20 | func fetch( 21 | cached: @escaping (TrendingRepositoriesPage) -> Void, 22 | completion: @escaping (Result) -> Void 23 | ) -> Cancellable? { 24 | 25 | trendingRepositoriesRepository.fetchTrendingRepositoriesList( 26 | cached: cached, 27 | completion: completion 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/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 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Infrastructure/Network/DataTransferService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum DataTransferError: Error { 4 | case noResponse 5 | case parsing(Error) 6 | case networkFailure(NetworkError) 7 | case resolvedNetworkFailure(Error) 8 | } 9 | 10 | protocol DataTransferDispatchQueue { 11 | func asyncExecute(work: @escaping () -> Void) 12 | } 13 | 14 | extension DispatchQueue: DataTransferDispatchQueue { 15 | func asyncExecute(work: @escaping () -> Void) { 16 | async(group: nil, execute: work) 17 | } 18 | } 19 | 20 | protocol DataTransferService { 21 | typealias CompletionHandler = (Result) -> Void 22 | 23 | @discardableResult 24 | func request( 25 | with endpoint: E, 26 | on queue: DataTransferDispatchQueue, 27 | completion: @escaping CompletionHandler 28 | ) -> NetworkCancellable? where E.Response == T 29 | 30 | @discardableResult 31 | func request( 32 | with endpoint: E, 33 | completion: @escaping CompletionHandler 34 | ) -> NetworkCancellable? where E.Response == T 35 | 36 | @discardableResult 37 | func request( 38 | with endpoint: E, 39 | on queue: DataTransferDispatchQueue, 40 | completion: @escaping CompletionHandler 41 | ) -> NetworkCancellable? where E.Response == Void 42 | 43 | @discardableResult 44 | func request( 45 | with endpoint: E, 46 | completion: @escaping CompletionHandler 47 | ) -> NetworkCancellable? where E.Response == Void 48 | 49 | func url(for endpoint: E) throws -> URL 50 | } 51 | 52 | protocol DataTransferErrorResolver { 53 | func resolve(error: NetworkError) -> Error 54 | } 55 | 56 | protocol ResponseDecoder { 57 | func decode(_ data: Data) throws -> T 58 | } 59 | 60 | protocol DataTransferErrorLogger { 61 | func log(error: Error) 62 | } 63 | 64 | final class DefaultDataTransferService { 65 | 66 | private let networkService: NetworkService 67 | private let errorResolver: DataTransferErrorResolver 68 | private let errorLogger: DataTransferErrorLogger 69 | 70 | init( 71 | with networkService: NetworkService, 72 | errorResolver: DataTransferErrorResolver = DefaultDataTransferErrorResolver(), 73 | errorLogger: DataTransferErrorLogger = DefaultDataTransferErrorLogger() 74 | ) { 75 | self.networkService = networkService 76 | self.errorResolver = errorResolver 77 | self.errorLogger = errorLogger 78 | } 79 | } 80 | 81 | extension DefaultDataTransferService: DataTransferService { 82 | 83 | func request( 84 | with endpoint: E, 85 | on queue: DataTransferDispatchQueue, 86 | completion: @escaping CompletionHandler 87 | ) -> NetworkCancellable? where E.Response == T { 88 | 89 | networkService.request(endpoint: endpoint) { result in 90 | switch result { 91 | case .success(let data): 92 | let result: Result = self.decode( 93 | data: data, 94 | decoder: endpoint.responseDecoder 95 | ) 96 | queue.asyncExecute { completion(result) } 97 | case .failure(let error): 98 | self.errorLogger.log(error: error) 99 | let error = self.resolve(networkError: error) 100 | queue.asyncExecute { completion(.failure(error)) } 101 | } 102 | } 103 | } 104 | 105 | func request( 106 | with endpoint: E, 107 | completion: @escaping CompletionHandler 108 | ) -> NetworkCancellable? where E.Response == T { 109 | request(with: endpoint, on: DispatchQueue.main, completion: completion) 110 | } 111 | 112 | func request( 113 | with endpoint: E, 114 | on queue: DataTransferDispatchQueue, 115 | completion: @escaping CompletionHandler 116 | ) -> NetworkCancellable? where E : ResponseRequestable, E.Response == Void { 117 | networkService.request(endpoint: endpoint) { result in 118 | switch result { 119 | case .success: 120 | queue.asyncExecute { completion(.success(())) } 121 | case .failure(let error): 122 | self.errorLogger.log(error: error) 123 | let error = self.resolve(networkError: error) 124 | queue.asyncExecute { completion(.failure(error)) } 125 | } 126 | } 127 | } 128 | 129 | func request( 130 | with endpoint: E, 131 | completion: @escaping CompletionHandler 132 | ) -> NetworkCancellable? where E : ResponseRequestable, E.Response == Void { 133 | request(with: endpoint, on: DispatchQueue.main, completion: completion) 134 | } 135 | 136 | func url( 137 | for endpoint: E 138 | ) throws -> URL { 139 | try networkService.url(for: endpoint) 140 | } 141 | 142 | // MARK: - Private 143 | private func decode( 144 | data: Data?, 145 | decoder: ResponseDecoder 146 | ) -> Result { 147 | do { 148 | guard let data = data else { return .failure(.noResponse) } 149 | let result: T = try decoder.decode(data) 150 | return .success(result) 151 | } catch { 152 | self.errorLogger.log(error: error) 153 | return .failure(.parsing(error)) 154 | } 155 | } 156 | 157 | private func resolve(networkError error: NetworkError) -> DataTransferError { 158 | let resolvedError = self.errorResolver.resolve(error: error) 159 | return resolvedError is NetworkError 160 | ? .networkFailure(error) 161 | : .resolvedNetworkFailure(resolvedError) 162 | } 163 | } 164 | 165 | // MARK: - Logger 166 | final class DefaultDataTransferErrorLogger: DataTransferErrorLogger { 167 | func log(error: Error) { 168 | printIfDebug("-------------") 169 | printIfDebug("\(error)") 170 | } 171 | } 172 | 173 | // MARK: - Error Resolver 174 | class DefaultDataTransferErrorResolver: DataTransferErrorResolver { 175 | func resolve(error: NetworkError) -> Error { 176 | return error 177 | } 178 | } 179 | 180 | // MARK: - Response Decoders 181 | class JSONResponseDecoder: ResponseDecoder { 182 | let jsonDecoder = JSONDecoder() 183 | func decode(_ data: Data) throws -> T { 184 | return try jsonDecoder.decode(T.self, from: data) 185 | } 186 | } 187 | 188 | class RawDataResponseDecoder: ResponseDecoder { 189 | enum CodingKeys: String, CodingKey { 190 | case `default` = "" 191 | } 192 | func decode(_ data: Data) throws -> T { 193 | if T.self is Data.Type, let data = data as? T { 194 | return data 195 | } else { 196 | let context = DecodingError.Context( 197 | codingPath: [CodingKeys.default], 198 | debugDescription: "Expected Data type" 199 | ) 200 | throw Swift.DecodingError.typeMismatch(T.self, context) 201 | } 202 | } 203 | } 204 | 205 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Infrastructure/Network/Endpoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum HTTPMethodType: String { 4 | case get = "GET" 5 | case head = "HEAD" 6 | case post = "POST" 7 | case put = "PUT" 8 | case patch = "PATCH" 9 | case delete = "DELETE" 10 | } 11 | 12 | enum BodyEncoding { 13 | case jsonSerializationData 14 | case stringEncodingAscii 15 | } 16 | 17 | class Endpoint: ResponseRequestable { 18 | 19 | typealias Response = R 20 | 21 | let path: String 22 | let method: HTTPMethodType 23 | let headerParameters: [String: String] 24 | let queryParametersEncodable: Encodable? 25 | let queryParameters: [String: Any] 26 | let bodyParametersEncodable: Encodable? 27 | let bodyParameters: [String: Any] 28 | let bodyEncoding: BodyEncoding 29 | let responseDecoder: ResponseDecoder 30 | 31 | init( 32 | path: String, 33 | method: HTTPMethodType, 34 | headerParameters: [String: String] = [:], 35 | queryParametersEncodable: Encodable? = nil, 36 | queryParameters: [String: Any] = [:], 37 | bodyParametersEncodable: Encodable? = nil, 38 | bodyParameters: [String: Any] = [:], 39 | bodyEncoding: BodyEncoding = .jsonSerializationData, 40 | responseDecoder: ResponseDecoder = JSONResponseDecoder() 41 | ) { 42 | self.path = path 43 | self.method = method 44 | self.headerParameters = headerParameters 45 | self.queryParametersEncodable = queryParametersEncodable 46 | self.queryParameters = queryParameters 47 | self.bodyParametersEncodable = bodyParametersEncodable 48 | self.bodyParameters = bodyParameters 49 | self.bodyEncoding = bodyEncoding 50 | self.responseDecoder = responseDecoder 51 | } 52 | } 53 | 54 | protocol Requestable { 55 | var path: String { get } 56 | var method: HTTPMethodType { get } 57 | var headerParameters: [String: String] { get } 58 | var queryParametersEncodable: Encodable? { get } 59 | var queryParameters: [String: Any] { get } 60 | var bodyParametersEncodable: Encodable? { get } 61 | var bodyParameters: [String: Any] { get } 62 | var bodyEncoding: BodyEncoding { get } 63 | 64 | func urlRequest(with networkConfig: NetworkConfigurable) throws -> URLRequest 65 | } 66 | 67 | protocol ResponseRequestable: Requestable { 68 | associatedtype Response 69 | 70 | var responseDecoder: ResponseDecoder { get } 71 | } 72 | 73 | enum RequestGenerationError: Error { 74 | case components 75 | case urlPathIsInvalid 76 | } 77 | 78 | extension Requestable { 79 | 80 | func url(with config: NetworkConfigurable) throws -> URL { 81 | 82 | let endpoint: String 83 | if let baseURL = config.baseURL { 84 | let baseURL = baseURL.absoluteString.last != "/" 85 | ? baseURL.absoluteString + "/" 86 | : baseURL.absoluteString 87 | endpoint = baseURL.appending(path) 88 | } else { 89 | endpoint = path 90 | } 91 | 92 | guard var urlComponents = URLComponents(string: endpoint) else { throw RequestGenerationError.components } 93 | var urlQueryItems = [URLQueryItem]() 94 | 95 | let queryParameters = try queryParametersEncodable?.toDictionary() ?? self.queryParameters 96 | queryParameters.forEach { 97 | urlQueryItems.append(URLQueryItem(name: $0.key, value: "\($0.value)")) 98 | } 99 | config.queryParameters.forEach { 100 | urlQueryItems.append(URLQueryItem(name: $0.key, value: $0.value)) 101 | } 102 | 103 | urlComponents.queryItems = urlQueryItems.isEmpty 104 | ? urlComponents.queryItems 105 | : (urlComponents.queryItems ?? []) + urlQueryItems 106 | 107 | guard let url = urlComponents.url else { throw RequestGenerationError.components } 108 | 109 | if urlComponents.queryItems?.isEmpty ?? true { 110 | guard let url = URL(string: endpoint) 111 | else { throw RequestGenerationError.urlPathIsInvalid } 112 | return url 113 | } 114 | 115 | return url 116 | } 117 | 118 | func urlRequest(with config: NetworkConfigurable) throws -> URLRequest { 119 | 120 | let url = try self.url(with: config) 121 | var urlRequest = URLRequest(url: url) 122 | var allHeaders: [String: String] = config.headers 123 | headerParameters.forEach { allHeaders.updateValue($1, forKey: $0) } 124 | 125 | let bodyParameters = try bodyParametersEncodable?.toDictionary() ?? self.bodyParameters 126 | if !bodyParameters.isEmpty { 127 | urlRequest.httpBody = encodeBody(bodyParameters: bodyParameters, bodyEncoding: bodyEncoding) 128 | } 129 | urlRequest.httpMethod = method.rawValue 130 | urlRequest.allHTTPHeaderFields = allHeaders 131 | return urlRequest 132 | } 133 | 134 | private func encodeBody(bodyParameters: [String: Any], bodyEncoding: BodyEncoding) -> Data? { 135 | switch bodyEncoding { 136 | case .jsonSerializationData: 137 | return try? JSONSerialization.data(withJSONObject: bodyParameters) 138 | case .stringEncodingAscii: 139 | return bodyParameters.queryString.data(using: String.Encoding.ascii, allowLossyConversion: true) 140 | } 141 | } 142 | } 143 | 144 | private extension Dictionary { 145 | var queryString: String { 146 | return self.map { "\($0.key)=\($0.value)" } 147 | .joined(separator: "&") 148 | .addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed) ?? "" 149 | } 150 | } 151 | 152 | private extension Encodable { 153 | func toDictionary() throws -> [String: Any]? { 154 | let data = try JSONEncoder().encode(self) 155 | let jsonData = try JSONSerialization.jsonObject(with: data) 156 | return jsonData as? [String: Any] 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Infrastructure/Network/NetworkConfig.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol NetworkConfigurable { 4 | var baseURL: URL? { get } 5 | var headers: [String: String] { get } 6 | var queryParameters: [String: String] { get } 7 | } 8 | 9 | struct ApiDataNetworkConfig: NetworkConfigurable { 10 | let baseURL: URL? 11 | let headers: [String: String] 12 | let queryParameters: [String: String] 13 | 14 | init( 15 | baseURL: URL? = nil, 16 | headers: [String: String] = [:], 17 | queryParameters: [String: String] = [:] 18 | ) { 19 | self.baseURL = baseURL 20 | self.headers = headers 21 | self.queryParameters = queryParameters 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Infrastructure/Network/NetworkService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum NetworkError: Error { 4 | case error(statusCode: Int, data: Data?) 5 | case notConnected 6 | case cancelled 7 | case generic(Error) 8 | case urlGeneration 9 | } 10 | 11 | protocol NetworkCancellable { 12 | func cancel() 13 | } 14 | 15 | extension URLSessionTask: NetworkCancellable { } 16 | 17 | protocol NetworkService { 18 | typealias CompletionHandler = (Result) -> Void 19 | 20 | func request( 21 | endpoint: Requestable, 22 | completion: @escaping CompletionHandler 23 | ) -> NetworkCancellable? 24 | 25 | func url(for endpoint: Requestable) throws -> URL 26 | } 27 | 28 | protocol NetworkSessionManager { 29 | typealias CompletionHandler = (Data?, URLResponse?, Error?) -> Void 30 | 31 | func request(_ request: URLRequest, 32 | completion: @escaping CompletionHandler) -> NetworkCancellable 33 | } 34 | 35 | protocol NetworkLogger { 36 | func log(request: URLRequest) 37 | func log(responseData data: Data?, response: URLResponse?) 38 | func log(error: Error) 39 | } 40 | 41 | // MARK: - Implementation 42 | 43 | final class DefaultNetworkService { 44 | 45 | private let config: NetworkConfigurable 46 | private let sessionManager: NetworkSessionManager 47 | private let logger: NetworkLogger 48 | 49 | init( 50 | config: NetworkConfigurable = ApiDataNetworkConfig(), 51 | sessionManager: NetworkSessionManager = DefaultNetworkSessionManager(), 52 | logger: NetworkLogger = DefaultNetworkLogger() 53 | ) { 54 | self.sessionManager = sessionManager 55 | self.config = config 56 | self.logger = logger 57 | } 58 | 59 | private func request( 60 | request: URLRequest, 61 | completion: @escaping CompletionHandler 62 | ) -> NetworkCancellable { 63 | 64 | let sessionDataTask = sessionManager.request(request) { data, response, requestError in 65 | 66 | if let requestError = requestError { 67 | var error: NetworkError 68 | if let response = response as? HTTPURLResponse { 69 | error = .error(statusCode: response.statusCode, data: data) 70 | } else { 71 | error = self.resolve(error: requestError) 72 | } 73 | 74 | self.logger.log(error: error) 75 | completion(.failure(error)) 76 | } else { 77 | self.logger.log(responseData: data, response: response) 78 | completion(.success(data)) 79 | } 80 | } 81 | 82 | logger.log(request: request) 83 | 84 | return sessionDataTask 85 | } 86 | 87 | private func resolve(error: Error) -> NetworkError { 88 | let code = URLError.Code(rawValue: (error as NSError).code) 89 | switch code { 90 | case .notConnectedToInternet: return .notConnected 91 | case .cancelled: return .cancelled 92 | default: return .generic(error) 93 | } 94 | } 95 | } 96 | 97 | extension DefaultNetworkService: NetworkService { 98 | 99 | func request( 100 | endpoint: Requestable, 101 | completion: @escaping CompletionHandler 102 | ) -> NetworkCancellable? { 103 | do { 104 | let urlRequest = try endpoint.urlRequest(with: config) 105 | return request(request: urlRequest, completion: completion) 106 | } catch { 107 | completion(.failure(.urlGeneration)) 108 | return nil 109 | } 110 | } 111 | 112 | func url(for endpoint: Requestable) throws -> URL { 113 | try endpoint.url(with: config) 114 | } 115 | } 116 | 117 | // MARK: - Default Network Session Manager 118 | 119 | class DefaultNetworkSessionManager: NetworkSessionManager { 120 | 121 | func request( 122 | _ request: URLRequest, 123 | completion: @escaping CompletionHandler 124 | ) -> NetworkCancellable { 125 | let task = URLSession.shared.dataTask(with: request, completionHandler: completion) 126 | task.resume() 127 | return task 128 | } 129 | } 130 | 131 | // MARK: - Logger 132 | 133 | final class DefaultNetworkLogger: NetworkLogger { 134 | 135 | private let shouldLogRequests: Bool 136 | 137 | init(shouldLogRequests: Bool = true) { 138 | self.shouldLogRequests = shouldLogRequests 139 | } 140 | 141 | func log(request: URLRequest) { 142 | guard shouldLogRequests else { return } 143 | print("-------------") 144 | print("request: \(request.url!)") 145 | print("headers: \(request.allHTTPHeaderFields!)") 146 | print("method: \(request.httpMethod!)") 147 | if let httpBody = request.httpBody, 148 | let result = ((try? JSONSerialization.jsonObject( 149 | with: httpBody, 150 | options: []) as? [String: AnyObject]) as [String: AnyObject]?? 151 | ) { 152 | printIfDebug("body: \(String(describing: result))") 153 | } else if let httpBody = request.httpBody, let resultString = String(data: httpBody, encoding: .utf8) { 154 | printIfDebug("body: \(String(describing: resultString))") 155 | } 156 | } 157 | 158 | func log(responseData data: Data?, response: URLResponse?) { 159 | guard shouldLogRequests else { return } 160 | guard let data = data else { return } 161 | if let dataDict = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { 162 | printIfDebug("responseData: \(String(describing: dataDict))") 163 | } 164 | } 165 | 166 | func log(error: Error) { 167 | printIfDebug("\(error)") 168 | } 169 | } 170 | 171 | // MARK: - NetworkError extension 172 | 173 | extension NetworkError { 174 | var isNotFoundError: Bool { return hasStatusCode(404) } 175 | 176 | func hasStatusCode(_ codeError: Int) -> Bool { 177 | switch self { 178 | case let .error(code, _): 179 | return code == codeError 180 | default: return false 181 | } 182 | } 183 | } 184 | 185 | extension Dictionary where Key == String { 186 | func prettyPrint() -> String { 187 | var string: String = "" 188 | if let data = try? JSONSerialization.data(withJSONObject: self, options: .prettyPrinted) { 189 | if let nstr = NSString(data: data, encoding: String.Encoding.utf8.rawValue) { 190 | string = nstr as String 191 | } 192 | } 193 | return string 194 | } 195 | } 196 | 197 | func printIfDebug(_ string: String) { 198 | #if DEBUG 199 | print(string) 200 | #endif 201 | } 202 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Presentation/TrendingRepositoriesFeature/Flows/TrendingRepositoriesFlowCoordinator.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | protocol TrendingRepositoriesFlowCoordinatorDependencies { 4 | func makeTrendingRepositoriesListViewController() -> TrendingRepositoriesListViewController 5 | } 6 | 7 | final class TrendingRepositoriesFlowCoordinator { 8 | 9 | private weak var navigationController: UINavigationController? 10 | private let dependencies: TrendingRepositoriesFlowCoordinatorDependencies 11 | 12 | init( 13 | navigationController: UINavigationController, 14 | dependencies: TrendingRepositoriesFlowCoordinatorDependencies 15 | ) { 16 | self.navigationController = navigationController 17 | self.dependencies = dependencies 18 | } 19 | 20 | func start() { 21 | let viewController = dependencies.makeTrendingRepositoriesListViewController() 22 | navigationController?.pushViewController(viewController, animated: false) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Presentation/TrendingRepositoriesFeature/Images+TrendingRepositoriesFeature.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum Images { } 4 | 5 | extension Images { 6 | enum Common { 7 | enum Icons { 8 | static let star = "star_icon" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Presentation/TrendingRepositoriesFeature/Localizations+TrendingRepositoriesFeature.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum Localizations { } 4 | 5 | extension Localizations { 6 | enum TrendingRepositoriesFeature { 7 | enum TrendingRepositoriesList { } 8 | } 9 | } 10 | 11 | extension Localizations.TrendingRepositoriesFeature.TrendingRepositoriesList { 12 | enum ListScreen { 13 | static let title = "Trending" 14 | } 15 | enum EmptyState { 16 | static let emptyDataPullToRefresh = "No data\nPlease pull to refresh" 17 | } 18 | enum Errors { 19 | static let failedLoadingRepositoriesTitle = "Something went wrong.." 20 | } 21 | } 22 | 23 | extension Localizations { 24 | enum Common { 25 | enum Errors { 26 | static let noInternetConnection = "No Internet connection" 27 | static let errorTitle = "Error" 28 | static let okButtonTitle = "OK" 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Presentation/TrendingRepositoriesFeature/TradingRepositoriesList/View/TrendingRepositoriesListTableView/Cells/TrendingRepositoryListEmptyItemCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class TrendingRepositoriesListEmptyItemCell: UITableViewCell { 4 | private let label: UILabel = { 5 | let label = UILabel() 6 | label.numberOfLines = 0 7 | label.font = .preferredFont(forTextStyle: .subheadline) 8 | label.textColor = .secondaryLabel 9 | label.textAlignment = .center 10 | return label 11 | }() 12 | 13 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 14 | super.init(style: style, reuseIdentifier: reuseIdentifier) 15 | commonInit() 16 | } 17 | 18 | required internal init?(coder: NSCoder) { 19 | super.init(coder: coder) 20 | commonInit() 21 | } 22 | 23 | private func commonInit() { 24 | backgroundColor = .secondarySystemGroupedBackground 25 | selectionStyle = .none 26 | label.text = NSLocalizedString( 27 | Localizations.TrendingRepositoriesFeature.TrendingRepositoriesList.EmptyState.emptyDataPullToRefresh, 28 | comment: "" 29 | ) 30 | setupViewLayout() 31 | } 32 | 33 | private func setupViewLayout() { 34 | contentView.addSubview(label) 35 | label.translatesAutoresizingMaskIntoConstraints = false 36 | let guide = contentView.layoutMarginsGuide 37 | NSLayoutConstraint.activate([ 38 | label.topAnchor.constraint(equalTo: guide.topAnchor), 39 | guide.bottomAnchor.constraint(equalTo: label.bottomAnchor), 40 | label.leadingAnchor.constraint(equalTo: guide.leadingAnchor), 41 | guide.trailingAnchor.constraint(equalTo: label.trailingAnchor) 42 | ]) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Presentation/TrendingRepositoriesFeature/TradingRepositoriesList/View/TrendingRepositoriesListTableView/Cells/TrendingRepositoryListItemCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class TrendingRepositoriesListItemCell: UITableViewCell { 4 | 5 | private enum Constants { 6 | static let ownerImageSize: CGFloat = 40 7 | static let languageColorImageSize: CGFloat = 10 8 | static let starImageSize: CGFloat = 18 9 | static let horizontalSpaceBetweenLanguageAndStars: CGFloat = 24 10 | static let horizontalSpaceBetweenImageAndTextContent: CGFloat = 12 11 | static let horizontalSpaceBetweenLanguageColorAndLabel: CGFloat = 8 12 | static let horizontalSpaceBetweenStarImageAndLabel: CGFloat = 6 13 | static let verticalSpaceBetweenText: CGFloat = 10 14 | } 15 | 16 | private lazy var horizontalStack: UIStackView = { 17 | let stack = UIStackView(arrangedSubviews: [ 18 | ownerNameLabel, 19 | repositoryNameLabel, 20 | repositoryDetailsStack 21 | ]) 22 | stack.axis = .vertical 23 | stack.spacing = Constants.verticalSpaceBetweenText 24 | return stack 25 | }() 26 | 27 | private lazy var repositoryDetailsStack: UIStackView = { 28 | let stack = UIStackView(arrangedSubviews: [ 29 | repositoryDescriptionLabel, 30 | repositoryLanguageAndStartsStack 31 | ]) 32 | stack.axis = .vertical 33 | stack.spacing = Constants.verticalSpaceBetweenText 34 | return stack 35 | }() 36 | 37 | private lazy var repositoryLanguageAndStartsStack: UIStackView = { 38 | let stack = UIStackView(arrangedSubviews: [ 39 | repositoryLanguageStack, 40 | repositoryStartsStack, 41 | rightLanguageAndStarsSpace 42 | ]) 43 | stack.axis = .horizontal 44 | stack.distribution = .fill 45 | stack.spacing = Constants.horizontalSpaceBetweenLanguageAndStars 46 | 47 | return stack 48 | }() 49 | 50 | private lazy var rightLanguageAndStarsSpace = UIView() 51 | 52 | private let ownerNameLabel: UILabel = { 53 | let label = UILabel() 54 | label.numberOfLines = 0 55 | label.font = .preferredFont(forTextStyle: .subheadline) 56 | label.textColor = .secondaryLabel 57 | return label 58 | }() 59 | 60 | private let ownerImageView: UIImageView = { 61 | let imageView = UIImageView() 62 | imageView.contentMode = .scaleAspectFit 63 | imageView.layer.cornerRadius = Constants.ownerImageSize / 2 64 | imageView.layer.masksToBounds = true 65 | return imageView 66 | }() 67 | 68 | private let repositoryNameLabel: UILabel = { 69 | let label = UILabel() 70 | label.numberOfLines = 0 71 | label.font = .preferredFont(forTextStyle: .headline) 72 | return label 73 | }() 74 | 75 | private let repositoryDescriptionLabel: UILabel = { 76 | let label = UILabel() 77 | label.numberOfLines = 0 78 | label.font = .preferredFont(forTextStyle: .subheadline) 79 | label.textColor = .secondaryLabel 80 | return label 81 | }() 82 | 83 | private lazy var repositoryLanguageStack: UIStackView = { 84 | let stack = UIStackView(arrangedSubviews: [ 85 | repositoryLanguageColorView, 86 | repositoryLanguageLabel 87 | ]) 88 | stack.axis = .horizontal 89 | stack.alignment = .center 90 | stack.spacing = Constants.horizontalSpaceBetweenLanguageColorAndLabel 91 | return stack 92 | }() 93 | 94 | private let repositoryLanguageColorView: UIView = { 95 | let view = UIView() 96 | view.layer.cornerRadius = Constants.languageColorImageSize / 2 97 | view.layer.masksToBounds = true 98 | return view 99 | }() 100 | 101 | private let repositoryLanguageLabel: UILabel = { 102 | let label = UILabel() 103 | label.numberOfLines = 0 104 | label.font = .preferredFont(forTextStyle: .subheadline) 105 | label.textColor = .secondaryLabel 106 | return label 107 | }() 108 | 109 | private lazy var repositoryStartsStack: UIStackView = { 110 | let stack = UIStackView(arrangedSubviews: [ 111 | repositoryStarImageView, 112 | repositoryStarsLabel 113 | ]) 114 | stack.axis = .horizontal 115 | stack.alignment = .center 116 | stack.spacing = Constants.horizontalSpaceBetweenStarImageAndLabel 117 | return stack 118 | }() 119 | 120 | private let repositoryStarImageView: UIImageView = { 121 | let image = UIImage( 122 | named: Images.Common.Icons.star 123 | )?.withRenderingMode(.alwaysTemplate) 124 | let imageView = UIImageView(image: image) 125 | imageView.tintColor = .systemOrange 126 | return imageView 127 | }() 128 | 129 | private let repositoryStarsLabel: UILabel = { 130 | let label = UILabel() 131 | label.numberOfLines = 0 132 | label.font = .preferredFont(forTextStyle: .subheadline) 133 | label.textColor = .secondaryLabel 134 | return label 135 | }() 136 | 137 | private var ownerImageLoadTask: Cancellable? { 138 | willSet { ownerImageLoadTask?.cancel() } 139 | } 140 | private var ownerImageUrl: String? 141 | private let mainQueue: DispatchQueueType = DispatchQueue.main 142 | 143 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 144 | super.init(style: style, reuseIdentifier: reuseIdentifier) 145 | commonInit() 146 | } 147 | 148 | required internal init?(coder: NSCoder) { 149 | super.init(coder: coder) 150 | commonInit() 151 | } 152 | 153 | private func commonInit() { 154 | backgroundColor = .secondarySystemGroupedBackground 155 | selectionStyle = .none 156 | setupViewLayout() 157 | } 158 | 159 | private func setupViewLayout() { 160 | contentView.addSubview(ownerImageView) 161 | contentView.addSubview(horizontalStack) 162 | 163 | ownerImageView.translatesAutoresizingMaskIntoConstraints = false 164 | horizontalStack.translatesAutoresizingMaskIntoConstraints = false 165 | repositoryStarImageView.translatesAutoresizingMaskIntoConstraints = false 166 | 167 | repositoryLanguageColorView.translatesAutoresizingMaskIntoConstraints = false 168 | 169 | let guide = contentView.layoutMarginsGuide 170 | 171 | NSLayoutConstraint.activate([ 172 | ownerImageView.topAnchor.constraint(equalTo: guide.topAnchor, constant: 5), 173 | guide.bottomAnchor.constraint(greaterThanOrEqualTo: ownerImageView.bottomAnchor), 174 | ownerImageView.leadingAnchor.constraint(equalTo: guide.leadingAnchor), 175 | ownerImageView.widthAnchor.constraint(equalToConstant: Constants.ownerImageSize), 176 | ownerImageView.heightAnchor.constraint(equalToConstant: Constants.ownerImageSize) 177 | ]) 178 | 179 | NSLayoutConstraint.activate([ 180 | horizontalStack.topAnchor.constraint(equalTo: guide.topAnchor), 181 | guide.bottomAnchor.constraint(equalTo: horizontalStack.bottomAnchor), 182 | horizontalStack.leadingAnchor.constraint( 183 | equalTo: ownerImageView.trailingAnchor, 184 | constant: Constants.horizontalSpaceBetweenImageAndTextContent 185 | ), 186 | guide.trailingAnchor.constraint(equalTo: horizontalStack.trailingAnchor) 187 | ]) 188 | 189 | NSLayoutConstraint.activate([ 190 | repositoryLanguageColorView.widthAnchor.constraint(equalToConstant: Constants.languageColorImageSize), 191 | repositoryLanguageColorView.heightAnchor.constraint(equalToConstant: Constants.languageColorImageSize) 192 | ]) 193 | 194 | NSLayoutConstraint.activate([ 195 | repositoryStarImageView.widthAnchor.constraint(equalToConstant: Constants.starImageSize), 196 | repositoryStarImageView.heightAnchor.constraint(equalToConstant: Constants.starImageSize) 197 | ]) 198 | 199 | rightLanguageAndStarsSpace.setContentHuggingPriority(.fittingSizeLevel, for: .horizontal) 200 | rightLanguageAndStarsSpace.setContentCompressionResistancePriority( 201 | .fittingSizeLevel, 202 | for: .horizontal 203 | ) 204 | 205 | } 206 | 207 | override internal func prepareForReuse() { 208 | super.prepareForReuse() 209 | repositoryNameLabel.text = nil 210 | ownerNameLabel.text = nil 211 | } 212 | 213 | internal func configure( 214 | with model: TrendingRepositoriesListItemViewModel, 215 | imagesRepository: ImagesRepository 216 | ) { 217 | repositoryNameLabel.text = model.repository.name 218 | ownerNameLabel.text = model.repository.owner.name 219 | repositoryDescriptionLabel.text = model.repository.description 220 | repositoryLanguageLabel.text = model.repository.language 221 | repositoryLanguageStack.isHidden = model.repository.language == nil 222 | repositoryStarsLabel.text = model.repository.stargazersCountFormatted 223 | repositoryLanguageColorView.backgroundColor = model.repository.languageColor 224 | 225 | repositoryDetailsStack.isHidden = !model.isExpanded 226 | 227 | updateOwnerImage( 228 | imagesRepository: imagesRepository, 229 | imageUrl: model.repository.owner.avatarUrl 230 | ) 231 | } 232 | 233 | private func updateOwnerImage( 234 | imagesRepository: ImagesRepository, 235 | imageUrl: String 236 | ) { 237 | ownerImageView.image = nil 238 | 239 | ownerImageUrl = imageUrl 240 | ownerImageLoadTask = imagesRepository.fetchImage( 241 | with: imageUrl, 242 | size: Int(Constants.ownerImageSize * UIScreen.main.scale) 243 | ) { [weak self] result in 244 | self?.mainQueue.async { 245 | defer { self?.ownerImageLoadTask = nil } 246 | guard self?.ownerImageUrl == imageUrl else { return } 247 | if case let .success(data) = result { 248 | self?.ownerImageView.image = UIImage(data: data) 249 | } 250 | } 251 | } 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Presentation/TrendingRepositoriesFeature/TradingRepositoriesList/View/TrendingRepositoriesListTableView/Cells/TrendingRepositoryListLoadingItemCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class TrendingRepositoriesListLoadingItemCell: UITableViewCell, SkeletonLoadable { 4 | 5 | private enum Constants { 6 | static let ownerImageSize: CGFloat = 40 7 | static let horizontalSpaceBetweenImageAndTextContent: CGFloat = 12 8 | static let verticalSpaceBetweenText: CGFloat = 10 9 | } 10 | 11 | private lazy var horizontalStack: UIStackView = { 12 | let stack = UIStackView(arrangedSubviews: [ 13 | ownerNameLabel, 14 | repositoryNameLabel 15 | ]) 16 | stack.axis = .vertical 17 | stack.spacing = Constants.verticalSpaceBetweenText 18 | return stack 19 | }() 20 | 21 | private let ownerNameLabel: UILabel = { 22 | let label = UILabel() 23 | label.numberOfLines = 0 24 | label.font = .preferredFont(forTextStyle: .subheadline) 25 | label.textColor = .secondaryLabel 26 | return label 27 | }() 28 | 29 | private let ownerImageView: UIImageView = { 30 | let imageView = UIImageView() 31 | imageView.contentMode = .scaleAspectFit 32 | imageView.layer.cornerRadius = Constants.ownerImageSize / 2 33 | imageView.layer.masksToBounds = true 34 | return imageView 35 | }() 36 | 37 | private let repositoryNameLabel: UILabel = { 38 | let label = UILabel() 39 | label.numberOfLines = 0 40 | label.font = .preferredFont(forTextStyle: .headline) 41 | return label 42 | }() 43 | 44 | let ownerImageLayer = CAGradientLayer() 45 | let repositoryNameLayer = CAGradientLayer() 46 | let ownerNameLayer = CAGradientLayer() 47 | 48 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 49 | super.init(style: style, reuseIdentifier: reuseIdentifier) 50 | commonInit() 51 | } 52 | 53 | required internal init?(coder: NSCoder) { 54 | super.init(coder: coder) 55 | commonInit() 56 | } 57 | 58 | private func commonInit() { 59 | setup() 60 | setupViewLayout() 61 | } 62 | 63 | func setup() { 64 | 65 | backgroundColor = .secondarySystemGroupedBackground 66 | selectionStyle = .none 67 | 68 | repositoryNameLabel.text = "repo" 69 | ownerNameLabel.text = "owner" 70 | 71 | repositoryNameLabel.translatesAutoresizingMaskIntoConstraints = false 72 | ownerNameLabel.translatesAutoresizingMaskIntoConstraints = false 73 | 74 | ownerImageLayer.startPoint = CGPoint(x: 0, y: 0.5) 75 | ownerImageLayer.endPoint = CGPoint(x: 1, y: 0.5) 76 | ownerImageView.layer.addSublayer(ownerImageLayer) 77 | 78 | ownerNameLayer.startPoint = CGPoint(x: 0, y: 0.5) 79 | ownerNameLayer.endPoint = CGPoint(x: 1, y: 0.5) 80 | ownerNameLabel.layer.addSublayer(ownerNameLayer) 81 | 82 | repositoryNameLayer.startPoint = CGPoint(x: 0, y: 0.5) 83 | repositoryNameLayer.endPoint = CGPoint(x: 1, y: 0.5) 84 | repositoryNameLabel.layer.addSublayer(repositoryNameLayer) 85 | 86 | let ownerImageGroup = makeAnimationGroup() 87 | ownerImageGroup.beginTime = 0.0 88 | ownerImageLayer.add(ownerImageGroup, forKey: "backgroundColor") 89 | 90 | let ownerNameGroup = makeAnimationGroup(previousGroup: ownerImageGroup) 91 | ownerNameLayer.add(ownerNameGroup, forKey: "backgroundColor") 92 | 93 | let reponameGroup = makeAnimationGroup(previousGroup: ownerImageGroup) 94 | reponameGroup.beginTime = 0.0 95 | repositoryNameLayer.add(reponameGroup, forKey: "backgroundColor") 96 | } 97 | 98 | private func setupViewLayout() { 99 | contentView.addSubview(ownerImageView) 100 | contentView.addSubview(horizontalStack) 101 | 102 | ownerImageView.translatesAutoresizingMaskIntoConstraints = false 103 | horizontalStack.translatesAutoresizingMaskIntoConstraints = false 104 | 105 | let guide = contentView.layoutMarginsGuide 106 | 107 | NSLayoutConstraint.activate([ 108 | ownerImageView.topAnchor.constraint(equalTo: guide.topAnchor, constant: 5), 109 | guide.bottomAnchor.constraint(greaterThanOrEqualTo: ownerImageView.bottomAnchor), 110 | ownerImageView.leadingAnchor.constraint(equalTo: guide.leadingAnchor), 111 | ownerImageView.widthAnchor.constraint(equalToConstant: Constants.ownerImageSize), 112 | ownerImageView.heightAnchor.constraint(equalToConstant: Constants.ownerImageSize) 113 | ]) 114 | 115 | NSLayoutConstraint.activate([ 116 | horizontalStack.topAnchor.constraint(equalTo: guide.topAnchor), 117 | guide.bottomAnchor.constraint(equalTo: horizontalStack.bottomAnchor), 118 | horizontalStack.leadingAnchor.constraint( 119 | equalTo: ownerImageView.trailingAnchor, 120 | constant: Constants.horizontalSpaceBetweenImageAndTextContent 121 | ), 122 | guide.trailingAnchor.constraint(equalTo: horizontalStack.trailingAnchor) 123 | ]) 124 | } 125 | 126 | override func layoutSubviews() { 127 | super.layoutSubviews() 128 | 129 | ownerImageLayer.frame = .init( 130 | origin: .zero, 131 | size: .init( 132 | width: ownerImageView.bounds.width, 133 | height: ownerImageView.bounds.height 134 | ) 135 | ) 136 | 137 | ownerNameLayer.frame = .init( 138 | origin: .zero, 139 | size: .init( 140 | width: ownerNameLabel.bounds.width / 3, 141 | height: ownerNameLabel.bounds.height 142 | ) 143 | ) 144 | 145 | repositoryNameLayer.frame = .init( 146 | origin: .zero, 147 | size: .init( 148 | width: repositoryNameLabel.bounds.width / 2, 149 | height: repositoryNameLabel.bounds.height 150 | ) 151 | ) 152 | 153 | ownerNameLayer.cornerRadius = ownerNameLabel.bounds.height / 2 154 | repositoryNameLayer.cornerRadius = repositoryNameLabel.bounds.height / 2 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Presentation/TrendingRepositoriesFeature/TradingRepositoriesList/View/TrendingRepositoriesListTableView/TrendingRepositoriesListTableViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class TrendingRepositoriesListTableViewController: UITableViewController { 4 | 5 | private enum Constants { 6 | static let numberOfLoadingShimmeringCells = 8 7 | } 8 | 9 | private enum Section: Hashable { 10 | case main 11 | } 12 | 13 | private enum Cell: Hashable { 14 | case item(TrendingRepositoriesListItemViewModel) 15 | case emptyData 16 | case loading(index: Int) 17 | } 18 | 19 | private let viewModel: TrendingRepositoriesListViewModel 20 | private let imagesRepository: ImagesRepository 21 | private var registeredCellTypes: Set = [] 22 | 23 | private lazy var tableViewDataSource: UITableViewDiffableDataSource = { [weak self] in 24 | let dataSource = UITableViewDiffableDataSource(tableView: tableView) { tableView, _, cellItem in 25 | 26 | return self?.dequeueReusableCell(tableView, cell: cellItem) 27 | } 28 | return dataSource 29 | }() 30 | 31 | // MARK: - Lifecycle 32 | 33 | init( 34 | viewModel: TrendingRepositoriesListViewModel, 35 | imagesRepository: ImagesRepository 36 | ) { 37 | self.viewModel = viewModel 38 | self.imagesRepository = imagesRepository 39 | super.init(nibName: nil, bundle: nil) 40 | } 41 | 42 | @available(*, unavailable) 43 | required internal init?(coder: NSCoder) { 44 | fatalError("init(coder:) has not been implemented") 45 | } 46 | 47 | override func viewDidLoad() { 48 | super.viewDidLoad() 49 | setupViews() 50 | } 51 | 52 | func reload() { 53 | updateSeparatorStyle() 54 | updateItems() 55 | } 56 | 57 | private func updateSeparatorStyle() { 58 | switch viewModel.content.value { 59 | case .emptyData: 60 | tableView.separatorStyle = .none 61 | case .items, .loading: 62 | tableView.separatorStyle = .singleLine 63 | } 64 | } 65 | 66 | private func setupViews() { 67 | tableView.estimatedRowHeight = 80 68 | tableView.rowHeight = UITableView.automaticDimension 69 | 70 | refreshControl = UIRefreshControl() 71 | refreshControl?.addTarget(self, action: #selector(self.refresh(_:)), for: .valueChanged) 72 | } 73 | 74 | func updateItems() { 75 | var snapshot = NSDiffableDataSourceSnapshot() 76 | 77 | snapshot.appendSections([.main]) 78 | switch viewModel.content.value { 79 | case .items(let models): 80 | snapshot.appendItems(models.map { .item($0) }, toSection: .main) 81 | case .emptyData: 82 | snapshot.appendItems([.emptyData], toSection: .main) 83 | case .loading: 84 | let loadingItems: [Cell] = (0.. UITableViewCell { 108 | switch cell { 109 | case .item(let item): 110 | let cell = tableView.dequeueReusableCell( 111 | TrendingRepositoriesListItemCell.self, 112 | registeredCellTypes: ®isteredCellTypes 113 | ) 114 | cell.configure( 115 | with: item, 116 | imagesRepository: imagesRepository 117 | ) 118 | return cell 119 | case .emptyData: 120 | return tableView.dequeueReusableCell( 121 | TrendingRepositoriesListEmptyItemCell.self, 122 | registeredCellTypes: ®isteredCellTypes 123 | ) 124 | case .loading: 125 | return tableView.dequeueReusableCell( 126 | TrendingRepositoriesListLoadingItemCell.self, 127 | registeredCellTypes: ®isteredCellTypes 128 | ) 129 | } 130 | } 131 | } 132 | 133 | // MARK: - UITableViewDelegate 134 | 135 | extension TrendingRepositoriesListTableViewController { 136 | 137 | override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 138 | switch viewModel.content.value { 139 | case .emptyData: 140 | return tableView.frame.height * 0.8 141 | case .items, .loading: 142 | return super.tableView(tableView, heightForRowAt: indexPath) 143 | } 144 | } 145 | 146 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 147 | viewModel.viewDidSelectItem(at: indexPath.row) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Presentation/TrendingRepositoriesFeature/TradingRepositoriesList/View/TrendingRepositoriesListViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class TrendingRepositoriesListViewController: UIViewController { 4 | 5 | private var viewModel: TrendingRepositoriesListViewModel 6 | private let imagesRepository: ImagesRepository 7 | private lazy var itemsTableViewController = TrendingRepositoriesListTableViewController( 8 | viewModel: viewModel, 9 | imagesRepository: imagesRepository 10 | ) 11 | 12 | // MARK: - Lifecycle 13 | 14 | init( 15 | viewModel: TrendingRepositoriesListViewModel, 16 | imagesRepository: ImagesRepository 17 | ) { 18 | self.viewModel = viewModel 19 | self.imagesRepository = imagesRepository 20 | super.init(nibName: nil, bundle: nil) 21 | } 22 | 23 | @available(*, unavailable) 24 | required internal init?(coder: NSCoder) { 25 | fatalError("init(coder:) has not been implemented") 26 | } 27 | 28 | override func viewDidLoad() { 29 | super.viewDidLoad() 30 | 31 | setupLocalizations() 32 | setupViewLayout() 33 | bind(to: viewModel) 34 | viewModel.viewDidLoad() 35 | } 36 | 37 | private func bind(to viewModel: TrendingRepositoriesListViewModel) { 38 | viewModel.content.observe(on: self) { [weak self] 39 | _ in self?.updateItems() } 40 | viewModel.error.observe(on: self) { [weak self] in self?.showError($0) } 41 | } 42 | 43 | private func setupLocalizations() { 44 | title = NSLocalizedString( 45 | Localizations.TrendingRepositoriesFeature.TrendingRepositoriesList.ListScreen.title, 46 | comment: "" 47 | ) 48 | } 49 | 50 | private func setupViewLayout() { 51 | add(child: itemsTableViewController, container: view) 52 | } 53 | 54 | private func updateItems() { 55 | itemsTableViewController.reload() 56 | } 57 | 58 | private func showError(_ error: String) { 59 | guard !error.isEmpty else { return } 60 | 61 | let alert = UIAlertController( 62 | title: NSLocalizedString( 63 | Localizations.Common.Errors.errorTitle, 64 | comment: "" 65 | ), 66 | message: error, 67 | preferredStyle: .alert 68 | ) 69 | alert.addAction( 70 | UIAlertAction( 71 | title: NSLocalizedString( 72 | Localizations.Common.Errors.okButtonTitle, 73 | comment: "" 74 | ), 75 | style: UIAlertAction.Style.default, 76 | handler: nil 77 | ) 78 | ) 79 | present(alert, animated: true) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Presentation/TrendingRepositoriesFeature/TradingRepositoriesList/ViewModel/TrendingRepositoriesDisplayModeltems/TrendingRepositoriesListContentViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum TrendingRepositoriesListContentViewModel: Hashable { 4 | case items([TrendingRepositoriesListItemViewModel]) 5 | case emptyData 6 | case loading 7 | } 8 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Presentation/TrendingRepositoriesFeature/TradingRepositoriesList/ViewModel/TrendingRepositoriesDisplayModeltems/TrendingRepositoriesListItemViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | struct TrendingRepositoriesListItemViewModel: Hashable { 5 | struct RepositoryOwnerItemViewModel: Hashable { 6 | let name: String 7 | let avatarUrl: String 8 | } 9 | struct RepositoryItemViewModel: Hashable { 10 | let id: Int 11 | let owner: RepositoryOwnerItemViewModel 12 | let name: String 13 | let description: String 14 | let stargazersCountFormatted: String 15 | let language: String? 16 | let languageColor: UIColor? 17 | } 18 | let repository: RepositoryItemViewModel 19 | var isExpanded: Bool = false 20 | } 21 | 22 | extension TrendingRepositoriesListItemViewModel { 23 | 24 | init(repository: Repository) { 25 | self.repository = .init( 26 | id: repository.id, 27 | owner: .init( 28 | name: repository.owner.name, 29 | avatarUrl: repository.owner.avatarUrl 30 | ), 31 | name: repository.name, 32 | description: repository.description, 33 | stargazersCountFormatted: repository.stargazersCount.formatLongNumber(), 34 | language: repository.language, 35 | languageColor: repository.languageColor 36 | ) 37 | } 38 | } 39 | 40 | private extension Repository { 41 | var languageColor: UIColor? { 42 | guard let language else { return nil } 43 | switch language { 44 | case "Go": 45 | return .systemTeal 46 | case "TypeScript": 47 | return .systemOrange 48 | case "Python": 49 | return .systemBlue 50 | default: 51 | return .systemBlue 52 | } 53 | } 54 | } 55 | 56 | private extension Int { 57 | func formatLongNumber() -> String { 58 | let num = abs(Double(self)) 59 | let sign = (self < 0) ? "-" : "" 60 | 61 | switch num { 62 | case 1_000_000_000...: 63 | var formatted = num / 1_000_000_000 64 | formatted = formatted.reduceScale(to: 1) 65 | return "\(sign)\(formatted)b" 66 | 67 | case 1_000_000...: 68 | var formatted = num / 1_000_000 69 | formatted = formatted.reduceScale(to: 1) 70 | return "\(sign)\(formatted)m" 71 | 72 | case 1_000...: 73 | var formatted = num / 1_000 74 | formatted = formatted.reduceScale(to: 1) 75 | return "\(sign)\(formatted)k" 76 | 77 | case 0...: 78 | return "\(self)" 79 | 80 | default: 81 | return "\(sign)\(self)" 82 | } 83 | } 84 | } 85 | 86 | private extension Double { 87 | func reduceScale(to places: Int) -> Double { 88 | let multiplier = pow(10, Double(places)) 89 | let newDecimal = multiplier * self 90 | let truncated = Double(Int(newDecimal)) 91 | let originalDecimal = truncated / multiplier 92 | return originalDecimal 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Presentation/TrendingRepositoriesFeature/TradingRepositoriesList/ViewModel/TrendingRepositoriesListViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol TrendingRepositoriesListViewModelInput { 4 | func viewDidLoad() 5 | func viewDidRefresh() 6 | func viewDidSelectItem(at index: Int) 7 | } 8 | 9 | protocol TrendingRepositoriesListViewModelOutput { 10 | var content: Observable { get } 11 | var error: Observable { get } 12 | } 13 | 14 | protocol TrendingRepositoriesListViewModel: TrendingRepositoriesListViewModelInput, TrendingRepositoriesListViewModelOutput {} 15 | 16 | final class DefaultTrendingRepositoriesListViewModel: TrendingRepositoriesListViewModel { 17 | 18 | private let fetchTrendingRepositoriesUseCase: FetchTrendingRepositoriesUseCase 19 | 20 | private(set) var items: [TrendingRepositoriesListItemViewModel] = [] 21 | private var dataLoadTask: Cancellable? { willSet { dataLoadTask?.cancel() } } 22 | 23 | private let mainQueue: DispatchQueueType 24 | 25 | // MARK: - OUTPUT 26 | 27 | let content: Observable = Observable(.emptyData) 28 | let error: Observable = Observable("") 29 | 30 | // MARK: - Init 31 | 32 | init( 33 | fetchTrendingRepositoriesUseCase: FetchTrendingRepositoriesUseCase, 34 | mainQueue: DispatchQueueType 35 | ) { 36 | self.fetchTrendingRepositoriesUseCase = fetchTrendingRepositoriesUseCase 37 | self.mainQueue = mainQueue 38 | } 39 | 40 | // MARK: - Private 41 | 42 | private func reload() { 43 | resetData() 44 | loadData() 45 | } 46 | 47 | private func resetData() { 48 | items.removeAll() 49 | content.value = .emptyData 50 | } 51 | 52 | private func loadData() { 53 | content.value = .loading 54 | 55 | dataLoadTask = fetchTrendingRepositoriesUseCase.fetch( 56 | cached: { [weak self] page in 57 | self?.mainQueue.async { 58 | self?.udpate(page.items) 59 | } 60 | }, 61 | completion: { [weak self] result in 62 | self?.mainQueue.async { 63 | switch result { 64 | case .success(let page): 65 | self?.udpate(page.items) 66 | case .failure(let error): 67 | self?.handle(error: error) 68 | self?.updateContent() 69 | } 70 | } 71 | } 72 | ) 73 | } 74 | 75 | private func handle(error: Error) { 76 | switch error.uiError { 77 | case .notConnectedToInternet: 78 | self.error.value = NSLocalizedString( 79 | Localizations.Common.Errors.noInternetConnection, 80 | comment: "" 81 | ) 82 | case .cancelled: 83 | return 84 | case .generic: 85 | self.error.value = NSLocalizedString( 86 | Localizations.TrendingRepositoriesFeature.TrendingRepositoriesList.Errors.failedLoadingRepositoriesTitle, 87 | comment: "" 88 | ) 89 | } 90 | } 91 | 92 | private func udpate(_ repositories: [Repository]) { 93 | items = repositories.map { TrendingRepositoriesListItemViewModel(repository: $0) } 94 | updateContent() 95 | } 96 | 97 | private func updateContent() { 98 | content.value = items.isEmpty 99 | ? .emptyData 100 | : .items(items) 101 | } 102 | } 103 | 104 | // MARK: - INPUT. View event methods 105 | 106 | extension DefaultTrendingRepositoriesListViewModel { 107 | 108 | func viewDidLoad() { 109 | reload() 110 | } 111 | 112 | func viewDidRefresh() { 113 | reload() 114 | } 115 | 116 | func viewDidSelectItem(at index: Int) { 117 | guard case .items = content.value else { return } 118 | var item = items[index] 119 | item.isExpanded.toggle() 120 | items[index] = item 121 | content.value = .items(items) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Presentation/TrendingRepositoriesFeature/TrendingRepositoriesFeatureDIContainer.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SwiftUI 3 | 4 | final class TrendingRepositoriesFeatureDIContainer: TrendingRepositoriesFlowCoordinatorDependencies { 5 | 6 | struct Dependencies { 7 | let apiDataTransferService: DataTransferService 8 | let imagesDataTransferService: DataTransferService 9 | let appConfiguration: AppConfiguration 10 | } 11 | 12 | private let dependencies: Dependencies 13 | 14 | // MARK: - Persistent Storage 15 | lazy var trendingRepositoriesCache: TrendingRepositoriesStorage = CoreDataTrendingRepositoriesStorage( 16 | currentTime: { Date() }, 17 | config: .init( 18 | maxAliveTimeInSeconds: dependencies 19 | .appConfiguration 20 | .trendingRepositoriesCacheMaxAliveTimeInSeconds 21 | ) 22 | ) 23 | lazy var imagesCache: ImagesStorage = CoreDataImagesStorage( 24 | currentTime: { Date() } 25 | ) 26 | 27 | init(dependencies: Dependencies) { 28 | self.dependencies = dependencies 29 | } 30 | 31 | // MARK: - Use Cases 32 | func makeFetchTrendingRepositoriesUseCase() -> FetchTrendingRepositoriesUseCase { 33 | DefaultFetchTrendingRepositoriesUseCase( 34 | trendingRepositoriesRepository: makeTrendingRepositoriesRepository() 35 | ) 36 | } 37 | 38 | // MARK: - Repositories 39 | func makeTrendingRepositoriesRepository() -> TrendingRepositoriesRepository { 40 | DefaultTrendingRepositoriesRepository( 41 | dataTransferService: dependencies.apiDataTransferService, 42 | cache: trendingRepositoriesCache 43 | ) 44 | } 45 | 46 | func makeImagesRepository() -> ImagesRepository { 47 | DefaultImagesRepository( 48 | dataTransferService: dependencies.imagesDataTransferService, 49 | imagesCache: imagesCache 50 | ) 51 | } 52 | 53 | // MARK: - Trending Repositories List 54 | // MARK: - TrendingRepositoriesFlowCoordinatorDependencies 55 | func makeTrendingRepositoriesListViewController() -> TrendingRepositoriesListViewController { 56 | TrendingRepositoriesListViewController( 57 | viewModel: makeTrendingRepositoriesListViewModel(), 58 | imagesRepository: makeImagesRepository() 59 | ) 60 | } 61 | 62 | func makeTrendingRepositoriesListViewModel() -> TrendingRepositoriesListViewModel { 63 | DefaultTrendingRepositoriesListViewModel( 64 | fetchTrendingRepositoriesUseCase: makeFetchTrendingRepositoriesUseCase(), 65 | mainQueue: DispatchQueue.main 66 | ) 67 | } 68 | 69 | // MARK: - Flow Coordinators 70 | func makeTrendingRepositoriesFlowCoordinator(navigationController: UINavigationController) -> TrendingRepositoriesFlowCoordinator { 71 | TrendingRepositoriesFlowCoordinator( 72 | navigationController: navigationController, 73 | dependencies: self 74 | ) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Presentation/Utils/Extensions/Error+CancelError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Error { 4 | var isCancelError: Bool { 5 | guard let error = self as? DataTransferError, 6 | case let DataTransferError.networkFailure(networkError) = error, 7 | case .cancelled = networkError else { 8 | return false 9 | } 10 | return true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Presentation/Utils/Extensions/Error+ConnectionError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Error { 4 | var isInternetConnectionError: Bool { 5 | guard let error = self as? DataTransferError, 6 | case let .networkFailure(networkError) = error, 7 | case .notConnected = networkError else { 8 | return false 9 | } 10 | return true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Presentation/Utils/Extensions/Error+UIError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum UIError { 4 | case notConnectedToInternet 5 | case cancelled 6 | case generic(Error) 7 | } 8 | 9 | extension Error { 10 | var uiError: UIError { 11 | if let error = self as? DataTransferError, 12 | case DataTransferError.networkFailure(let networkError) = error { 13 | switch networkError { 14 | case .notConnected: 15 | return .notConnectedToInternet 16 | case .cancelled: 17 | return .cancelled 18 | default: 19 | return .generic(self) 20 | } 21 | } 22 | return .generic(self) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Presentation/Utils/Extensions/UITableView+DequeueReusableCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UITableView { 4 | 5 | func dequeueReusableCell( 6 | _ cellType: Cell.Type, 7 | registeredCellTypes: inout Set 8 | ) -> Cell { 9 | let identifier = String(describing: cellType) 10 | if !registeredCellTypes.contains(identifier) { 11 | register(cellType, forCellReuseIdentifier: identifier) 12 | registeredCellTypes.insert(identifier) 13 | } 14 | let dequeuedCell = dequeueReusableCell( 15 | withIdentifier: identifier 16 | ) 17 | guard let cell = dequeuedCell as? Cell else { 18 | assertionFailure( 19 | "Cannot dequeue reusable cell \(TrendingRepositoriesListItemCell.self) with reuseIdentifier: \(identifier)" 20 | ) 21 | return Cell() 22 | } 23 | return cell 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Presentation/Utils/Extensions/UIViewController+AddChild.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIViewController { 4 | 5 | func add(child: UIViewController, container: UIView) { 6 | addChild(child) 7 | child.view.frame = container.bounds 8 | container.addSubview(child.view) 9 | child.didMove(toParent: self) 10 | } 11 | 12 | func remove() { 13 | guard parent != nil else { 14 | return 15 | } 16 | willMove(toParent: nil) 17 | removeFromParent() 18 | view.removeFromSuperview() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Presentation/Utils/Observable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class Observable { 4 | 5 | struct Observer { 6 | weak var observer: AnyObject? 7 | let block: (Value) -> Void 8 | } 9 | 10 | private var observers = [Observer]() 11 | 12 | var value: Value { 13 | didSet { notifyObservers() } 14 | } 15 | 16 | init(_ value: Value) { 17 | self.value = value 18 | } 19 | 20 | func observe(on observer: AnyObject, observerBlock: @escaping (Value) -> Void) { 21 | observers.append(Observer(observer: observer, block: observerBlock)) 22 | observerBlock(self.value) 23 | } 24 | 25 | func remove(observer: AnyObject) { 26 | observers = observers.filter { $0.observer !== observer } 27 | } 28 | 29 | private func notifyObservers() { 30 | for observer in observers { 31 | observer.block(self.value) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTest/Presentation/Utils/SkeletonLoadable.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | protocol SkeletonLoadable {} 4 | 5 | extension SkeletonLoadable { 6 | 7 | func makeAnimationGroup(previousGroup: CAAnimationGroup? = nil) -> CAAnimationGroup { 8 | let animDuration: CFTimeInterval = 1.5 9 | let anim1 = CABasicAnimation(keyPath: #keyPath(CAGradientLayer.backgroundColor)) 10 | anim1.fromValue = UIColor.gradientLightGrey.cgColor 11 | anim1.toValue = UIColor.gradientDarkGrey.cgColor 12 | anim1.duration = animDuration 13 | anim1.beginTime = 0.0 14 | 15 | let anim2 = CABasicAnimation(keyPath: #keyPath(CAGradientLayer.backgroundColor)) 16 | anim2.fromValue = UIColor.gradientDarkGrey.cgColor 17 | anim2.toValue = UIColor.gradientLightGrey.cgColor 18 | anim2.duration = animDuration 19 | anim2.beginTime = anim1.beginTime + anim1.duration 20 | 21 | let group = CAAnimationGroup() 22 | group.animations = [anim1, anim2] 23 | group.repeatCount = .greatestFiniteMagnitude // infinite 24 | group.duration = anim2.beginTime + anim2.duration 25 | group.isRemovedOnCompletion = false 26 | 27 | if let previousGroup = previousGroup { 28 | group.beginTime = previousGroup.beginTime + 0.33 29 | } 30 | 31 | return group 32 | } 33 | 34 | } 35 | 36 | extension UIColor { 37 | 38 | static var gradientDarkGrey: UIColor { 39 | return UIColor(red: 239 / 255.0, green: 241 / 255.0, blue: 241 / 255.0, alpha: 1) 40 | } 41 | 42 | static var gradientLightGrey: UIColor { 43 | return UIColor(red: 201 / 255.0, green: 201 / 255.0, blue: 201 / 255.0, alpha: 1) 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTestTests/Domain/UseCases/FetchTrendingRepositoriesUseCaseTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SkillsTest 3 | 4 | class FetchTrendingRepositoriesUseCaseTests: XCTestCase { 5 | 6 | static let trendingRepositoriesPage = TrendingRepositoriesPage( 7 | items: [ 8 | .stub(name: "Repository1"), 9 | .stub(name: "Repository2"), 10 | ] 11 | ) 12 | 13 | enum TrendingRepositoriesRepositorySuccessTestError: Error { 14 | case failedFetching 15 | } 16 | 17 | final class TrendingRepositoriesRepositoryMock: TrendingRepositoriesRepository { 18 | 19 | typealias FetchTrendingRepositoriesListBlock = ( 20 | (TrendingRepositoriesPage) -> Void, 21 | (Result) -> Void 22 | ) -> Void 23 | 24 | lazy var _fetchTrendingRepositoriesList: FetchTrendingRepositoriesListBlock = { _, _ in 25 | XCTFail("not implemented") 26 | } 27 | 28 | func fetchTrendingRepositoriesList( 29 | cached: @escaping (TrendingRepositoriesPage) -> Void, 30 | completion: @escaping (Result) -> Void 31 | ) -> Cancellable? { 32 | _fetchTrendingRepositoriesList(cached, completion) 33 | return nil 34 | } 35 | } 36 | 37 | func testFetchTrendingRepositoriesUseCase_whenRepositorySuccessfullyFetches_thenUseCaseExecutesWithSuccess() { 38 | // given 39 | var completionCalls = 0 40 | var cachedCompletionCalls = 0 41 | let trendingRepositoriesRepositoryMock = TrendingRepositoriesRepositoryMock() 42 | 43 | let cachedPage = TrendingRepositoriesPage( 44 | items: [ 45 | .stub(name: "CachedRepository1"), 46 | .stub(name: "CachedRepository2"), 47 | ] 48 | ) 49 | 50 | trendingRepositoriesRepositoryMock._fetchTrendingRepositoriesList = { cached, completion in 51 | cached(cachedPage) 52 | completion( 53 | .success(FetchTrendingRepositoriesUseCaseTests.trendingRepositoriesPage) 54 | ) 55 | completionCalls += 1 56 | } 57 | let useCase = DefaultFetchTrendingRepositoriesUseCase( 58 | trendingRepositoriesRepository: trendingRepositoriesRepositoryMock 59 | ) 60 | 61 | // when 62 | _ = useCase.fetch( 63 | cached: { page in 64 | XCTAssertEqual(page, cachedPage) 65 | cachedCompletionCalls += 1 66 | }, 67 | completion: { result in 68 | switch result { 69 | case .success(let page): 70 | XCTAssertEqual( 71 | FetchTrendingRepositoriesUseCaseTests.trendingRepositoriesPage, 72 | page 73 | ) 74 | completionCalls += 1 75 | case .failure: 76 | XCTFail("Should not fail") 77 | } 78 | }) 79 | // then 80 | XCTAssertEqual(completionCalls, 2) 81 | XCTAssertEqual(cachedCompletionCalls, 1) 82 | } 83 | 84 | func testFetchTrendingRepositoriesUseCase_whenRepositoryFailsFetching_thenUseCaseExecutesWithFailure() { 85 | // given 86 | var completionCalls = 0 87 | let trendingRepositoriesRepositoryMock = TrendingRepositoriesRepositoryMock() 88 | 89 | trendingRepositoriesRepositoryMock._fetchTrendingRepositoriesList = { _, completion in 90 | completionCalls += 1 91 | } 92 | let useCase = DefaultFetchTrendingRepositoriesUseCase( 93 | trendingRepositoriesRepository: trendingRepositoriesRepositoryMock 94 | ) 95 | 96 | // when 97 | _ = useCase.fetch( 98 | cached: { _ in }, 99 | completion: { _ in 100 | completionCalls += 1 101 | } 102 | ) 103 | // then 104 | XCTAssertEqual(completionCalls, 1) 105 | 106 | // Test for memory leaks 107 | addTeardownBlock { [weak useCase] in 108 | XCTAssertNil(useCase) 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTestTests/Infrastructure/Network/DataTransferServiceTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SkillsTest 3 | 4 | private struct MockModel: Decodable { 5 | let name: String 6 | } 7 | 8 | final class DataTransferDispatchQueueMock: DataTransferDispatchQueue { 9 | func asyncExecute(work: @escaping () -> Void) { 10 | work() 11 | } 12 | } 13 | 14 | class DataTransferServiceTests: XCTestCase { 15 | 16 | private enum DataTransferErrorMock: Error { 17 | case someError 18 | } 19 | 20 | func test_whenReceivedValidJsonInResponse_shouldDecodeResponseToDecodableObject() { 21 | //given 22 | let config = NetworkConfigurableMock() 23 | var completionCallsCount = 0 24 | 25 | let responseData = #"{"name": "Hello"}"#.data(using: .utf8) 26 | let networkService = DefaultNetworkService( 27 | config: config, 28 | sessionManager: NetworkSessionManagerMock( 29 | response: nil, 30 | data: responseData, 31 | error: nil 32 | ) 33 | ) 34 | 35 | let sut = DefaultDataTransferService(with: networkService) 36 | //when 37 | _ = sut.request( 38 | with: Endpoint(path: "http://mock.endpoint.com", method: .get), 39 | on: DataTransferDispatchQueueMock() 40 | ) { result in 41 | do { 42 | let object = try result.get() 43 | XCTAssertEqual(object.name, "Hello") 44 | completionCallsCount += 1 45 | } catch { 46 | XCTFail("Failed decoding MockObject") 47 | } 48 | } 49 | //then 50 | XCTAssertEqual(completionCallsCount, 1) 51 | } 52 | 53 | func test_whenInvalidResponse_shouldNotDecodeObject() { 54 | //given 55 | let config = NetworkConfigurableMock() 56 | var completionCallsCount = 0 57 | 58 | let responseData = #"{"age": 20}"#.data(using: .utf8) 59 | let networkService = DefaultNetworkService( 60 | config: config, 61 | sessionManager: NetworkSessionManagerMock( 62 | response: nil, 63 | data: responseData, 64 | error: nil 65 | ) 66 | ) 67 | 68 | let sut = DefaultDataTransferService(with: networkService) 69 | //when 70 | _ = sut.request( 71 | with: Endpoint(path: "http://mock.endpoint.com", method: .get), 72 | on: DataTransferDispatchQueueMock() 73 | ) { result in 74 | do { 75 | _ = try result.get() 76 | XCTFail("Should not happen") 77 | } catch { 78 | completionCallsCount += 1 79 | } 80 | } 81 | //then 82 | XCTAssertEqual(completionCallsCount, 1) 83 | } 84 | 85 | func test_whenBadRequestReceived_shouldRethrowNetworkError() { 86 | //given 87 | let config = NetworkConfigurableMock() 88 | var completionCallsCount = 0 89 | 90 | let responseData = #"{"invalidStructure": "Nothing"}"#.data(using: .utf8)! 91 | let response = HTTPURLResponse(url: URL(string: "test_url")!, 92 | statusCode: 500, 93 | httpVersion: "1.1", 94 | headerFields: nil) 95 | let networkService = DefaultNetworkService( 96 | config: config, 97 | sessionManager: NetworkSessionManagerMock( 98 | response: response, 99 | data: responseData, 100 | error: DataTransferErrorMock.someError 101 | ) 102 | ) 103 | 104 | let sut = DefaultDataTransferService(with: networkService) 105 | //when 106 | _ = sut.request( 107 | with: Endpoint(path: "http://mock.endpoint.com", method: .get), 108 | on: DataTransferDispatchQueueMock() 109 | ) { result in 110 | do { 111 | _ = try result.get() 112 | XCTFail("Should not happen") 113 | } catch let error { 114 | 115 | if case DataTransferError.networkFailure(NetworkError.error(statusCode: 500, _)) = error { 116 | completionCallsCount += 1 117 | } else { 118 | XCTFail("Wrong error") 119 | } 120 | } 121 | } 122 | //then 123 | XCTAssertEqual(completionCallsCount, 1) 124 | } 125 | 126 | func test_whenNoDataReceived_shouldThrowNoDataError() { 127 | //given 128 | let config = NetworkConfigurableMock() 129 | var completionCallsCount = 0 130 | 131 | let response = HTTPURLResponse(url: URL(string: "test_url")!, 132 | statusCode: 200, 133 | httpVersion: "1.1", 134 | headerFields: [:]) 135 | let networkService = DefaultNetworkService( 136 | config: config, 137 | sessionManager: NetworkSessionManagerMock( 138 | response: response, 139 | data: nil, 140 | error: nil 141 | ) 142 | ) 143 | 144 | let sut = DefaultDataTransferService(with: networkService) 145 | //when 146 | _ = sut.request( 147 | with: Endpoint(path: "http://mock.endpoint.com", method: .get), 148 | on: DataTransferDispatchQueueMock() 149 | ) { result in 150 | do { 151 | _ = try result.get() 152 | XCTFail("Should not happen") 153 | } catch let error { 154 | if case DataTransferError.noResponse = error { 155 | completionCallsCount += 1 156 | } else { 157 | XCTFail("Wrong error") 158 | } 159 | } 160 | } 161 | //then 162 | XCTAssertEqual(completionCallsCount, 1) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTestTests/Infrastructure/Network/Mocks/NetworkConfigurableMock.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SkillsTest 3 | 4 | class NetworkConfigurableMock: NetworkConfigurable { 5 | var baseURL: URL? = URL(string: "https://mock.test.com") 6 | var headers: [String: String] = [:] 7 | var queryParameters: [String: String] = [:] 8 | } 9 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTestTests/Infrastructure/Network/Mocks/NetworkSessionManagerMock.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SkillsTest 3 | 4 | struct NetworkSessionManagerMock: NetworkSessionManager { 5 | let response: HTTPURLResponse? 6 | let data: Data? 7 | let error: Error? 8 | 9 | func request( 10 | _ request: URLRequest, 11 | completion: @escaping CompletionHandler 12 | ) -> NetworkCancellable { 13 | completion(data, response, error) 14 | return NetworkCancellableMock() 15 | } 16 | } 17 | 18 | struct NetworkCancellableMock: NetworkCancellable { 19 | func cancel() {} 20 | } 21 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTestTests/Infrastructure/Network/NetworkServiceTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SkillsTest 3 | 4 | class NetworkServiceTests: XCTestCase { 5 | 6 | private struct EndpointMock: Requestable { 7 | var path: String 8 | var method: HTTPMethodType 9 | var headerParameters: [String: String] = [:] 10 | var queryParametersEncodable: Encodable? 11 | var queryParameters: [String: Any] = [:] 12 | var bodyParametersEncodable: Encodable? 13 | var bodyParameters: [String: Any] = [:] 14 | var bodyEncoding: BodyEncoding = .stringEncodingAscii 15 | 16 | init(path: String, method: HTTPMethodType) { 17 | self.path = path 18 | self.method = method 19 | } 20 | } 21 | 22 | class NetworkLoggerMock: NetworkLogger { 23 | var loggedErrors: [Error] = [] 24 | func log(request: URLRequest) { } 25 | func log(responseData data: Data?, response: URLResponse?) { } 26 | func log(error: Error) { loggedErrors.append(error) } 27 | } 28 | 29 | private enum NetworkErrorMock: Error { 30 | case someError 31 | } 32 | 33 | func test_whenMockDataPassed_shouldReturnProperResponse() { 34 | // given 35 | let config = NetworkConfigurableMock() 36 | let expectation = self.expectation(description: "Should return correct data") 37 | 38 | let expectedResponseData = "Response data".data(using: .utf8)! 39 | let sut = DefaultNetworkService( 40 | config: config, 41 | sessionManager: NetworkSessionManagerMock( 42 | response: nil, 43 | data: expectedResponseData, 44 | error: nil 45 | ) 46 | ) 47 | // when 48 | _ = sut.request(endpoint: EndpointMock(path: "http://mock.test.com", method: .get)) { result in 49 | guard let responseData = try? result.get() else { 50 | XCTFail("Should return proper response") 51 | return 52 | } 53 | XCTAssertEqual(responseData, expectedResponseData) 54 | expectation.fulfill() 55 | } 56 | // then 57 | wait(for: [expectation], timeout: 0.1) 58 | } 59 | 60 | func test_whenErrorWithNSURLErrorCancelledReturned_shouldReturnCancelledError() { 61 | // given 62 | let config = NetworkConfigurableMock() 63 | let expectation = self.expectation(description: "Should return hasStatusCode error") 64 | 65 | let cancelledError = NSError(domain: "network", code: NSURLErrorCancelled, userInfo: nil) 66 | let sut = DefaultNetworkService( 67 | config: config, 68 | sessionManager: NetworkSessionManagerMock( 69 | response: nil, 70 | data: nil, 71 | error: cancelledError as Error 72 | ) 73 | ) 74 | // when 75 | _ = sut.request(endpoint: EndpointMock(path: "http://mock.test.com", method: .get)) { result in 76 | do { 77 | _ = try result.get() 78 | XCTFail("Should not happen") 79 | } catch let error { 80 | guard case NetworkError.cancelled = error else { 81 | XCTFail("NetworkError.cancelled not found") 82 | return 83 | } 84 | 85 | expectation.fulfill() 86 | } 87 | } 88 | // then 89 | wait(for: [expectation], timeout: 0.1) 90 | } 91 | 92 | func test_whenStatusCodeEqualOrAbove400_shouldReturnhasStatusCodeError() { 93 | // given 94 | let config = NetworkConfigurableMock() 95 | let expectation = self.expectation(description: "Should return hasStatusCode error") 96 | 97 | let response = HTTPURLResponse( 98 | url: URL(string: "test_url")!, 99 | statusCode: 500, 100 | httpVersion: "1.1", 101 | headerFields: [:] 102 | ) 103 | let sut = DefaultNetworkService( 104 | config: config, 105 | sessionManager: NetworkSessionManagerMock( 106 | response: response, 107 | data: nil, 108 | error: NetworkErrorMock.someError 109 | ) 110 | ) 111 | // when 112 | _ = sut.request(endpoint: EndpointMock(path: "http://mock.test.com", method: .get)) { result in 113 | do { 114 | _ = try result.get() 115 | XCTFail("Should not happen") 116 | } catch let error { 117 | if case NetworkError.error(let statusCode, _) = error { 118 | XCTAssertEqual(statusCode, 500) 119 | expectation.fulfill() 120 | } 121 | } 122 | } 123 | // then 124 | wait(for: [expectation], timeout: 0.1) 125 | } 126 | 127 | func test_whenErrorWithNSURLErrorNotConnecteDtoInternetReturned_shouldReturnNotConnectedError() { 128 | // given 129 | let config = NetworkConfigurableMock() 130 | let expectation = self.expectation(description: "Should return hasStatusCode error") 131 | 132 | let error = NSError(domain: "network", code: NSURLErrorNotConnectedToInternet, userInfo: nil) 133 | let sut = DefaultNetworkService( 134 | config: config, 135 | sessionManager: NetworkSessionManagerMock( 136 | response: nil, 137 | data: nil, 138 | error: error as Error) 139 | ) 140 | 141 | // when 142 | _ = sut.request(endpoint: EndpointMock(path: "http://mock.test.com", method: .get)) { result in 143 | do { 144 | _ = try result.get() 145 | XCTFail("Should not happen") 146 | } catch let error { 147 | guard case NetworkError.notConnected = error else { 148 | XCTFail("NetworkError.notConnected not found") 149 | return 150 | } 151 | 152 | expectation.fulfill() 153 | } 154 | } 155 | // then 156 | wait(for: [expectation], timeout: 0.1) 157 | } 158 | 159 | func test_whenhasStatusCodeUsedWithWrongError_shouldReturnFalse() { 160 | // when 161 | let sut = NetworkError.notConnected 162 | // then 163 | XCTAssertFalse(sut.hasStatusCode(200)) 164 | } 165 | 166 | func test_whenhasStatusCodeUsed_shouldReturnCorrectStatusCode_() { 167 | // when 168 | let sut = NetworkError.error(statusCode: 400, data: nil) 169 | // then 170 | XCTAssertTrue(sut.hasStatusCode(400)) 171 | XCTAssertFalse(sut.hasStatusCode(399)) 172 | XCTAssertFalse(sut.hasStatusCode(401)) 173 | } 174 | 175 | func test_whenErrorWithNSURLErrorNotConnecteDtoInternetReturned_shouldLogThisError() { 176 | // given 177 | let config = NetworkConfigurableMock() 178 | let expectation = self.expectation(description: "Should return hasStatusCode error") 179 | 180 | let error = NSError(domain: "network", code: NSURLErrorNotConnectedToInternet, userInfo: nil) 181 | let NetworkLogger = NetworkLoggerMock() 182 | let sut = DefaultNetworkService( 183 | config: config, 184 | sessionManager: NetworkSessionManagerMock( 185 | response: nil, 186 | data: nil, 187 | error: error as Error), 188 | logger: NetworkLogger 189 | ) 190 | // when 191 | _ = sut.request(endpoint: EndpointMock(path: "http://mock.test.com", method: .get)) { result in 192 | do { 193 | _ = try result.get() 194 | XCTFail("Should not happen") 195 | } catch let error { 196 | guard case NetworkError.notConnected = error else { 197 | XCTFail("NetworkError.notConnected not found") 198 | return 199 | } 200 | 201 | expectation.fulfill() 202 | } 203 | } 204 | 205 | // then 206 | wait(for: [expectation], timeout: 0.1) 207 | XCTAssertTrue(NetworkLogger.loggedErrors.contains { 208 | guard case NetworkError.notConnected = $0 else { return false } 209 | return true 210 | }) 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTestTests/Presentation/Mocks/DispatchQueueTypeMock.swift: -------------------------------------------------------------------------------- 1 | @testable import SkillsTest 2 | 3 | final class DispatchQueueTypeMock: DispatchQueueType { 4 | func async(execute work: @escaping () -> Void) { 5 | work() 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTestTests/Presentation/TrendingRepositoriesFeature/TrendingRepositoriesListViewModelTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SkillsTest 3 | 4 | class TrendingRepositoriesListViewModelTests: XCTestCase { 5 | 6 | private enum FetchTrendingRepositoriesUseCaseError: Error { 7 | case someError 8 | } 9 | 10 | static let trendingRepositoriesPage = TrendingRepositoriesPage( 11 | items: [ 12 | .stub(name: "Repository1"), 13 | .stub(name: "Repository2"), 14 | ] 15 | ) 16 | 17 | final class FetchTrendingRepositoriesUseCaseMock: FetchTrendingRepositoriesUseCase { 18 | typealias FetchBlock = ( 19 | (TrendingRepositoriesPage) -> Void, 20 | (Result) -> Void 21 | ) -> Void 22 | 23 | lazy var _fetch: FetchBlock = { _, _ in 24 | XCTFail("not implemented") 25 | } 26 | 27 | func fetch( 28 | cached: @escaping (TrendingRepositoriesPage) -> Void, 29 | completion: @escaping (Result) -> Void 30 | ) -> Cancellable? { 31 | _fetch(cached, completion) 32 | return nil 33 | } 34 | } 35 | 36 | func test_whenFetchTrendingRepositoriesUseCaseRetrievesEmptyPage_thenViewModelIsEmpty() { 37 | // given 38 | let fetchTrendingRepositoriesUseCaseMock = FetchTrendingRepositoriesUseCaseMock() 39 | var fetchCompletionCalls = 0 40 | fetchTrendingRepositoriesUseCaseMock._fetch = { _, completion in 41 | completion(.success(TrendingRepositoriesPage.init(items: []))) 42 | fetchCompletionCalls += 1 43 | } 44 | let viewModel = DefaultTrendingRepositoriesListViewModel.make( 45 | fetchTrendingRepositoriesUseCase: fetchTrendingRepositoriesUseCaseMock 46 | ) 47 | // when 48 | viewModel.viewDidLoad() 49 | 50 | // then 51 | XCTAssertEqual(fetchCompletionCalls, 1) 52 | XCTAssert(viewModel.content.value == .emptyData) 53 | XCTAssertEqual(viewModel.items.count, 0) 54 | } 55 | 56 | func test_whenFetchTrendingRepositoriesUseCaseRetrievesRepositories_thenViewModelContainsTwoItems() { 57 | // given 58 | let fetchTrendingRepositoriesUseCaseMock = FetchTrendingRepositoriesUseCaseMock() 59 | var fetchCompletionCalls = 0 60 | fetchTrendingRepositoriesUseCaseMock._fetch = { _, completion in 61 | completion(.success(TrendingRepositoriesListViewModelTests.trendingRepositoriesPage)) 62 | fetchCompletionCalls += 1 63 | } 64 | let viewModel = DefaultTrendingRepositoriesListViewModel.make( 65 | fetchTrendingRepositoriesUseCase: fetchTrendingRepositoriesUseCaseMock 66 | ) 67 | // when 68 | viewModel.viewDidLoad() 69 | 70 | // then 71 | let expectedItems = TrendingRepositoriesListViewModelTests 72 | .trendingRepositoriesPage 73 | .items 74 | .map { TrendingRepositoriesListItemViewModel(repository: $0) } 75 | XCTAssertEqual(viewModel.items, expectedItems) 76 | XCTAssertEqual(fetchCompletionCalls, 1) 77 | } 78 | 79 | func test_whenFetchTrendingRepositoriesUseCaseReturnsError_thenViewModelContainsError() { 80 | // given 81 | let fetchTrendingRepositoriesUseCaseMock = FetchTrendingRepositoriesUseCaseMock() 82 | var fetchCompletionCalls = 0 83 | fetchTrendingRepositoriesUseCaseMock._fetch = { _, completion in 84 | completion(.failure(FetchTrendingRepositoriesUseCaseError.someError)) 85 | fetchCompletionCalls += 1 86 | } 87 | let viewModel = DefaultTrendingRepositoriesListViewModel.make( 88 | fetchTrendingRepositoriesUseCase: fetchTrendingRepositoriesUseCaseMock 89 | ) 90 | // when 91 | viewModel.viewDidLoad() 92 | 93 | // then 94 | XCTAssertEqual(fetchCompletionCalls, 1) 95 | XCTAssertNotNil(viewModel.error) 96 | } 97 | 98 | func test_whenSelectTrendingRepository_thenViewModelShouldExpandThisRepository() { 99 | // given 100 | let fetchTrendingRepositoriesUseCaseMock = FetchTrendingRepositoriesUseCaseMock() 101 | var fetchCompletionCalls = 0 102 | fetchTrendingRepositoriesUseCaseMock._fetch = { _, completion in 103 | completion(.success(TrendingRepositoriesListViewModelTests.trendingRepositoriesPage)) 104 | fetchCompletionCalls += 1 105 | } 106 | let viewModel = DefaultTrendingRepositoriesListViewModel.make( 107 | fetchTrendingRepositoriesUseCase: fetchTrendingRepositoriesUseCaseMock 108 | ) 109 | // when 110 | viewModel.viewDidLoad() 111 | 112 | let allExpandedBeforeSelection = viewModel.items.allSatisfy { model in 113 | !model.isExpanded 114 | } 115 | 116 | viewModel.viewDidSelectItem(at: 1) 117 | let itemIsExpandedAfterFirstSelection = viewModel.items[1].isExpanded 118 | 119 | viewModel.viewDidSelectItem(at: 1) 120 | let itemIsExpandedAfterSecondSelection = viewModel.items[1].isExpanded 121 | 122 | // then 123 | XCTAssertTrue(allExpandedBeforeSelection) 124 | XCTAssertTrue(itemIsExpandedAfterFirstSelection) 125 | XCTAssertFalse(itemIsExpandedAfterSecondSelection) 126 | } 127 | 128 | func test_whenFetchRepositoriesUseCaseReturnsError_thenViewModelShowsCachedData() { 129 | // given 130 | let fetchTrendingRepositoriesUseCaseMock = FetchTrendingRepositoriesUseCaseMock() 131 | var fetchCompletionCalls = 0 132 | fetchTrendingRepositoriesUseCaseMock._fetch = { cached, completion in 133 | cached(TrendingRepositoriesListViewModelTests.trendingRepositoriesPage) 134 | completion(.failure(FetchTrendingRepositoriesUseCaseError.someError)) 135 | fetchCompletionCalls += 1 136 | } 137 | 138 | // when 139 | let viewModel = DefaultTrendingRepositoriesListViewModel.make( 140 | fetchTrendingRepositoriesUseCase: fetchTrendingRepositoriesUseCaseMock 141 | ) 142 | // when 143 | viewModel.viewDidLoad() 144 | 145 | // then 146 | let expectedItems = TrendingRepositoriesListViewModelTests 147 | .trendingRepositoriesPage 148 | .items 149 | .map { TrendingRepositoriesListItemViewModel(repository: $0) } 150 | XCTAssertEqual(viewModel.items, expectedItems) 151 | XCTAssertEqual(fetchCompletionCalls, 1) 152 | } 153 | 154 | func test_whenFetchUsersUseCaseReturnsCachedData_thenViewModelShowsFirstCachedDataAndThenFetchedData() { 155 | 156 | // given 157 | let fetchTrendingRepositoriesUseCaseMock = FetchTrendingRepositoriesUseCaseMock() 158 | var fetchCompletionCalls = 0 159 | 160 | let pageUpdated = TrendingRepositoriesPage( 161 | items: [ 162 | .stub(name: "UpdatedRepository1"), 163 | .stub(name: "UpdatedRepository2"), 164 | ] 165 | ) 166 | 167 | let viewModel = DefaultTrendingRepositoriesListViewModel.make( 168 | fetchTrendingRepositoriesUseCase: fetchTrendingRepositoriesUseCaseMock 169 | ) 170 | 171 | let testItemsBeforeFreshData = { 172 | let expectedItems = TrendingRepositoriesListViewModelTests 173 | .trendingRepositoriesPage 174 | .items 175 | .map { TrendingRepositoriesListItemViewModel(repository: $0) } 176 | 177 | XCTAssertEqual(viewModel.items, expectedItems) 178 | } 179 | 180 | fetchTrendingRepositoriesUseCaseMock._fetch = { cached, completion in 181 | cached(TrendingRepositoriesListViewModelTests.trendingRepositoriesPage) 182 | 183 | testItemsBeforeFreshData() 184 | 185 | completion(.success(pageUpdated)) 186 | fetchCompletionCalls += 1 187 | } 188 | 189 | // when 190 | viewModel.viewDidLoad() 191 | 192 | // then 193 | let expectedItems = pageUpdated 194 | .items 195 | .map { TrendingRepositoriesListItemViewModel(repository: $0) } 196 | XCTAssertEqual(viewModel.items, expectedItems) 197 | XCTAssertEqual(fetchCompletionCalls, 1) 198 | } 199 | } 200 | 201 | extension DefaultTrendingRepositoriesListViewModel { 202 | static func make( 203 | fetchTrendingRepositoriesUseCase: FetchTrendingRepositoriesUseCase 204 | ) -> DefaultTrendingRepositoriesListViewModel { 205 | DefaultTrendingRepositoriesListViewModel( 206 | fetchTrendingRepositoriesUseCase: fetchTrendingRepositoriesUseCase, 207 | mainQueue: DispatchQueueTypeMock() 208 | ) 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /SkillsTest/SkillsTestTests/Stubs/Repository+Stub.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SkillsTest 3 | 4 | extension Repository { 5 | static func stub( 6 | id: Int = 1, 7 | owner: Owner = .stub(), 8 | name: String = "RepositoryName", 9 | description: String = "Description", 10 | stargazersCount: Int = 0, 11 | language: String? = nil 12 | ) -> Self { 13 | .init( 14 | id: id, 15 | owner: owner, 16 | name: name, 17 | description: description, 18 | stargazersCount: stargazersCount, 19 | language: language 20 | ) 21 | } 22 | } 23 | 24 | extension Owner { 25 | static func stub( 26 | id: Int = 1, 27 | name: String = "OwnerName", 28 | avatarUrl: String = "http://mock.endpoint.com" 29 | ) -> Self { 30 | .init( 31 | id: id, 32 | name: "OwnerName", 33 | avatarUrl: avatarUrl 34 | ) 35 | } 36 | } 37 | --------------------------------------------------------------------------------