├── .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 | 
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 | 
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 |
--------------------------------------------------------------------------------