├── Pokemon Challenge Tests └── Scenes │ ├── Details │ └── DetailsViewModelTests.swift │ └── Pokemons │ └── PokemonsViewModelTests.swift ├── Pokemon Challenge.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ └── contents.xcworkspacedata └── xcuserdata │ └── evenstaian.xcuserdatad │ ├── xcdebugger │ └── Breakpoints_v2.xcbkptlist │ └── xcschemes │ └── xcschememanagement.plist ├── Pokemon Challenge ├── Application │ ├── AppDelegate.swift │ └── SceneDelegate.swift ├── Architecture │ └── ViewCode.swift ├── Info.plist ├── Models │ ├── EvolutionChainDetails.swift │ ├── Species.swift │ └── SpeciesDetails.swift ├── Network │ ├── API │ │ ├── ApiConstants.swift │ │ ├── ApiRequests.swift │ │ └── MockAPIRequests.swift │ ├── Abstractions │ │ └── APIRequesting.swift │ ├── Cache │ │ └── ImageStore.swift │ └── Enums │ │ └── NetworkErrors.swift ├── Scenes │ ├── Details │ │ ├── Components │ │ │ ├── DetailsAttributesComponent.swift │ │ │ ├── DetailsEvolutionChain.swift │ │ │ └── DetailsHeaderComponent.swift │ │ ├── DetailsFactory.swift │ │ ├── DetailsService.swift │ │ ├── DetailsViewController.swift │ │ └── DetailsViewModel.swift │ └── Pokemons │ │ ├── Components │ │ ├── AuthorComponent.swift │ │ ├── CollectionCells │ │ │ └── PokemonCell.swift │ │ ├── HeaderComponent.swift │ │ ├── ListComponent.swift │ │ ├── PokemonCollectionView │ │ │ └── PokemonCollectionDataSource.swift │ │ └── ServiceMessageComponent.swift │ │ ├── PokemonsCoordinator.swift │ │ ├── PokemonsFactory.swift │ │ ├── PokemonsService.swift │ │ ├── PokemonsViewController.swift │ │ └── PokemonsViewModel.swift ├── UIResources │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── 100.png │ │ │ ├── 1024.png │ │ │ ├── 114.png │ │ │ ├── 120.png │ │ │ ├── 144.png │ │ │ ├── 152.png │ │ │ ├── 167.png │ │ │ ├── 180.png │ │ │ ├── 20.png │ │ │ ├── 29.png │ │ │ ├── 40.png │ │ │ ├── 50.png │ │ │ ├── 57.png │ │ │ ├── 58.png │ │ │ ├── 60.png │ │ │ ├── 72.png │ │ │ ├── 76.png │ │ │ ├── 80.png │ │ │ ├── 87.png │ │ │ └── Contents.json │ │ ├── Contents.json │ │ └── pokemon-logo.imageset │ │ │ ├── Contents.json │ │ │ └── pokemon-logo.pdf │ ├── LaunchScreenViewController.swift │ └── Orientation │ │ └── OrientationManager.swift └── Utils │ └── Helpers │ └── Extracters │ └── Extracters.swift └── README.MD /Pokemon Challenge Tests/Scenes/Details/DetailsViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailsViewModelTests.swift 3 | // Pokemon Challenge Tests 4 | // 5 | // Created by Evens Taian on 20/01/25. 6 | // 7 | 8 | import XCTest 9 | @testable import Pokemon_Challenge 10 | 11 | final class DetailsViewModelTests: XCTestCase { 12 | var sut: DetailsViewModel! 13 | var mockService: MockDetailsService! 14 | var mockAPI: MockAPIRequests! 15 | 16 | override func setUp() { 17 | super.setUp() 18 | mockAPI = MockAPIRequests() 19 | mockService = MockDetailsService(API: mockAPI) 20 | 21 | let pokemon = Species(id: 1, name: "bulbasaur", url: "https://url") 22 | sut = DetailsViewModel(pokemon: pokemon, service: mockService) 23 | } 24 | 25 | override func tearDown() { 26 | sut = nil 27 | mockService = nil 28 | mockAPI = nil 29 | super.tearDown() 30 | } 31 | 32 | func test_viewDidLoad_shouldCallGetSpeciesDetails() { 33 | // When 34 | sut.viewDidLoad() 35 | 36 | // Then 37 | XCTAssertTrue(mockService.getSpeciesDetailsCalled, "getSpeciesDetails should be called when viewDidLoad is called") 38 | } 39 | 40 | func test_getSpeciesDetails_whenSuccessful_shouldCallEvolutionChain() { 41 | // Given 42 | var evolutionChainCalled = false 43 | sut.onEvolutionChainDetailsUpdated = { _ in 44 | evolutionChainCalled = true 45 | } 46 | 47 | // When 48 | sut.getSpeciesDetails(id: 1) 49 | 50 | // Then 51 | XCTAssertTrue(evolutionChainCalled, "Should have called evolution chain after getting species details") 52 | } 53 | 54 | func test_getSpeciesDetails_whenIdIsNil_shouldNotCallService() { 55 | // When 56 | sut.getSpeciesDetails(id: nil) 57 | 58 | // Then 59 | XCTAssertFalse(mockService.getSpeciesDetailsCalled, "Service should not be called when ID is nil") 60 | } 61 | 62 | func test_getSpeciesDetails_shouldReturnCorrectData() { 63 | // Given 64 | var receivedDetails: SpeciesDetails? 65 | sut.onDetailsUpdated = { details in 66 | receivedDetails = details 67 | } 68 | 69 | // When 70 | sut.getSpeciesDetails(id: 1) 71 | 72 | // Then 73 | XCTAssertNotNil(receivedDetails) 74 | XCTAssertEqual(receivedDetails?.name, "bulbasaur") 75 | XCTAssertEqual(receivedDetails?.base_happiness, 50) 76 | XCTAssertEqual(receivedDetails?.capture_rate, 45) 77 | XCTAssertEqual(receivedDetails?.color.name, "green") 78 | XCTAssertEqual(receivedDetails?.is_legendary, false) 79 | XCTAssertEqual(receivedDetails?.is_mythical, false) 80 | } 81 | 82 | func test_getEvolutionChain_whenUrlIsNil_shouldNotCallService() { 83 | // When 84 | sut.getEvolutionChain(urlString: nil) 85 | 86 | // Then 87 | XCTAssertFalse(mockService.getEvolutionChainDetailsCalled, "Service should not be called when URL is nil") 88 | } 89 | 90 | func test_getEvolutionChain_shouldReturnCorrectData() { 91 | // Given 92 | var receivedEvolutionChain: EvolutionChainDetails? 93 | sut.onEvolutionChainDetailsUpdated = { details in 94 | receivedEvolutionChain = details 95 | } 96 | 97 | // When 98 | sut.getEvolutionChain(urlString: "https://pokeapi.co/api/v2/evolution-chain/1/") 99 | 100 | // Then 101 | XCTAssertNotNil(receivedEvolutionChain) 102 | XCTAssertEqual(receivedEvolutionChain?.chain.species.name, "bulbasaur") 103 | XCTAssertEqual(receivedEvolutionChain?.chain.evolvesTo.first?.species.name, "ivysaur") 104 | XCTAssertEqual(receivedEvolutionChain?.chain.evolvesTo.first?.evolvesTo.first?.species.name, "venusaur") 105 | } 106 | } 107 | 108 | // MARK: - Mocks 109 | 110 | class MockDetailsService: DetailsServicing { 111 | var API: APIRequesting 112 | var getSpeciesDetailsCalled = false 113 | var getEvolutionChainDetailsCalled = false 114 | 115 | init(API: APIRequesting) { 116 | self.API = API 117 | } 118 | 119 | func getSpeciesDetails(id: Int, completion: @escaping (Result) -> Void) { 120 | getSpeciesDetailsCalled = true 121 | API.getSpeciesDetails(id: id, completion: completion) 122 | } 123 | 124 | func getEvolutionChainDetails(urlString: String, completion: @escaping (Result) -> Void) { 125 | getEvolutionChainDetailsCalled = true 126 | API.getEvolutionChain(urlString: urlString, completion: completion) 127 | } 128 | } 129 | 130 | -------------------------------------------------------------------------------- /Pokemon Challenge Tests/Scenes/Pokemons/PokemonsViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PokemonsViewModelTests.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 20/01/25. 6 | // 7 | 8 | import XCTest 9 | @testable import Pokemon_Challenge 10 | 11 | final class PokemonsViewModelTests: XCTestCase { 12 | 13 | private var sut: PokemonsViewModel! 14 | private var mockService: MockPokemonsService! 15 | private var mockCoordinator: MockPokemonsCoordinator! 16 | 17 | override func setUp() { 18 | super.setUp() 19 | mockService = MockPokemonsService() 20 | mockCoordinator = MockPokemonsCoordinator() 21 | sut = PokemonsViewModel(service: mockService, coordinator: mockCoordinator) 22 | } 23 | 24 | override func tearDown() { 25 | sut = nil 26 | mockService = nil 27 | mockCoordinator = nil 28 | super.tearDown() 29 | } 30 | 31 | func test_viewDidLoad_shouldResetOffsetAndPokemons() { 32 | // Given 33 | sut.offset = 100 34 | sut.pokemons = [Species(id: nil, name: "test", url: "test")] 35 | 36 | // When 37 | sut.viewDidLoad() 38 | 39 | // Then 40 | XCTAssertEqual(sut.offset, 0) 41 | XCTAssertTrue(sut.pokemons.isEmpty) 42 | } 43 | 44 | func test_getSpecies_whenSuccessful_shouldUpdatePokemons() { 45 | // Given 46 | let expectation = XCTestExpectation(description: "Pokemon list updated") 47 | let mockSpecies = [Species(id: nil, name: "bulbasaur", url: "url1")] 48 | mockService.mockResult = .success(SpeciesResponse(count: 1, results: mockSpecies)) 49 | 50 | var updatedPokemons: [Species]? 51 | sut.onPokemonsUpdated = { pokemons in 52 | updatedPokemons = pokemons 53 | expectation.fulfill() 54 | } 55 | 56 | // When 57 | sut.getSpecies(offset: 0, limit: 20) 58 | 59 | // Then 60 | wait(for: [expectation], timeout: 1.0) 61 | XCTAssertEqual(updatedPokemons?.count, 1) 62 | XCTAssertEqual(updatedPokemons?.first?.name, "bulbasaur") 63 | } 64 | 65 | func test_getSpecies_whenFailure_shouldNotUpdatePokemons() { 66 | // Given 67 | mockService.mockResult = .failure(.unknown) 68 | 69 | // When 70 | sut.getSpecies(offset: 0, limit: 20) 71 | 72 | // Then 73 | XCTAssertTrue(sut.pokemons.isEmpty) 74 | } 75 | 76 | func test_getSpecies_withNilOffset_shouldIncrementCurrentOffset() { 77 | // Given 78 | sut.offset = 20 79 | let expectation = XCTestExpectation(description: "Service called") 80 | 81 | // When 82 | sut.getSpecies(offset: nil, limit: nil) 83 | 84 | // Then 85 | XCTAssertEqual(sut.offset, 40) 86 | expectation.fulfill() 87 | wait(for: [expectation], timeout: 1.0) 88 | } 89 | 90 | func test_getSpecies_withProvidedOffset_shouldUseProvidedOffset() { 91 | // Given 92 | sut.offset = 20 93 | let expectation = XCTestExpectation(description: "Service called") 94 | 95 | // When 96 | sut.getSpecies(offset: 50, limit: nil) 97 | 98 | // Then 99 | XCTAssertEqual(sut.offset, 50) 100 | expectation.fulfill() 101 | wait(for: [expectation], timeout: 1.0) 102 | } 103 | 104 | func test_goToDetails_shouldCallCoordinator() { 105 | // Given 106 | let pokemon = Species(id: nil, name: "pikachu", url: "test-url") 107 | 108 | // When 109 | sut.goToDetails(pokemon: pokemon) 110 | 111 | // Then 112 | XCTAssertEqual(mockCoordinator.pokemonPassedToDetails?.name, "pikachu") 113 | XCTAssertEqual(mockCoordinator.pokemonPassedToDetails?.url, "test-url") 114 | } 115 | 116 | func test_getSpecies_whenNoConnectionError_shouldCallOnRequestErrorWithCorrectMessage() { 117 | // Given 118 | let expectation = XCTestExpectation(description: "Error callback called") 119 | mockService.mockResult = .failure(.noConnection) 120 | var receivedErrorMessage: String? 121 | 122 | sut.onRequestError = { message in 123 | receivedErrorMessage = message 124 | expectation.fulfill() 125 | } 126 | 127 | // When 128 | sut.getSpecies(offset: 0, limit: 20) 129 | 130 | // Then 131 | wait(for: [expectation], timeout: 1.0) 132 | XCTAssertEqual(receivedErrorMessage, "No internet connection. Please check your network and try again.") 133 | } 134 | 135 | func test_getSpecies_whenNotFoundError_shouldCallOnRequestErrorWithCorrectMessage() { 136 | // Given 137 | let expectation = XCTestExpectation(description: "Error callback called") 138 | mockService.mockResult = .failure(.notFound) 139 | var receivedErrorMessage: String? 140 | 141 | sut.onRequestError = { message in 142 | receivedErrorMessage = message 143 | expectation.fulfill() 144 | } 145 | 146 | // When 147 | sut.getSpecies(offset: 0, limit: 20) 148 | 149 | // Then 150 | wait(for: [expectation], timeout: 1.0) 151 | XCTAssertEqual(receivedErrorMessage, "Pokemon data not found. Please try again later.") 152 | } 153 | 154 | func test_getSpecies_whenUnknownError_shouldCallOnRequestErrorWithCorrectMessage() { 155 | // Given 156 | let expectation = XCTestExpectation(description: "Error callback called") 157 | mockService.mockResult = .failure(.unknown) 158 | var receivedErrorMessage: String? 159 | 160 | sut.onRequestError = { message in 161 | receivedErrorMessage = message 162 | expectation.fulfill() 163 | } 164 | 165 | // When 166 | sut.getSpecies(offset: 0, limit: 20) 167 | 168 | // Then 169 | wait(for: [expectation], timeout: 1.0) 170 | XCTAssertEqual(receivedErrorMessage, "An unexpected error occurred. Please try again later.") 171 | } 172 | } 173 | 174 | // MARK: - Mocks 175 | 176 | private class MockPokemonsService: PokemonsServicing { 177 | var API: APIRequesting = MockAPIRequests() 178 | var mockResult: Result? 179 | 180 | func getSpecies(offset: Int?, limit: Int?, completion: @escaping (Result) -> Void) { 181 | if let result = mockResult { 182 | completion(result) 183 | } 184 | } 185 | } 186 | 187 | private class MockPokemonsCoordinator: PokemonsCoordinating { 188 | var controller: UIViewController? 189 | var pokemonPassedToDetails: Species? 190 | 191 | func goToDetails(pokemon: Species) { 192 | pokemonPassedToDetails = pokemon 193 | } 194 | } 195 | 196 | -------------------------------------------------------------------------------- /Pokemon Challenge.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXContainerItemProxy section */ 10 | D679B26A2D3EE7FE00D82250 /* PBXContainerItemProxy */ = { 11 | isa = PBXContainerItemProxy; 12 | containerPortal = D649D6E82D3D765200BAB56A /* Project object */; 13 | proxyType = 1; 14 | remoteGlobalIDString = D649D6EF2D3D765200BAB56A; 15 | remoteInfo = "Pokemon Challenge"; 16 | }; 17 | /* End PBXContainerItemProxy section */ 18 | 19 | /* Begin PBXFileReference section */ 20 | D649D6F02D3D765200BAB56A /* Pokemon Challenge.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Pokemon Challenge.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 21 | D679B2662D3EE7FE00D82250 /* Pokemon Challenge Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Pokemon Challenge Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 22 | /* End PBXFileReference section */ 23 | 24 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 25 | D649D7122D3D7EE800BAB56A /* Exceptions for "Pokemon Challenge" folder in "Pokemon Challenge" target */ = { 26 | isa = PBXFileSystemSynchronizedBuildFileExceptionSet; 27 | membershipExceptions = ( 28 | Info.plist, 29 | ); 30 | target = D649D6EF2D3D765200BAB56A /* Pokemon Challenge */; 31 | }; 32 | /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ 33 | 34 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 35 | D649D6F22D3D765200BAB56A /* Pokemon Challenge */ = { 36 | isa = PBXFileSystemSynchronizedRootGroup; 37 | exceptions = ( 38 | D649D7122D3D7EE800BAB56A /* Exceptions for "Pokemon Challenge" folder in "Pokemon Challenge" target */, 39 | ); 40 | path = "Pokemon Challenge"; 41 | sourceTree = ""; 42 | }; 43 | D679B2672D3EE7FE00D82250 /* Pokemon Challenge Tests */ = { 44 | isa = PBXFileSystemSynchronizedRootGroup; 45 | path = "Pokemon Challenge Tests"; 46 | sourceTree = ""; 47 | }; 48 | /* End PBXFileSystemSynchronizedRootGroup section */ 49 | 50 | /* Begin PBXFrameworksBuildPhase section */ 51 | D649D6ED2D3D765200BAB56A /* Frameworks */ = { 52 | isa = PBXFrameworksBuildPhase; 53 | buildActionMask = 2147483647; 54 | files = ( 55 | ); 56 | runOnlyForDeploymentPostprocessing = 0; 57 | }; 58 | D679B2632D3EE7FE00D82250 /* Frameworks */ = { 59 | isa = PBXFrameworksBuildPhase; 60 | buildActionMask = 2147483647; 61 | files = ( 62 | ); 63 | runOnlyForDeploymentPostprocessing = 0; 64 | }; 65 | /* End PBXFrameworksBuildPhase section */ 66 | 67 | /* Begin PBXGroup section */ 68 | D649D6E72D3D765200BAB56A = { 69 | isa = PBXGroup; 70 | children = ( 71 | D649D6F22D3D765200BAB56A /* Pokemon Challenge */, 72 | D679B2672D3EE7FE00D82250 /* Pokemon Challenge Tests */, 73 | D649D6F12D3D765200BAB56A /* Products */, 74 | ); 75 | sourceTree = ""; 76 | }; 77 | D649D6F12D3D765200BAB56A /* Products */ = { 78 | isa = PBXGroup; 79 | children = ( 80 | D649D6F02D3D765200BAB56A /* Pokemon Challenge.app */, 81 | D679B2662D3EE7FE00D82250 /* Pokemon Challenge Tests.xctest */, 82 | ); 83 | name = Products; 84 | sourceTree = ""; 85 | }; 86 | /* End PBXGroup section */ 87 | 88 | /* Begin PBXNativeTarget section */ 89 | D649D6EF2D3D765200BAB56A /* Pokemon Challenge */ = { 90 | isa = PBXNativeTarget; 91 | buildConfigurationList = D649D6FF2D3D765400BAB56A /* Build configuration list for PBXNativeTarget "Pokemon Challenge" */; 92 | buildPhases = ( 93 | D649D6EC2D3D765200BAB56A /* Sources */, 94 | D649D6ED2D3D765200BAB56A /* Frameworks */, 95 | D649D6EE2D3D765200BAB56A /* Resources */, 96 | ); 97 | buildRules = ( 98 | ); 99 | dependencies = ( 100 | ); 101 | fileSystemSynchronizedGroups = ( 102 | D649D6F22D3D765200BAB56A /* Pokemon Challenge */, 103 | ); 104 | name = "Pokemon Challenge"; 105 | packageProductDependencies = ( 106 | ); 107 | productName = "Pokemon Challenge"; 108 | productReference = D649D6F02D3D765200BAB56A /* Pokemon Challenge.app */; 109 | productType = "com.apple.product-type.application"; 110 | }; 111 | D679B2652D3EE7FE00D82250 /* Pokemon Challenge Tests */ = { 112 | isa = PBXNativeTarget; 113 | buildConfigurationList = D679B26C2D3EE7FE00D82250 /* Build configuration list for PBXNativeTarget "Pokemon Challenge Tests" */; 114 | buildPhases = ( 115 | D679B2622D3EE7FE00D82250 /* Sources */, 116 | D679B2632D3EE7FE00D82250 /* Frameworks */, 117 | D679B2642D3EE7FE00D82250 /* Resources */, 118 | ); 119 | buildRules = ( 120 | ); 121 | dependencies = ( 122 | D679B26B2D3EE7FE00D82250 /* PBXTargetDependency */, 123 | ); 124 | fileSystemSynchronizedGroups = ( 125 | D679B2672D3EE7FE00D82250 /* Pokemon Challenge Tests */, 126 | ); 127 | name = "Pokemon Challenge Tests"; 128 | packageProductDependencies = ( 129 | ); 130 | productName = "Pokemon Challenge Tests"; 131 | productReference = D679B2662D3EE7FE00D82250 /* Pokemon Challenge Tests.xctest */; 132 | productType = "com.apple.product-type.bundle.unit-test"; 133 | }; 134 | /* End PBXNativeTarget section */ 135 | 136 | /* Begin PBXProject section */ 137 | D649D6E82D3D765200BAB56A /* Project object */ = { 138 | isa = PBXProject; 139 | attributes = { 140 | BuildIndependentTargetsInParallel = 1; 141 | LastSwiftUpdateCheck = 1610; 142 | LastUpgradeCheck = 1610; 143 | TargetAttributes = { 144 | D649D6EF2D3D765200BAB56A = { 145 | CreatedOnToolsVersion = 16.1; 146 | LastSwiftMigration = 1610; 147 | }; 148 | D679B2652D3EE7FE00D82250 = { 149 | CreatedOnToolsVersion = 16.1; 150 | TestTargetID = D649D6EF2D3D765200BAB56A; 151 | }; 152 | }; 153 | }; 154 | buildConfigurationList = D649D6EB2D3D765200BAB56A /* Build configuration list for PBXProject "Pokemon Challenge" */; 155 | developmentRegion = en; 156 | hasScannedForEncodings = 0; 157 | knownRegions = ( 158 | en, 159 | Base, 160 | ); 161 | mainGroup = D649D6E72D3D765200BAB56A; 162 | minimizedProjectReferenceProxies = 1; 163 | preferredProjectObjectVersion = 77; 164 | productRefGroup = D649D6F12D3D765200BAB56A /* Products */; 165 | projectDirPath = ""; 166 | projectRoot = ""; 167 | targets = ( 168 | D649D6EF2D3D765200BAB56A /* Pokemon Challenge */, 169 | D679B2652D3EE7FE00D82250 /* Pokemon Challenge Tests */, 170 | ); 171 | }; 172 | /* End PBXProject section */ 173 | 174 | /* Begin PBXResourcesBuildPhase section */ 175 | D649D6EE2D3D765200BAB56A /* Resources */ = { 176 | isa = PBXResourcesBuildPhase; 177 | buildActionMask = 2147483647; 178 | files = ( 179 | ); 180 | runOnlyForDeploymentPostprocessing = 0; 181 | }; 182 | D679B2642D3EE7FE00D82250 /* Resources */ = { 183 | isa = PBXResourcesBuildPhase; 184 | buildActionMask = 2147483647; 185 | files = ( 186 | ); 187 | runOnlyForDeploymentPostprocessing = 0; 188 | }; 189 | /* End PBXResourcesBuildPhase section */ 190 | 191 | /* Begin PBXSourcesBuildPhase section */ 192 | D649D6EC2D3D765200BAB56A /* Sources */ = { 193 | isa = PBXSourcesBuildPhase; 194 | buildActionMask = 2147483647; 195 | files = ( 196 | ); 197 | runOnlyForDeploymentPostprocessing = 0; 198 | }; 199 | D679B2622D3EE7FE00D82250 /* Sources */ = { 200 | isa = PBXSourcesBuildPhase; 201 | buildActionMask = 2147483647; 202 | files = ( 203 | ); 204 | runOnlyForDeploymentPostprocessing = 0; 205 | }; 206 | /* End PBXSourcesBuildPhase section */ 207 | 208 | /* Begin PBXTargetDependency section */ 209 | D679B26B2D3EE7FE00D82250 /* PBXTargetDependency */ = { 210 | isa = PBXTargetDependency; 211 | target = D649D6EF2D3D765200BAB56A /* Pokemon Challenge */; 212 | targetProxy = D679B26A2D3EE7FE00D82250 /* PBXContainerItemProxy */; 213 | }; 214 | /* End PBXTargetDependency section */ 215 | 216 | /* Begin XCBuildConfiguration section */ 217 | D649D6FD2D3D765400BAB56A /* Debug */ = { 218 | isa = XCBuildConfiguration; 219 | buildSettings = { 220 | ALWAYS_SEARCH_USER_PATHS = NO; 221 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 222 | CLANG_ANALYZER_NONNULL = YES; 223 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 224 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 225 | CLANG_ENABLE_MODULES = YES; 226 | CLANG_ENABLE_OBJC_ARC = YES; 227 | CLANG_ENABLE_OBJC_WEAK = YES; 228 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 229 | CLANG_WARN_BOOL_CONVERSION = YES; 230 | CLANG_WARN_COMMA = YES; 231 | CLANG_WARN_CONSTANT_CONVERSION = YES; 232 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 233 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 234 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 235 | CLANG_WARN_EMPTY_BODY = YES; 236 | CLANG_WARN_ENUM_CONVERSION = YES; 237 | CLANG_WARN_INFINITE_RECURSION = YES; 238 | CLANG_WARN_INT_CONVERSION = YES; 239 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 240 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 241 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 242 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 243 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 244 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 245 | CLANG_WARN_STRICT_PROTOTYPES = YES; 246 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 247 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 248 | CLANG_WARN_UNREACHABLE_CODE = YES; 249 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 250 | COPY_PHASE_STRIP = NO; 251 | DEBUG_INFORMATION_FORMAT = dwarf; 252 | ENABLE_STRICT_OBJC_MSGSEND = YES; 253 | ENABLE_TESTABILITY = YES; 254 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 255 | GCC_C_LANGUAGE_STANDARD = gnu17; 256 | GCC_DYNAMIC_NO_PIC = NO; 257 | GCC_NO_COMMON_BLOCKS = YES; 258 | GCC_OPTIMIZATION_LEVEL = 0; 259 | GCC_PREPROCESSOR_DEFINITIONS = ( 260 | "DEBUG=1", 261 | "$(inherited)", 262 | ); 263 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 264 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 265 | GCC_WARN_UNDECLARED_SELECTOR = YES; 266 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 267 | GCC_WARN_UNUSED_FUNCTION = YES; 268 | GCC_WARN_UNUSED_VARIABLE = YES; 269 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 270 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 271 | MTL_FAST_MATH = YES; 272 | ONLY_ACTIVE_ARCH = YES; 273 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 274 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 275 | }; 276 | name = Debug; 277 | }; 278 | D649D6FE2D3D765400BAB56A /* Release */ = { 279 | isa = XCBuildConfiguration; 280 | buildSettings = { 281 | ALWAYS_SEARCH_USER_PATHS = NO; 282 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 283 | CLANG_ANALYZER_NONNULL = YES; 284 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 285 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 286 | CLANG_ENABLE_MODULES = YES; 287 | CLANG_ENABLE_OBJC_ARC = YES; 288 | CLANG_ENABLE_OBJC_WEAK = YES; 289 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 290 | CLANG_WARN_BOOL_CONVERSION = YES; 291 | CLANG_WARN_COMMA = YES; 292 | CLANG_WARN_CONSTANT_CONVERSION = YES; 293 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 294 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 295 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 296 | CLANG_WARN_EMPTY_BODY = YES; 297 | CLANG_WARN_ENUM_CONVERSION = YES; 298 | CLANG_WARN_INFINITE_RECURSION = YES; 299 | CLANG_WARN_INT_CONVERSION = YES; 300 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 301 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 302 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 303 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 304 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 305 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 306 | CLANG_WARN_STRICT_PROTOTYPES = YES; 307 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 308 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 309 | CLANG_WARN_UNREACHABLE_CODE = YES; 310 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 311 | COPY_PHASE_STRIP = NO; 312 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 313 | ENABLE_NS_ASSERTIONS = NO; 314 | ENABLE_STRICT_OBJC_MSGSEND = YES; 315 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 316 | GCC_C_LANGUAGE_STANDARD = gnu17; 317 | GCC_NO_COMMON_BLOCKS = YES; 318 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 319 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 320 | GCC_WARN_UNDECLARED_SELECTOR = YES; 321 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 322 | GCC_WARN_UNUSED_FUNCTION = YES; 323 | GCC_WARN_UNUSED_VARIABLE = YES; 324 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 325 | MTL_ENABLE_DEBUG_INFO = NO; 326 | MTL_FAST_MATH = YES; 327 | SWIFT_COMPILATION_MODE = wholemodule; 328 | }; 329 | name = Release; 330 | }; 331 | D649D7002D3D765400BAB56A /* Debug */ = { 332 | isa = XCBuildConfiguration; 333 | buildSettings = { 334 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 335 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 336 | CLANG_ENABLE_MODULES = YES; 337 | CODE_SIGN_ENTITLEMENTS = ""; 338 | CODE_SIGN_STYLE = Automatic; 339 | CURRENT_PROJECT_VERSION = 1; 340 | DEVELOPMENT_ASSET_PATHS = ""; 341 | DEVELOPMENT_TEAM = ""; 342 | ENABLE_HARDENED_RUNTIME = YES; 343 | ENABLE_PREVIEWS = YES; 344 | GENERATE_INFOPLIST_FILE = YES; 345 | INFOPLIST_FILE = "$(SRCROOT)/Pokemon Challenge/Info.plist"; 346 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 347 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 348 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 349 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 350 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 351 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 352 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 353 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 354 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 355 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 356 | IPHONEOS_DEPLOYMENT_TARGET = 18.1; 357 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 358 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 359 | MACOSX_DEPLOYMENT_TARGET = 15.1; 360 | MARKETING_VERSION = 1.0; 361 | PRODUCT_BUNDLE_IDENTIFIER = "evenstaian.Pokemon-Challenge"; 362 | PRODUCT_NAME = "$(TARGET_NAME)"; 363 | SDKROOT = auto; 364 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 365 | SUPPORTS_MACCATALYST = NO; 366 | SWIFT_EMIT_LOC_STRINGS = YES; 367 | SWIFT_OBJC_BRIDGING_HEADER = ""; 368 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 369 | SWIFT_VERSION = 5.0; 370 | TARGETED_DEVICE_FAMILY = "1,2"; 371 | XROS_DEPLOYMENT_TARGET = 2.1; 372 | }; 373 | name = Debug; 374 | }; 375 | D649D7012D3D765400BAB56A /* Release */ = { 376 | isa = XCBuildConfiguration; 377 | buildSettings = { 378 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 379 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 380 | CLANG_ENABLE_MODULES = YES; 381 | CODE_SIGN_ENTITLEMENTS = ""; 382 | CODE_SIGN_STYLE = Automatic; 383 | CURRENT_PROJECT_VERSION = 1; 384 | DEVELOPMENT_ASSET_PATHS = ""; 385 | DEVELOPMENT_TEAM = ""; 386 | ENABLE_HARDENED_RUNTIME = YES; 387 | ENABLE_PREVIEWS = YES; 388 | GENERATE_INFOPLIST_FILE = YES; 389 | INFOPLIST_FILE = "$(SRCROOT)/Pokemon Challenge/Info.plist"; 390 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 391 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 392 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 393 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 394 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 395 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 396 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 397 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 398 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 399 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 400 | IPHONEOS_DEPLOYMENT_TARGET = 18.1; 401 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 402 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 403 | MACOSX_DEPLOYMENT_TARGET = 15.1; 404 | MARKETING_VERSION = 1.0; 405 | PRODUCT_BUNDLE_IDENTIFIER = "evenstaian.Pokemon-Challenge"; 406 | PRODUCT_NAME = "$(TARGET_NAME)"; 407 | SDKROOT = auto; 408 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 409 | SUPPORTS_MACCATALYST = NO; 410 | SWIFT_EMIT_LOC_STRINGS = YES; 411 | SWIFT_OBJC_BRIDGING_HEADER = ""; 412 | SWIFT_VERSION = 5.0; 413 | TARGETED_DEVICE_FAMILY = "1,2"; 414 | XROS_DEPLOYMENT_TARGET = 2.1; 415 | }; 416 | name = Release; 417 | }; 418 | D679B26D2D3EE7FE00D82250 /* Debug */ = { 419 | isa = XCBuildConfiguration; 420 | buildSettings = { 421 | BUNDLE_LOADER = "$(TEST_HOST)"; 422 | CODE_SIGN_STYLE = Automatic; 423 | CURRENT_PROJECT_VERSION = 1; 424 | DEVELOPMENT_TEAM = NKZKJG4ZLT; 425 | GENERATE_INFOPLIST_FILE = YES; 426 | IPHONEOS_DEPLOYMENT_TARGET = 18.1; 427 | MARKETING_VERSION = 1.0; 428 | PRODUCT_BUNDLE_IDENTIFIER = "evenstaian.Pokemon-Challenge-Tests"; 429 | PRODUCT_NAME = "$(TARGET_NAME)"; 430 | SDKROOT = iphoneos; 431 | SWIFT_EMIT_LOC_STRINGS = NO; 432 | SWIFT_VERSION = 5.0; 433 | TARGETED_DEVICE_FAMILY = "1,2"; 434 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Pokemon Challenge.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Pokemon Challenge"; 435 | }; 436 | name = Debug; 437 | }; 438 | D679B26E2D3EE7FE00D82250 /* Release */ = { 439 | isa = XCBuildConfiguration; 440 | buildSettings = { 441 | BUNDLE_LOADER = "$(TEST_HOST)"; 442 | CODE_SIGN_STYLE = Automatic; 443 | CURRENT_PROJECT_VERSION = 1; 444 | DEVELOPMENT_TEAM = NKZKJG4ZLT; 445 | GENERATE_INFOPLIST_FILE = YES; 446 | IPHONEOS_DEPLOYMENT_TARGET = 18.1; 447 | MARKETING_VERSION = 1.0; 448 | PRODUCT_BUNDLE_IDENTIFIER = "evenstaian.Pokemon-Challenge-Tests"; 449 | PRODUCT_NAME = "$(TARGET_NAME)"; 450 | SDKROOT = iphoneos; 451 | SWIFT_EMIT_LOC_STRINGS = NO; 452 | SWIFT_VERSION = 5.0; 453 | TARGETED_DEVICE_FAMILY = "1,2"; 454 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Pokemon Challenge.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Pokemon Challenge"; 455 | VALIDATE_PRODUCT = YES; 456 | }; 457 | name = Release; 458 | }; 459 | /* End XCBuildConfiguration section */ 460 | 461 | /* Begin XCConfigurationList section */ 462 | D649D6EB2D3D765200BAB56A /* Build configuration list for PBXProject "Pokemon Challenge" */ = { 463 | isa = XCConfigurationList; 464 | buildConfigurations = ( 465 | D649D6FD2D3D765400BAB56A /* Debug */, 466 | D649D6FE2D3D765400BAB56A /* Release */, 467 | ); 468 | defaultConfigurationIsVisible = 0; 469 | defaultConfigurationName = Release; 470 | }; 471 | D649D6FF2D3D765400BAB56A /* Build configuration list for PBXNativeTarget "Pokemon Challenge" */ = { 472 | isa = XCConfigurationList; 473 | buildConfigurations = ( 474 | D649D7002D3D765400BAB56A /* Debug */, 475 | D649D7012D3D765400BAB56A /* Release */, 476 | ); 477 | defaultConfigurationIsVisible = 0; 478 | defaultConfigurationName = Release; 479 | }; 480 | D679B26C2D3EE7FE00D82250 /* Build configuration list for PBXNativeTarget "Pokemon Challenge Tests" */ = { 481 | isa = XCConfigurationList; 482 | buildConfigurations = ( 483 | D679B26D2D3EE7FE00D82250 /* Debug */, 484 | D679B26E2D3EE7FE00D82250 /* Release */, 485 | ); 486 | defaultConfigurationIsVisible = 0; 487 | defaultConfigurationName = Release; 488 | }; 489 | /* End XCConfigurationList section */ 490 | }; 491 | rootObject = D649D6E82D3D765200BAB56A /* Project object */; 492 | } 493 | -------------------------------------------------------------------------------- /Pokemon Challenge.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Pokemon Challenge.xcodeproj/xcuserdata/evenstaian.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /Pokemon Challenge.xcodeproj/xcuserdata/evenstaian.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | Pokemon Challenge.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Pokemon Challenge/Application/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | import UIKit 9 | 10 | @main 11 | class AppDelegate: UIResponder, UIApplicationDelegate { 12 | 13 | var window: UIWindow? 14 | 15 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 16 | return true 17 | } 18 | 19 | // MARK: UISceneSession Lifecycle 20 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 21 | let configuration = UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 22 | configuration.delegateClass = SceneDelegate.self 23 | return configuration 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Pokemon Challenge/Application/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | import UIKit 9 | 10 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 11 | var window: UIWindow? 12 | private var mainNavigation: UINavigationController? 13 | 14 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 15 | guard let windowScene = (scene as? UIWindowScene) else { return } 16 | 17 | let window = UIWindow(windowScene: windowScene) 18 | 19 | let rootScene = PokemonsFactory.makeModule() 20 | let navigation = UINavigationController(rootViewController: rootScene) 21 | self.mainNavigation = navigation 22 | 23 | let launchScreen = LaunchScreenViewController() 24 | launchScreen.onFinishAnimation = { [weak self] in 25 | self?.showMainScreen() 26 | } 27 | 28 | window.rootViewController = launchScreen 29 | window.makeKeyAndVisible() 30 | self.window = window 31 | } 32 | 33 | private func showMainScreen() { 34 | guard let navigation = mainNavigation else { return } 35 | 36 | UIView.transition(with: window!, 37 | duration: 0.5, 38 | options: .transitionCrossDissolve, 39 | animations: { 40 | self.window?.rootViewController = navigation 41 | }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Pokemon Challenge/Architecture/ViewCode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewCode.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | protocol ViewCode { 9 | func setupViews() 10 | func setupConstraints() 11 | } 12 | -------------------------------------------------------------------------------- /Pokemon Challenge/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 | -------------------------------------------------------------------------------- /Pokemon Challenge/Models/EvolutionChainDetails.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EvolutionChainDetails.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | import Foundation 9 | 10 | struct EvolutionChainDetails: Decodable { 11 | let chain: ChainLink 12 | } 13 | 14 | struct ChainLink: Decodable { 15 | let species: Species 16 | let evolvesTo: [ChainLink] 17 | let evolutionDetails: [EvolutionDetails]? 18 | 19 | enum CodingKeys: String, CodingKey { 20 | case species 21 | case evolvesTo = "evolves_to" 22 | case evolutionDetails = "evolution_details" 23 | } 24 | } 25 | 26 | struct EvolutionDetails: Decodable { 27 | let minLevel: Int? 28 | let trigger: Trigger 29 | 30 | enum CodingKeys: String, CodingKey { 31 | case minLevel = "min_level" 32 | case trigger 33 | } 34 | } 35 | 36 | struct Trigger: Decodable { 37 | let name: String 38 | } 39 | -------------------------------------------------------------------------------- /Pokemon Challenge/Models/Species.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Species.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | import Foundation 9 | 10 | struct SpeciesResponse: Decodable { 11 | let count: Int 12 | let results: [Species] 13 | } 14 | 15 | struct Species: Decodable { 16 | var id: Int? 17 | let name: String 18 | let url: String 19 | } 20 | -------------------------------------------------------------------------------- /Pokemon Challenge/Models/SpeciesDetails.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SpeciesDetails.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | struct SpeciesDetails: Decodable { 9 | let name: String 10 | let evolution_chain: EvolutionChain 11 | let base_happiness: Int 12 | let capture_rate: Int 13 | let color: PokemonColor 14 | let egg_groups: [EggGroup] 15 | let genera: [Genus] 16 | let growth_rate: GrowthRate 17 | let habitat: Habitat 18 | let is_legendary: Bool 19 | let is_mythical: Bool 20 | } 21 | 22 | struct EvolutionChain: Decodable { 23 | let url: String 24 | } 25 | 26 | struct PokemonColor: Decodable { 27 | let name: String 28 | } 29 | 30 | struct EggGroup: Decodable { 31 | let name: String 32 | } 33 | 34 | struct Genus: Decodable { 35 | let genus: String 36 | let language: Language 37 | } 38 | 39 | struct Language: Decodable { 40 | let name: String 41 | } 42 | 43 | struct GrowthRate: Decodable { 44 | let name: String 45 | } 46 | 47 | struct Habitat: Decodable { 48 | let name: String 49 | } 50 | -------------------------------------------------------------------------------- /Pokemon Challenge/Network/API/ApiConstants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ApiConstants.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | struct ApiConstants { 9 | static let baseURL = "https://pokeapi.co/api/v2/" 10 | static let imagesURL = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/" 11 | } 12 | -------------------------------------------------------------------------------- /Pokemon Challenge/Network/API/ApiRequests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ApiRequests.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | import Foundation 9 | import SystemConfiguration 10 | 11 | enum ParamsType: String { 12 | case offset = "offset" 13 | case limit = "limit" 14 | } 15 | 16 | enum MethodsType: String { 17 | case GET = "GET" 18 | case POST = "POST" 19 | } 20 | 21 | class ApiRequests: APIRequesting { 22 | private func getConfiguration() -> URLSessionConfiguration { 23 | 24 | let config = URLSessionConfiguration.default 25 | config.httpAdditionalHeaders = [ 26 | "Content-Type" : "application/json"] 27 | config.requestCachePolicy = .returnCacheDataElseLoad 28 | return config 29 | } 30 | 31 | private func getSession() -> URLSession{ 32 | return URLSession(configuration: getConfiguration()) 33 | } 34 | 35 | func getSpecies(offset: Int? = 0, limit: Int? = 20, completion: @escaping (Result) -> Void) { 36 | if let request = buildRequest(endpoint: "pokemon-species", specificUrl: nil, method: .GET, offset: offset, limit: limit) { 37 | buildResponse(request: request, completion: completion) 38 | } 39 | } 40 | 41 | func getSpeciesDetails(id: Int, completion: @escaping (Result) -> Void) { 42 | if let request = buildRequest(endpoint: "pokemon-species/\(id)", specificUrl: nil, method: .GET, offset: nil, limit: nil) { 43 | buildResponse(request: request, completion: completion) 44 | } 45 | } 46 | 47 | func getEvolutionChain(urlString: String, completion: @escaping (Result) -> Void) { 48 | if let request = buildRequest(endpoint: nil, specificUrl: urlString, method: .GET, offset: nil, limit: nil) { 49 | buildResponse(request: request, completion: completion) 50 | } 51 | } 52 | } 53 | 54 | extension ApiRequests { 55 | func buildRequest(endpoint: String?, specificUrl: String?, method: MethodsType, offset: Int?, limit: Int?) -> URLRequest?{ 56 | let url = specificUrl ?? "\(ApiConstants.baseURL)\(endpoint!)" 57 | print(url) 58 | var request = URLRequest(url: URL(string: url)!) 59 | request.httpMethod = method.rawValue 60 | 61 | var queryParams : [URLQueryItem] = [] 62 | 63 | if let offset = offset { 64 | queryParams.append(URLQueryItem(name: ParamsType.offset.rawValue, value: String(offset))) 65 | } 66 | 67 | if let limit = limit { 68 | queryParams.append(URLQueryItem(name: ParamsType.limit.rawValue, value: String(limit))) 69 | } 70 | 71 | guard var components = URLComponents(string: url) else { 72 | return nil 73 | } 74 | 75 | components.queryItems = queryParams 76 | request.url = components.url 77 | 78 | return request 79 | } 80 | 81 | func buildResponse(request: URLRequest, completion: @escaping (Result) -> Void) { 82 | if !NetworkReachability.isConnectedToNetwork() { 83 | completion(.failure(.noConnection)) 84 | } 85 | 86 | let dataTask = getSession().dataTask(with: request) { data, response, error in 87 | if error == nil { 88 | 89 | do { 90 | if let data = data { 91 | let charactersResponse = try JSONDecoder().decode(T.self, from: data) 92 | completion(.success(charactersResponse)) 93 | } 94 | } catch { 95 | completion(.failure(.notFound)) 96 | } 97 | }else{ 98 | if !NetworkReachability.isConnectedToNetwork() { 99 | completion(.failure(.noConnection)) 100 | } else { 101 | completion(.failure(.notFound)) 102 | } 103 | } 104 | } 105 | 106 | dataTask.resume() 107 | } 108 | } 109 | 110 | class NetworkReachability { 111 | static func isConnectedToNetwork() -> Bool { 112 | var zeroAddress = sockaddr_in() 113 | zeroAddress.sin_len = UInt8(MemoryLayout.size(ofValue: zeroAddress)) 114 | zeroAddress.sin_family = sa_family_t(AF_INET) 115 | 116 | guard let defaultRouteReachability = withUnsafePointer(to: &zeroAddress, { 117 | $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { 118 | SCNetworkReachabilityCreateWithAddress(nil, $0) 119 | } 120 | }) else { 121 | return false 122 | } 123 | 124 | var flags: SCNetworkReachabilityFlags = [] 125 | if !SCNetworkReachabilityGetFlags(defaultRouteReachability, &flags) { 126 | return false 127 | } 128 | 129 | let isReachable = flags.contains(.reachable) 130 | let needsConnection = flags.contains(.connectionRequired) 131 | 132 | return isReachable && !needsConnection 133 | } 134 | } 135 | 136 | -------------------------------------------------------------------------------- /Pokemon Challenge/Network/API/MockAPIRequests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockAPIRequests.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | import Foundation 9 | 10 | class MockAPIRequests: APIRequesting { 11 | 12 | func getSpecies(offset: Int?, limit: Int?, completion: @escaping (Result) -> Void) { 13 | let jsonString = """ 14 | { 15 | "count": 1025, 16 | "next": "https://pokeapi.co/api/v2/pokemon-species?offset=20&limit=20", 17 | "previous": null, 18 | "results": [ 19 | { 20 | "name": "bulbasaur", 21 | "url": "https://pokeapi.co/api/v2/pokemon-species/1/" 22 | }, 23 | { 24 | "name": "ivysaur", 25 | "url": "https://pokeapi.co/api/v2/pokemon-species/2/" 26 | }, 27 | { 28 | "name": "venusaur", 29 | "url": "https://pokeapi.co/api/v2/pokemon-species/3/" 30 | }, 31 | { 32 | "name": "charmander", 33 | "url": "https://pokeapi.co/api/v2/pokemon-species/4/" 34 | }, 35 | { 36 | "name": "charmeleon", 37 | "url": "https://pokeapi.co/api/v2/pokemon-species/5/" 38 | }, 39 | { 40 | "name": "charizard", 41 | "url": "https://pokeapi.co/api/v2/pokemon-species/6/" 42 | }, 43 | { 44 | "name": "squirtle", 45 | "url": "https://pokeapi.co/api/v2/pokemon-species/7/" 46 | }, 47 | { 48 | "name": "wartortle", 49 | "url": "https://pokeapi.co/api/v2/pokemon-species/8/" 50 | }, 51 | { 52 | "name": "blastoise", 53 | "url": "https://pokeapi.co/api/v2/pokemon-species/9/" 54 | }, 55 | { 56 | "name": "caterpie", 57 | "url": "https://pokeapi.co/api/v2/pokemon-species/10/" 58 | }, 59 | { 60 | "name": "metapod", 61 | "url": "https://pokeapi.co/api/v2/pokemon-species/11/" 62 | }, 63 | { 64 | "name": "butterfree", 65 | "url": "https://pokeapi.co/api/v2/pokemon-species/12/" 66 | }, 67 | { 68 | "name": "weedle", 69 | "url": "https://pokeapi.co/api/v2/pokemon-species/13/" 70 | }, 71 | { 72 | "name": "kakuna", 73 | "url": "https://pokeapi.co/api/v2/pokemon-species/14/" 74 | }, 75 | { 76 | "name": "beedrill", 77 | "url": "https://pokeapi.co/api/v2/pokemon-species/15/" 78 | }, 79 | { 80 | "name": "pidgey", 81 | "url": "https://pokeapi.co/api/v2/pokemon-species/16/" 82 | }, 83 | { 84 | "name": "pidgeotto", 85 | "url": "https://pokeapi.co/api/v2/pokemon-species/17/" 86 | }, 87 | { 88 | "name": "pidgeot", 89 | "url": "https://pokeapi.co/api/v2/pokemon-species/18/" 90 | }, 91 | { 92 | "name": "rattata", 93 | "url": "https://pokeapi.co/api/v2/pokemon-species/19/" 94 | }, 95 | { 96 | "name": "raticate", 97 | "url": "https://pokeapi.co/api/v2/pokemon-species/20/" 98 | } 99 | ] 100 | } 101 | """ 102 | 103 | generateJSONDataCompletion(jsonString: jsonString, completion: completion) 104 | } 105 | 106 | func getSpeciesDetails(id: Int, completion: @escaping (Result) -> Void) { 107 | let jsonString = """ 108 | { 109 | "base_happiness": 50, 110 | "capture_rate": 45, 111 | "color": { 112 | "name": "green", 113 | "url": "https://pokeapi.co/api/v2/pokemon-color/5/" 114 | }, 115 | "egg_groups": [ 116 | { 117 | "name": "monster", 118 | "url": "https://pokeapi.co/api/v2/egg-group/1/" 119 | }, 120 | { 121 | "name": "plant", 122 | "url": "https://pokeapi.co/api/v2/egg-group/7/" 123 | } 124 | ], 125 | "evolution_chain": { 126 | "url": "https://pokeapi.co/api/v2/evolution-chain/1/" 127 | }, 128 | "evolves_from_species": null, 129 | "form_descriptions": [], 130 | "forms_switchable": false, 131 | "gender_rate": 1, 132 | "genera": [ 133 | { 134 | "genus": "たねポケモン", 135 | "language": { 136 | "name": "ja-Hrkt", 137 | "url": "https://pokeapi.co/api/v2/language/1/" 138 | } 139 | }, 140 | { 141 | "genus": "씨앗포켓몬", 142 | "language": { 143 | "name": "ko", 144 | "url": "https://pokeapi.co/api/v2/language/3/" 145 | } 146 | }, 147 | { 148 | "genus": "種子寶可夢", 149 | "language": { 150 | "name": "zh-Hant", 151 | "url": "https://pokeapi.co/api/v2/language/4/" 152 | } 153 | }, 154 | { 155 | "genus": "Pokémon Graine", 156 | "language": { 157 | "name": "fr", 158 | "url": "https://pokeapi.co/api/v2/language/5/" 159 | } 160 | }, 161 | { 162 | "genus": "Samen-Pokémon", 163 | "language": { 164 | "name": "de", 165 | "url": "https://pokeapi.co/api/v2/language/6/" 166 | } 167 | }, 168 | { 169 | "genus": "Pokémon Semilla", 170 | "language": { 171 | "name": "es", 172 | "url": "https://pokeapi.co/api/v2/language/7/" 173 | } 174 | }, 175 | { 176 | "genus": "Pokémon Seme", 177 | "language": { 178 | "name": "it", 179 | "url": "https://pokeapi.co/api/v2/language/8/" 180 | } 181 | }, 182 | { 183 | "genus": "Seed Pokémon", 184 | "language": { 185 | "name": "en", 186 | "url": "https://pokeapi.co/api/v2/language/9/" 187 | } 188 | }, 189 | { 190 | "genus": "たねポケモン", 191 | "language": { 192 | "name": "ja", 193 | "url": "https://pokeapi.co/api/v2/language/11/" 194 | } 195 | }, 196 | { 197 | "genus": "种子宝可梦", 198 | "language": { 199 | "name": "zh-Hans", 200 | "url": "https://pokeapi.co/api/v2/language/12/" 201 | } 202 | } 203 | ], 204 | "generation": { 205 | "name": "generation-i", 206 | "url": "https://pokeapi.co/api/v2/generation/1/" 207 | }, 208 | "growth_rate": { 209 | "name": "medium-slow", 210 | "url": "https://pokeapi.co/api/v2/growth-rate/4/" 211 | }, 212 | "habitat": { 213 | "name": "grassland", 214 | "url": "https://pokeapi.co/api/v2/pokemon-habitat/3/" 215 | }, 216 | "has_gender_differences": false, 217 | "hatch_counter": 20, 218 | "id": 1, 219 | "is_baby": false, 220 | "is_legendary": false, 221 | "is_mythical": false, 222 | "name": "bulbasaur", 223 | "names": [ 224 | { 225 | "language": { 226 | "name": "ja-Hrkt", 227 | "url": "https://pokeapi.co/api/v2/language/1/" 228 | }, 229 | "name": "フシギダネ" 230 | }, 231 | { 232 | "language": { 233 | "name": "roomaji", 234 | "url": "https://pokeapi.co/api/v2/language/2/" 235 | }, 236 | "name": "Fushigidane" 237 | }, 238 | { 239 | "language": { 240 | "name": "ko", 241 | "url": "https://pokeapi.co/api/v2/language/3/" 242 | }, 243 | "name": "이상해씨" 244 | }, 245 | { 246 | "language": { 247 | "name": "zh-Hant", 248 | "url": "https://pokeapi.co/api/v2/language/4/" 249 | }, 250 | "name": "妙蛙種子" 251 | }, 252 | { 253 | "language": { 254 | "name": "fr", 255 | "url": "https://pokeapi.co/api/v2/language/5/" 256 | }, 257 | "name": "Bulbizarre" 258 | }, 259 | { 260 | "language": { 261 | "name": "de", 262 | "url": "https://pokeapi.co/api/v2/language/6/" 263 | }, 264 | "name": "Bisasam" 265 | }, 266 | { 267 | "language": { 268 | "name": "es", 269 | "url": "https://pokeapi.co/api/v2/language/7/" 270 | }, 271 | "name": "Bulbasaur" 272 | }, 273 | { 274 | "language": { 275 | "name": "it", 276 | "url": "https://pokeapi.co/api/v2/language/8/" 277 | }, 278 | "name": "Bulbasaur" 279 | }, 280 | { 281 | "language": { 282 | "name": "en", 283 | "url": "https://pokeapi.co/api/v2/language/9/" 284 | }, 285 | "name": "Bulbasaur" 286 | }, 287 | { 288 | "language": { 289 | "name": "ja", 290 | "url": "https://pokeapi.co/api/v2/language/11/" 291 | }, 292 | "name": "フシギダネ" 293 | }, 294 | { 295 | "language": { 296 | "name": "zh-Hans", 297 | "url": "https://pokeapi.co/api/v2/language/12/" 298 | }, 299 | "name": "妙蛙种子" 300 | } 301 | ], 302 | "order": 1, 303 | "pal_park_encounters": [ 304 | { 305 | "area": { 306 | "name": "field", 307 | "url": "https://pokeapi.co/api/v2/pal-park-area/2/" 308 | }, 309 | "base_score": 50, 310 | "rate": 30 311 | } 312 | ], 313 | "pokedex_numbers": [ 314 | { 315 | "entry_number": 1, 316 | "pokedex": { 317 | "name": "national", 318 | "url": "https://pokeapi.co/api/v2/pokedex/1/" 319 | } 320 | }, 321 | { 322 | "entry_number": 1, 323 | "pokedex": { 324 | "name": "kanto", 325 | "url": "https://pokeapi.co/api/v2/pokedex/2/" 326 | } 327 | }, 328 | { 329 | "entry_number": 226, 330 | "pokedex": { 331 | "name": "original-johto", 332 | "url": "https://pokeapi.co/api/v2/pokedex/3/" 333 | } 334 | }, 335 | { 336 | "entry_number": 231, 337 | "pokedex": { 338 | "name": "updated-johto", 339 | "url": "https://pokeapi.co/api/v2/pokedex/7/" 340 | } 341 | }, 342 | { 343 | "entry_number": 80, 344 | "pokedex": { 345 | "name": "kalos-central", 346 | "url": "https://pokeapi.co/api/v2/pokedex/12/" 347 | } 348 | }, 349 | { 350 | "entry_number": 1, 351 | "pokedex": { 352 | "name": "letsgo-kanto", 353 | "url": "https://pokeapi.co/api/v2/pokedex/26/" 354 | } 355 | }, 356 | { 357 | "entry_number": 68, 358 | "pokedex": { 359 | "name": "isle-of-armor", 360 | "url": "https://pokeapi.co/api/v2/pokedex/28/" 361 | } 362 | }, 363 | { 364 | "entry_number": 164, 365 | "pokedex": { 366 | "name": "blueberry", 367 | "url": "https://pokeapi.co/api/v2/pokedex/33/" 368 | } 369 | } 370 | ], 371 | "shape": { 372 | "name": "quadruped", 373 | "url": "https://pokeapi.co/api/v2/pokemon-shape/8/" 374 | }, 375 | "varieties": [ 376 | { 377 | "is_default": true, 378 | "pokemon": { 379 | "name": "bulbasaur", 380 | "url": "https://pokeapi.co/api/v2/pokemon/1/" 381 | } 382 | } 383 | ] 384 | } 385 | """ 386 | 387 | generateJSONDataCompletion(jsonString: jsonString, completion: completion) 388 | } 389 | 390 | func getEvolutionChain(urlString: String, completion: @escaping (Result) -> Void) { 391 | let jsonString = """ 392 | { 393 | "baby_trigger_item": null, 394 | "chain": { 395 | "evolution_details": [], 396 | "evolves_to": [ 397 | { 398 | "evolution_details": [ 399 | { 400 | "gender": null, 401 | "held_item": null, 402 | "item": null, 403 | "known_move": null, 404 | "known_move_type": null, 405 | "location": null, 406 | "min_affection": null, 407 | "min_beauty": null, 408 | "min_happiness": null, 409 | "min_level": 16, 410 | "needs_overworld_rain": false, 411 | "party_species": null, 412 | "party_type": null, 413 | "relative_physical_stats": null, 414 | "time_of_day": "", 415 | "trade_species": null, 416 | "trigger": { 417 | "name": "level-up", 418 | "url": "https://pokeapi.co/api/v2/evolution-trigger/1/" 419 | }, 420 | "turn_upside_down": false 421 | } 422 | ], 423 | "evolves_to": [ 424 | { 425 | "evolution_details": [ 426 | { 427 | "gender": null, 428 | "held_item": null, 429 | "item": null, 430 | "known_move": null, 431 | "known_move_type": null, 432 | "location": null, 433 | "min_affection": null, 434 | "min_beauty": null, 435 | "min_happiness": null, 436 | "min_level": 32, 437 | "needs_overworld_rain": false, 438 | "party_species": null, 439 | "party_type": null, 440 | "relative_physical_stats": null, 441 | "time_of_day": "", 442 | "trade_species": null, 443 | "trigger": { 444 | "name": "level-up", 445 | "url": "https://pokeapi.co/api/v2/evolution-trigger/1/" 446 | }, 447 | "turn_upside_down": false 448 | } 449 | ], 450 | "evolves_to": [], 451 | "is_baby": false, 452 | "species": { 453 | "name": "venusaur", 454 | "url": "https://pokeapi.co/api/v2/pokemon-species/3/" 455 | } 456 | } 457 | ], 458 | "is_baby": false, 459 | "species": { 460 | "name": "ivysaur", 461 | "url": "https://pokeapi.co/api/v2/pokemon-species/2/" 462 | } 463 | } 464 | ], 465 | "is_baby": false, 466 | "species": { 467 | "name": "bulbasaur", 468 | "url": "https://pokeapi.co/api/v2/pokemon-species/1/" 469 | } 470 | }, 471 | "id": 1 472 | } 473 | """ 474 | 475 | generateJSONDataCompletion(jsonString: jsonString, completion: completion) 476 | } 477 | 478 | func generateJSONDataCompletion(jsonString: String, completion: @escaping (Result) -> Void) { 479 | let jsonData = jsonString.data(using: .utf8)! 480 | 481 | do { 482 | let response = try JSONDecoder().decode(T.self, from: jsonData) 483 | completion(.success(response)) 484 | } catch let error { 485 | print(error) 486 | completion(.failure(.unknown)) 487 | } 488 | } 489 | } 490 | 491 | -------------------------------------------------------------------------------- /Pokemon Challenge/Network/Abstractions/APIRequesting.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIRequesting.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | protocol APIRequesting { 9 | func getSpecies(offset: Int?, limit: Int?, completion: @escaping (Result) -> Void) 10 | func getSpeciesDetails(id: Int, completion: @escaping (Result) -> Void) 11 | func getEvolutionChain(urlString: String, completion: @escaping (Result) -> Void) 12 | } 13 | -------------------------------------------------------------------------------- /Pokemon Challenge/Network/Cache/ImageStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageStore.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 20/01/25. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | class ImageStore{ 12 | 13 | typealias IsCached = Bool 14 | 15 | fileprivate let imageCache = NSCache() 16 | 17 | func fetch(for url:URL, completion: @escaping (UIImage?, IsCached) -> Void) { 18 | 19 | if let cachedImage = imageCache.object(forKey: url.absoluteString as NSString) { 20 | 21 | completion(cachedImage, true) 22 | return 23 | } 24 | 25 | URLSession.shared.dataTask(with: url) { (data, response, error) in 26 | 27 | if error != nil { 28 | 29 | print(error!) 30 | completion(nil, false) 31 | return 32 | } 33 | 34 | if let data = data { 35 | 36 | if let image = UIImage(data: data) { 37 | 38 | self.imageCache.setObject(image, forKey: url.absoluteString as NSString) 39 | completion(image, false) 40 | return 41 | } 42 | 43 | completion(nil, false) 44 | } 45 | 46 | }.resume() 47 | } 48 | 49 | func invalidate() { 50 | 51 | self.imageCache.removeAllObjects() 52 | } 53 | 54 | fileprivate func inject(image: UIImage, forKey key: String) { 55 | 56 | imageCache.setObject(image, forKey: key as NSString) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Pokemon Challenge/Network/Enums/NetworkErrors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkErrors.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | enum NetworkErrors: Error { 9 | case notFound 10 | case noConnection 11 | case unknown 12 | } 13 | -------------------------------------------------------------------------------- /Pokemon Challenge/Scenes/Details/Components/DetailsAttributesComponent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailsAttributesComponent.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | import UIKit 9 | 10 | class DetailsAttributesComponent: UIView { 11 | private lazy var scrollView: UIScrollView = { 12 | let scroll = UIScrollView() 13 | scroll.showsVerticalScrollIndicator = true 14 | scroll.alwaysBounceVertical = true 15 | scroll.translatesAutoresizingMaskIntoConstraints = false 16 | return scroll 17 | }() 18 | 19 | private lazy var contentStack: UIStackView = { 20 | let stack = UIStackView() 21 | stack.translatesAutoresizingMaskIntoConstraints = false 22 | stack.axis = .vertical 23 | stack.spacing = 16 24 | return stack 25 | }() 26 | 27 | private lazy var messageLabel: UILabel = { 28 | let label = UILabel() 29 | label.translatesAutoresizingMaskIntoConstraints = false 30 | label.font = .systemFont(ofSize: 16) 31 | label.textColor = .secondaryLabel 32 | label.numberOfLines = 0 33 | label.text = "Learn more about this amazing Pokémon!" 34 | return label 35 | }() 36 | 37 | override init(frame: CGRect) { 38 | super.init(frame: frame) 39 | setupViews() 40 | } 41 | 42 | required init?(coder: NSCoder) { 43 | fatalError("init(coder:) has not been implemented") 44 | } 45 | 46 | func configure(with details: SpeciesDetails) { 47 | contentStack.arrangedSubviews.forEach { $0.removeFromSuperview() } 48 | 49 | messageLabel.textColor = .systemGray 50 | contentStack.addArrangedSubview(messageLabel) 51 | 52 | let separator = createSeparator() 53 | contentStack.addArrangedSubview(separator) 54 | 55 | addAttribute(title: "Base Happiness", value: "\(details.base_happiness)") 56 | addAttribute(title: "Capture Rate", value: "\(details.capture_rate)") 57 | addAttribute(title: "Color", value: details.color.name.capitalized) 58 | addAttribute(title: "Egg Groups", value: details.egg_groups.map { $0.name.capitalized }.joined(separator: ", ")) 59 | addAttribute(title: "Growth Rate", value: details.growth_rate.name.capitalized) 60 | addAttribute(title: "Habitat", value: details.habitat.name.capitalized) 61 | 62 | if details.is_legendary { 63 | addAttribute(title: "Type", value: "Legendary") 64 | } else if details.is_mythical { 65 | addAttribute(title: "Type", value: "Mythical") 66 | } else { 67 | addAttribute(title: "Type", value: "Normal") 68 | } 69 | 70 | if let englishGenus = details.genera.first(where: { $0.language.name == "en" }) { 71 | addAttribute(title: "Classification", value: englishGenus.genus) 72 | } 73 | } 74 | 75 | private func createSeparator() -> UIView { 76 | let separator = UIView() 77 | separator.backgroundColor = .systemGray5 78 | separator.translatesAutoresizingMaskIntoConstraints = false 79 | separator.heightAnchor.constraint(equalToConstant: 1).isActive = true 80 | return separator 81 | } 82 | 83 | private func addAttribute(title: String, value: String) { 84 | let container = UIView() 85 | container.translatesAutoresizingMaskIntoConstraints = false 86 | 87 | let titleLabel = UILabel() 88 | titleLabel.translatesAutoresizingMaskIntoConstraints = false 89 | titleLabel.font = .systemFont(ofSize: 14, weight: .medium) 90 | titleLabel.textColor = .systemGray 91 | titleLabel.text = title 92 | 93 | let valueLabel = UILabel() 94 | valueLabel.translatesAutoresizingMaskIntoConstraints = false 95 | valueLabel.font = .systemFont(ofSize: 16, weight: .semibold) 96 | valueLabel.textColor = .black 97 | valueLabel.text = value 98 | valueLabel.numberOfLines = 0 99 | 100 | container.addSubview(titleLabel) 101 | container.addSubview(valueLabel) 102 | 103 | NSLayoutConstraint.activate([ 104 | titleLabel.topAnchor.constraint(equalTo: container.topAnchor), 105 | titleLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor), 106 | titleLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor), 107 | 108 | valueLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4), 109 | valueLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor), 110 | valueLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor), 111 | valueLabel.bottomAnchor.constraint(equalTo: container.bottomAnchor) 112 | ]) 113 | 114 | contentStack.addArrangedSubview(container) 115 | } 116 | } 117 | 118 | extension DetailsAttributesComponent: ViewCode { 119 | func setupViews() { 120 | backgroundColor = .white 121 | layer.cornerRadius = 16 122 | layer.borderWidth = 1 123 | layer.borderColor = UIColor.systemGray5.cgColor 124 | 125 | layer.shadowColor = UIColor.black.cgColor 126 | layer.shadowOffset = CGSize(width: 0, height: 2) 127 | layer.shadowRadius = 6 128 | layer.shadowOpacity = 0.1 129 | 130 | addSubview(scrollView) 131 | scrollView.addSubview(contentStack) 132 | 133 | setupConstraints() 134 | } 135 | 136 | func setupConstraints() { 137 | NSLayoutConstraint.activate([ 138 | 139 | scrollView.topAnchor.constraint(equalTo: topAnchor), 140 | scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), 141 | scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), 142 | scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), 143 | 144 | contentStack.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: 16), 145 | contentStack.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 16), 146 | contentStack.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -16), 147 | contentStack.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -16), 148 | 149 | contentStack.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -32) 150 | ]) 151 | } 152 | } 153 | 154 | -------------------------------------------------------------------------------- /Pokemon Challenge/Scenes/Details/Components/DetailsEvolutionChain.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailsEvolutionChain.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | import UIKit 9 | 10 | class DetailsEvolutionChain: UIView { 11 | 12 | private lazy var scrollView: UIScrollView = { 13 | let scroll = UIScrollView() 14 | scroll.showsHorizontalScrollIndicator = false 15 | scroll.translatesAutoresizingMaskIntoConstraints = false 16 | return scroll 17 | }() 18 | 19 | private lazy var stackView: UIStackView = { 20 | let stack = UIStackView() 21 | stack.axis = .horizontal 22 | stack.distribution = .equalSpacing 23 | stack.alignment = .center 24 | stack.spacing = 8 25 | stack.translatesAutoresizingMaskIntoConstraints = false 26 | return stack 27 | }() 28 | 29 | private lazy var titleLabel: UILabel = { 30 | let label = UILabel() 31 | label.text = "Evolution Chain" 32 | label.font = .systemFont(ofSize: 20, weight: .bold) 33 | label.textColor = .white 34 | label.translatesAutoresizingMaskIntoConstraints = false 35 | return label 36 | }() 37 | 38 | override init(frame: CGRect) { 39 | super.init(frame: frame) 40 | setupViews() 41 | } 42 | 43 | required init?(coder: NSCoder) { 44 | fatalError("init(coder:) has not been implemented") 45 | } 46 | 47 | func configure(with chain: ChainLink) { 48 | stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } 49 | 50 | addPokemonToChain(pokemon: chain.species) 51 | 52 | var currentLink = chain 53 | while let nextEvolution = currentLink.evolvesTo.first { 54 | let levelText = nextEvolution.evolutionDetails?.first?.minLevel != nil ? 55 | "Lvl \(nextEvolution.evolutionDetails!.first!.minLevel!)" : "???" 56 | addArrow(withText: levelText) 57 | 58 | addPokemonToChain(pokemon: nextEvolution.species) 59 | currentLink = nextEvolution 60 | } 61 | } 62 | 63 | private func addPokemonToChain(pokemon: Species) { 64 | let container = UIView() 65 | container.translatesAutoresizingMaskIntoConstraints = false 66 | 67 | let imageView = UIImageView() 68 | imageView.contentMode = .scaleAspectFit 69 | imageView.translatesAutoresizingMaskIntoConstraints = false 70 | if let id = Extracters.extractPokemonId(from: pokemon.url) { 71 | let imageStore = ImageStore() 72 | let imageUrlString = "\(ApiConstants.imagesURL)\(id).png" 73 | if let imageUrl = URL(string: imageUrlString) { 74 | imageStore.fetch(for: imageUrl) { [weak self] imageData, _ in 75 | DispatchQueue.main.async { 76 | imageView.image = imageData 77 | } 78 | } 79 | } 80 | } 81 | 82 | let nameLabel = UILabel() 83 | nameLabel.text = pokemon.name.capitalized 84 | nameLabel.font = .systemFont(ofSize: 14, weight: .medium) 85 | nameLabel.textAlignment = .center 86 | nameLabel.textColor = .white 87 | nameLabel.translatesAutoresizingMaskIntoConstraints = false 88 | 89 | container.addSubview(imageView) 90 | container.addSubview(nameLabel) 91 | 92 | NSLayoutConstraint.activate([ 93 | imageView.topAnchor.constraint(equalTo: container.topAnchor), 94 | imageView.centerXAnchor.constraint(equalTo: container.centerXAnchor), 95 | imageView.widthAnchor.constraint(equalToConstant: 80), 96 | imageView.heightAnchor.constraint(equalToConstant: 80), 97 | 98 | nameLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 4), 99 | nameLabel.centerXAnchor.constraint(equalTo: container.centerXAnchor), 100 | nameLabel.bottomAnchor.constraint(equalTo: container.bottomAnchor), 101 | 102 | container.widthAnchor.constraint(equalToConstant: 100) 103 | ]) 104 | 105 | stackView.addArrangedSubview(container) 106 | } 107 | 108 | private func addArrow(withText text: String) { 109 | let container = UIView() 110 | container.translatesAutoresizingMaskIntoConstraints = false 111 | 112 | let arrowLabel = UILabel() 113 | arrowLabel.text = "→" 114 | arrowLabel.font = .systemFont(ofSize: 24, weight: .bold) 115 | arrowLabel.textColor = .white 116 | arrowLabel.translatesAutoresizingMaskIntoConstraints = false 117 | 118 | let levelLabel = UILabel() 119 | levelLabel.text = text 120 | levelLabel.font = .systemFont(ofSize: 12) 121 | levelLabel.textAlignment = .center 122 | levelLabel.textColor = .white 123 | levelLabel.translatesAutoresizingMaskIntoConstraints = false 124 | 125 | container.addSubview(arrowLabel) 126 | container.addSubview(levelLabel) 127 | 128 | NSLayoutConstraint.activate([ 129 | arrowLabel.centerYAnchor.constraint(equalTo: container.centerYAnchor, constant: -10), 130 | arrowLabel.centerXAnchor.constraint(equalTo: container.centerXAnchor), 131 | 132 | levelLabel.topAnchor.constraint(equalTo: arrowLabel.bottomAnchor), 133 | levelLabel.centerXAnchor.constraint(equalTo: container.centerXAnchor), 134 | 135 | container.widthAnchor.constraint(equalToConstant: 40) 136 | ]) 137 | 138 | stackView.addArrangedSubview(container) 139 | } 140 | } 141 | 142 | extension DetailsEvolutionChain: ViewCode { 143 | func setupViews() { 144 | backgroundColor = .black.withAlphaComponent(0.7) 145 | layer.cornerRadius = 12 146 | layer.borderWidth = 1 147 | layer.borderColor = UIColor.white.withAlphaComponent(0.3).cgColor 148 | 149 | let blurEffect = UIBlurEffect(style: .dark) 150 | let blurView = UIVisualEffectView(effect: blurEffect) 151 | blurView.translatesAutoresizingMaskIntoConstraints = false 152 | addSubview(blurView) 153 | 154 | NSLayoutConstraint.activate([ 155 | blurView.topAnchor.constraint(equalTo: topAnchor), 156 | blurView.leadingAnchor.constraint(equalTo: leadingAnchor), 157 | blurView.trailingAnchor.constraint(equalTo: trailingAnchor), 158 | blurView.bottomAnchor.constraint(equalTo: bottomAnchor) 159 | ]) 160 | 161 | addSubview(titleLabel) 162 | addSubview(scrollView) 163 | scrollView.addSubview(stackView) 164 | 165 | setupConstraints() 166 | } 167 | 168 | func setupConstraints() { 169 | NSLayoutConstraint.activate([ 170 | titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 16), 171 | titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), 172 | 173 | scrollView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 16), 174 | scrollView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), 175 | scrollView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), 176 | scrollView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16), 177 | 178 | stackView.topAnchor.constraint(equalTo: scrollView.topAnchor), 179 | stackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), 180 | stackView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), 181 | stackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), 182 | stackView.heightAnchor.constraint(equalTo: scrollView.heightAnchor) 183 | ]) 184 | } 185 | } 186 | 187 | -------------------------------------------------------------------------------- /Pokemon Challenge/Scenes/Details/Components/DetailsHeaderComponent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailsHeaderComponent.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | import UIKit 9 | 10 | class DetailsHeaderComponent: UIView { 11 | private let imageSize: CGFloat = 120 12 | private let headerHeight: CGFloat = 152 13 | 14 | private lazy var pokemonImageView: UIImageView = { 15 | let imageView = UIImageView() 16 | imageView.translatesAutoresizingMaskIntoConstraints = false 17 | imageView.layer.cornerRadius = imageSize / 2 18 | imageView.clipsToBounds = true 19 | imageView.contentMode = .scaleAspectFill 20 | imageView.backgroundColor = .systemGray6 21 | return imageView 22 | }() 23 | 24 | private lazy var nameLabel: UILabel = { 25 | let label = UILabel() 26 | label.translatesAutoresizingMaskIntoConstraints = false 27 | label.font = .systemFont(ofSize: 24, weight: .bold) 28 | label.textColor = .label 29 | return label 30 | }() 31 | 32 | override init(frame: CGRect) { 33 | super.init(frame: frame) 34 | setupViews() 35 | } 36 | 37 | required init?(coder: NSCoder) { 38 | fatalError("init(coder:) has not been implemented") 39 | } 40 | 41 | func configure(with pokemon: Species) { 42 | nameLabel.text = pokemon.name.capitalized 43 | if let id = pokemon.id { 44 | let imageStore = ImageStore() 45 | let imageUrlString = "\(ApiConstants.imagesURL)\(id).png" 46 | if let imageUrl = URL(string: imageUrlString) { 47 | imageStore.fetch(for: imageUrl) { [weak self] imageData, _ in 48 | DispatchQueue.main.async { 49 | self?.pokemonImageView.image = imageData 50 | } 51 | } 52 | } 53 | } 54 | } 55 | } 56 | 57 | extension DetailsHeaderComponent: ViewCode { 58 | func setupViews() { 59 | backgroundColor = .systemBackground 60 | addSubview(pokemonImageView) 61 | addSubview(nameLabel) 62 | 63 | setupConstraints() 64 | } 65 | 66 | func setupConstraints() { 67 | NSLayoutConstraint.activate([ 68 | heightAnchor.constraint(equalToConstant: headerHeight), 69 | 70 | pokemonImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), 71 | pokemonImageView.topAnchor.constraint(equalTo: topAnchor, constant: 16), 72 | pokemonImageView.widthAnchor.constraint(equalToConstant: imageSize), 73 | pokemonImageView.heightAnchor.constraint(equalToConstant: imageSize), 74 | 75 | nameLabel.leadingAnchor.constraint(equalTo: pokemonImageView.trailingAnchor, constant: 16), 76 | nameLabel.centerYAnchor.constraint(equalTo: pokemonImageView.centerYAnchor), 77 | nameLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16) 78 | ]) 79 | } 80 | } 81 | 82 | -------------------------------------------------------------------------------- /Pokemon Challenge/Scenes/Details/DetailsFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailsFactory.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | import UIKit 9 | 10 | enum DetailsFactory { 11 | static func makeModule(pokemon: Species) -> UIViewController { 12 | let API = ApiRequests() 13 | let service = DetailsService(API: API) 14 | let viewModel = DetailsViewModel(pokemon: pokemon, service: service) 15 | let controller = DetailsViewController(detailsViewModel: viewModel) 16 | return controller 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Pokemon Challenge/Scenes/Details/DetailsService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailsService.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | import Foundation 8 | 9 | protocol DetailsServicing { 10 | var API: APIRequesting {get set} 11 | func getSpeciesDetails(id: Int, completion: @escaping (Result) -> Void) 12 | func getEvolutionChainDetails(urlString: String, completion: @escaping (Result) -> Void) 13 | } 14 | 15 | class DetailsService: DetailsServicing { 16 | var API: any APIRequesting 17 | 18 | init(API: any APIRequesting) { 19 | self.API = API 20 | } 21 | 22 | func getSpeciesDetails(id: Int, completion: @escaping (Result) -> Void) { 23 | self.API.getSpeciesDetails(id: id) { result in 24 | DispatchQueue.main.async { 25 | completion(result) 26 | } 27 | } 28 | } 29 | 30 | func getEvolutionChainDetails(urlString: String, completion: @escaping (Result) -> Void) { 31 | self.API.getEvolutionChain(urlString: urlString) { result in 32 | DispatchQueue.main.async { 33 | completion(result) 34 | } 35 | } 36 | } 37 | } 38 | 39 | -------------------------------------------------------------------------------- /Pokemon Challenge/Scenes/Details/DetailsViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailsViewController.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | import UIKit 9 | 10 | class DetailsViewController: UIViewController { 11 | 12 | private let detailsViewModel: DetailsViewmodeling 13 | private var portraitConstraints : [NSLayoutConstraint]? 14 | private var landscapeConstraints : [NSLayoutConstraint]? 15 | 16 | private lazy var scrollView: UIScrollView = { 17 | let scrollView = UIScrollView() 18 | scrollView.translatesAutoresizingMaskIntoConstraints = false 19 | return scrollView 20 | }() 21 | 22 | private lazy var contentView: UIView = { 23 | let view = UIView() 24 | view.translatesAutoresizingMaskIntoConstraints = false 25 | return view 26 | }() 27 | 28 | private lazy var headerComponent: DetailsHeaderComponent = { 29 | let view = DetailsHeaderComponent() 30 | view.translatesAutoresizingMaskIntoConstraints = false 31 | return view 32 | }() 33 | 34 | private lazy var attributesComponent: DetailsAttributesComponent = { 35 | let view = DetailsAttributesComponent() 36 | view.translatesAutoresizingMaskIntoConstraints = false 37 | return view 38 | }() 39 | 40 | private lazy var evolutionChainComponent: DetailsEvolutionChain = { 41 | let view = DetailsEvolutionChain() 42 | view.translatesAutoresizingMaskIntoConstraints = false 43 | return view 44 | }() 45 | 46 | init(detailsViewModel: DetailsViewmodeling) { 47 | self.detailsViewModel = detailsViewModel 48 | super.init(nibName: nil, bundle: nil) 49 | } 50 | 51 | required init?(coder: NSCoder) { 52 | fatalError("init(coder:) has not been implemented") 53 | } 54 | 55 | override func viewDidLoad() { 56 | super.viewDidLoad() 57 | view.backgroundColor = .systemBackground 58 | setupViews() 59 | 60 | headerComponent.configure(with: (detailsViewModel as! DetailsViewModel).pokemon) 61 | 62 | self.detailsViewModel.viewDidLoad() 63 | 64 | self.detailsViewModel.onDetailsUpdated = { [weak self] details in 65 | self?.attributesComponent.configure(with: details) 66 | } 67 | 68 | self.detailsViewModel.onEvolutionChainDetailsUpdated = { [weak self] evolutionChainDetails in 69 | self?.evolutionChainComponent.configure(with: evolutionChainDetails.chain) 70 | } 71 | } 72 | 73 | override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { 74 | super.viewWillTransition(to: size, with: coordinator) 75 | 76 | if let portraitConstraints = portraitConstraints, let landscapeConstraints = landscapeConstraints { 77 | coordinator.animate { [weak self] _ in 78 | self?.updateConstraintsWithOrientation(portraitConstraints, landscapeConstraints) 79 | } 80 | } 81 | } 82 | } 83 | 84 | extension DetailsViewController: ViewCode { 85 | func setupViews() { 86 | view.addSubview(scrollView) 87 | view.addSubview(evolutionChainComponent) 88 | scrollView.addSubview(contentView) 89 | 90 | contentView.addSubview(headerComponent) 91 | contentView.addSubview(attributesComponent) 92 | 93 | setupConstraints() 94 | } 95 | 96 | func setupConstraints() { 97 | portraitConstraints = [ 98 | scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), 99 | scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), 100 | scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), 101 | scrollView.bottomAnchor.constraint(equalTo: evolutionChainComponent.topAnchor), 102 | 103 | contentView.topAnchor.constraint(equalTo: scrollView.topAnchor), 104 | contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), 105 | contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), 106 | contentView.bottomAnchor.constraint(equalTo: attributesComponent.bottomAnchor, constant: 16), 107 | contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor), 108 | 109 | headerComponent.topAnchor.constraint(equalTo: contentView.topAnchor), 110 | headerComponent.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), 111 | headerComponent.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), 112 | 113 | attributesComponent.topAnchor.constraint(equalTo: headerComponent.bottomAnchor, constant: 24), 114 | attributesComponent.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), 115 | attributesComponent.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), 116 | attributesComponent.bottomAnchor.constraint(equalTo: evolutionChainComponent.topAnchor, constant: -16), 117 | 118 | evolutionChainComponent.leadingAnchor.constraint(equalTo: view.leadingAnchor), 119 | evolutionChainComponent.trailingAnchor.constraint(equalTo: view.trailingAnchor), 120 | evolutionChainComponent.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), 121 | evolutionChainComponent.heightAnchor.constraint(equalToConstant: 180) 122 | ] 123 | 124 | landscapeConstraints = [ 125 | scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), 126 | scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), 127 | scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), 128 | scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), 129 | 130 | contentView.topAnchor.constraint(equalTo: scrollView.topAnchor), 131 | contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), 132 | contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), 133 | contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), 134 | contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor), 135 | 136 | headerComponent.topAnchor.constraint(equalTo: contentView.topAnchor), 137 | headerComponent.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), 138 | headerComponent.widthAnchor.constraint(equalTo: contentView.widthAnchor, multiplier: 0.5), 139 | 140 | evolutionChainComponent.topAnchor.constraint(equalTo: headerComponent.bottomAnchor, constant: 16), 141 | evolutionChainComponent.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), 142 | evolutionChainComponent.widthAnchor.constraint(equalTo: contentView.widthAnchor, multiplier: 0.5), 143 | evolutionChainComponent.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor), 144 | evolutionChainComponent.heightAnchor.constraint(equalToConstant: 180), 145 | 146 | attributesComponent.topAnchor.constraint(equalTo: contentView.topAnchor), 147 | attributesComponent.leadingAnchor.constraint(equalTo: headerComponent.trailingAnchor, constant: 16), 148 | attributesComponent.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16), 149 | attributesComponent.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) 150 | ] 151 | 152 | if let _ = portraitConstraints, let _ = landscapeConstraints { 153 | updateConstraintsWithOrientation(portraitConstraints!, landscapeConstraints!) 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /Pokemon Challenge/Scenes/Details/DetailsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailsViewModel.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | // To respect Dependency Injection I created this abstraction, to not implement concret class ViewModel 9 | protocol DetailsViewmodeling : AnyObject { 10 | var onDetailsUpdated: ((SpeciesDetails) -> Void)? { get set } 11 | var onEvolutionChainDetailsUpdated: ((EvolutionChainDetails) -> Void)? { get set } 12 | func viewDidLoad() 13 | func getSpeciesDetails(id: Int?) 14 | } 15 | 16 | class DetailsViewModel: DetailsViewmodeling { 17 | let pokemon: Species 18 | let service: DetailsServicing 19 | var onDetailsUpdated: ((SpeciesDetails) -> Void)? 20 | var onEvolutionChainDetailsUpdated: ((EvolutionChainDetails) -> Void)? 21 | 22 | init(pokemon: Species, service: DetailsServicing){ 23 | self.pokemon = pokemon 24 | self.service = service 25 | } 26 | 27 | func viewDidLoad() { 28 | getSpeciesDetails(id: self.pokemon.id) 29 | } 30 | 31 | func getSpeciesDetails(id: Int?) { 32 | guard let id else { return } 33 | 34 | self.service.getSpeciesDetails(id: id) { result in 35 | switch result { 36 | case .success(let response): 37 | self.onDetailsUpdated?(response) 38 | self.getEvolutionChain(urlString: response.evolution_chain.url) 39 | case .failure(let error): 40 | print("Error fetching pokemon details: \(error)") 41 | } 42 | } 43 | } 44 | 45 | func getEvolutionChain(urlString: String?) { 46 | guard let urlString else { return } 47 | 48 | self.service.getEvolutionChainDetails(urlString: urlString) { result in 49 | switch result { 50 | case .success(let response): 51 | self.onEvolutionChainDetailsUpdated?(response) 52 | case .failure(let error): 53 | print("Error fetching pokemon evolution chain details: \(error)") 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Pokemon Challenge/Scenes/Pokemons/Components/AuthorComponent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthorComponent.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 20/01/25. 6 | // 7 | 8 | import UIKit 9 | 10 | final class AuthorComponent: UIView { 11 | private lazy var label: UILabel = { 12 | let label = UILabel() 13 | label.translatesAutoresizingMaskIntoConstraints = false 14 | label.text = "created by Evens Taian" 15 | label.textColor = .white 16 | label.textAlignment = .center 17 | return label 18 | }() 19 | 20 | override init(frame: CGRect) { 21 | super.init(frame: frame) 22 | setupViews() 23 | } 24 | 25 | required init?(coder: NSCoder) { 26 | fatalError("init(coder:) has not been implemented") 27 | } 28 | } 29 | 30 | extension AuthorComponent: ViewCode { 31 | func setupViews() { 32 | backgroundColor = .black 33 | addSubview(label) 34 | 35 | setupConstraints() 36 | } 37 | 38 | func setupConstraints() { 39 | NSLayoutConstraint.activate([ 40 | label.topAnchor.constraint(equalTo: topAnchor), 41 | label.leadingAnchor.constraint(equalTo: leadingAnchor), 42 | label.trailingAnchor.constraint(equalTo: trailingAnchor), 43 | label.bottomAnchor.constraint(equalTo: bottomAnchor), 44 | heightAnchor.constraint(equalToConstant: 40) 45 | ]) 46 | } 47 | } 48 | 49 | 50 | -------------------------------------------------------------------------------- /Pokemon Challenge/Scenes/Pokemons/Components/CollectionCells/PokemonCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PokemonCell.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | import UIKit 9 | 10 | class PokemonCell: UICollectionViewCell { 11 | private lazy var imageView: UIImageView = { 12 | let imageView = UIImageView() 13 | imageView.contentMode = .scaleAspectFit 14 | imageView.backgroundColor = .systemGray6 15 | imageView.layer.cornerRadius = 8 16 | imageView.clipsToBounds = true 17 | imageView.translatesAutoresizingMaskIntoConstraints = false 18 | return imageView 19 | }() 20 | 21 | private lazy var nameLabel: UILabel = { 22 | let label = UILabel() 23 | label.textAlignment = .center 24 | label.font = .systemFont(ofSize: 14, weight: .medium) 25 | label.translatesAutoresizingMaskIntoConstraints = false 26 | return label 27 | }() 28 | 29 | override init(frame: CGRect) { 30 | super.init(frame: frame) 31 | setupViews() 32 | setupConstraints() 33 | } 34 | 35 | required init?(coder: NSCoder) { 36 | fatalError("init(coder:) has not been implemented") 37 | } 38 | 39 | func configure(with pokemon: Species) { 40 | nameLabel.text = pokemon.name.capitalized 41 | if let id = pokemon.id { 42 | let imageStore = ImageStore() 43 | let imageUrlString = "\(ApiConstants.imagesURL)\(id).png" 44 | if let imageUrl = URL(string: imageUrlString) { 45 | imageStore.fetch(for: imageUrl) { [weak self] imageData, _ in 46 | DispatchQueue.main.async { 47 | self?.imageView.image = imageData 48 | } 49 | } 50 | } 51 | } 52 | } 53 | } 54 | 55 | extension PokemonCell: ViewCode { 56 | func setupViews() { 57 | contentView.addSubview(imageView) 58 | contentView.addSubview(nameLabel) 59 | 60 | contentView.backgroundColor = .white 61 | contentView.layer.cornerRadius = 8 62 | contentView.layer.shadowColor = UIColor.black.cgColor 63 | contentView.layer.shadowOpacity = 0.1 64 | contentView.layer.shadowOffset = CGSize(width: 0, height: 2) 65 | contentView.layer.shadowRadius = 4 66 | } 67 | 68 | func setupConstraints() { 69 | NSLayoutConstraint.activate([ 70 | imageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), 71 | imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8), 72 | imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8), 73 | imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor), 74 | 75 | nameLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 8), 76 | nameLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8), 77 | nameLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8), 78 | nameLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8) 79 | ]) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Pokemon Challenge/Scenes/Pokemons/Components/HeaderComponent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HeaderComponent.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | import UIKit 9 | 10 | class HeaderComponent: UIView { 11 | private lazy var logoImageView: UIImageView = { 12 | let imageView = UIImageView() 13 | imageView.image = UIImage(named: "pokemon-logo") 14 | imageView.contentMode = .scaleAspectFit 15 | imageView.translatesAutoresizingMaskIntoConstraints = false 16 | return imageView 17 | }() 18 | 19 | override init(frame: CGRect) { 20 | super.init(frame: frame) 21 | setupViews() 22 | setupConstraints() 23 | } 24 | 25 | required init?(coder: NSCoder) { 26 | fatalError("init(coder:) has not been implemented") 27 | } 28 | } 29 | 30 | extension HeaderComponent: ViewCode { 31 | func setupViews() { 32 | addSubview(logoImageView) 33 | backgroundColor = .white 34 | } 35 | 36 | func setupConstraints() { 37 | NSLayoutConstraint.activate([ 38 | logoImageView.topAnchor.constraint(equalTo: topAnchor, constant: 16), 39 | logoImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), 40 | logoImageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), 41 | logoImageView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16), 42 | logoImageView.heightAnchor.constraint(equalToConstant: 60) 43 | ]) 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /Pokemon Challenge/Scenes/Pokemons/Components/ListComponent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListComponent.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | import UIKit 9 | 10 | class ListComponent: UIView { 11 | lazy var refresher : UIRefreshControl = { 12 | let view = UIRefreshControl() 13 | view.tintColor = .green 14 | return view 15 | }() 16 | 17 | lazy var collectionView: UICollectionView = { 18 | let layout = UICollectionViewFlowLayout() 19 | layout.minimumInteritemSpacing = 16 20 | layout.minimumLineSpacing = 16 21 | 22 | let collection = UICollectionView(frame: .zero, collectionViewLayout: layout) 23 | collection.backgroundColor = .white 24 | collection.register(PokemonCell.self, forCellWithReuseIdentifier: "PokemonCell") 25 | collection.register(ServiceMessageComponent.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: "FooterViewIdentifier") 26 | collection.translatesAutoresizingMaskIntoConstraints = false 27 | collection.addSubview(refresher) 28 | return collection 29 | }() 30 | 31 | override init(frame: CGRect) { 32 | super.init(frame: frame) 33 | setupViews() 34 | setupConstraints() 35 | } 36 | 37 | required init?(coder: NSCoder) { 38 | fatalError("init(coder:) has not been implemented") 39 | } 40 | } 41 | 42 | extension ListComponent: ViewCode { 43 | func setupViews() { 44 | addSubview(collectionView) 45 | } 46 | 47 | func setupConstraints() { 48 | NSLayoutConstraint.activate([ 49 | collectionView.topAnchor.constraint(equalTo: topAnchor), 50 | collectionView.leadingAnchor.constraint(equalTo: leadingAnchor), 51 | collectionView.trailingAnchor.constraint(equalTo: trailingAnchor), 52 | collectionView.bottomAnchor.constraint(equalTo: bottomAnchor) 53 | ]) 54 | } 55 | } 56 | 57 | -------------------------------------------------------------------------------- /Pokemon Challenge/Scenes/Pokemons/Components/PokemonCollectionView/PokemonCollectionDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PokemonCollectionDataSource.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | import UIKit 9 | 10 | class PokemonCollectionDataSource: NSObject, UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { 11 | var didClick: ((_ pokemon: Species) -> Void)? 12 | var didListFinishScroll: (() -> Void)? 13 | var pokemons: [Species] = [] 14 | 15 | func updatePokemons(_ pokemons: [Species]) { 16 | self.pokemons = pokemons.map { pokemon in 17 | var updatedPokemon = pokemon 18 | if let idString = Extracters.extractPokemonId(from: pokemon.url), 19 | let id = Int(idString) { 20 | updatedPokemon.id = id 21 | } 22 | return updatedPokemon 23 | } 24 | } 25 | 26 | func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 27 | return pokemons.count 28 | } 29 | 30 | func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 31 | guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "PokemonCell", for: indexPath) as? PokemonCell else { 32 | return UICollectionViewCell() 33 | } 34 | 35 | let pokemon = pokemons[indexPath.item] 36 | cell.configure(with: pokemon) 37 | return cell 38 | } 39 | 40 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { 41 | let width = (collectionView.bounds.width - 48) / 2 42 | return CGSize(width: width, height: width * 1.3) 43 | } 44 | 45 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { 46 | return UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) 47 | } 48 | 49 | func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 50 | didClick?(pokemons[indexPath.item]) 51 | } 52 | 53 | func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { 54 | if indexPath.row == pokemons.count - 1 { 55 | didListFinishScroll?() 56 | } 57 | } 58 | 59 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize { 60 | return CGSize(width: collectionView.bounds.size.width, height: 60) 61 | } 62 | 63 | func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { 64 | if kind == UICollectionView.elementKindSectionFooter { 65 | let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "FooterViewIdentifier", for: indexPath) as! ServiceMessageComponent 66 | footerView.messageLabel.text = "Searching for more pokemons..." 67 | return footerView 68 | } 69 | return UICollectionReusableView() 70 | } 71 | } 72 | 73 | -------------------------------------------------------------------------------- /Pokemon Challenge/Scenes/Pokemons/Components/ServiceMessageComponent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServiceMessageComponent.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 20/01/25. 6 | // 7 | 8 | import UIKit 9 | 10 | class ServiceMessageComponent: UICollectionReusableView, ViewCode { 11 | 12 | let kMessageLabelFontSize : CGFloat = 12 13 | let kMessageLabelTopMargin : CGFloat = 20 14 | let kMessageLabelLeadingMargin : CGFloat = 20 15 | let kMessageLabelTrailingMargin : CGFloat = -20 16 | let kMessageLabelBottomMargin : CGFloat = -20 17 | let kMessageLabelHeight : CGFloat = 50 18 | 19 | lazy var messageLabel : UILabel = { 20 | let lbl = UILabel() 21 | lbl.translatesAutoresizingMaskIntoConstraints = false 22 | lbl.textAlignment = .center 23 | lbl.textColor = .black 24 | lbl.font = .systemFont(ofSize: kMessageLabelFontSize) 25 | return lbl 26 | }() 27 | 28 | override init(frame: CGRect) { 29 | super.init(frame: frame) 30 | setupViews() 31 | setupConstraints() 32 | } 33 | 34 | required init?(coder: NSCoder) { 35 | fatalError("init(coder:) has not been implemented") 36 | } 37 | 38 | func setupViews() { 39 | self.addSubview(messageLabel) 40 | } 41 | 42 | func setupConstraints() { 43 | NSLayoutConstraint.activate([ 44 | messageLabel.topAnchor.constraint(equalTo: self.topAnchor, constant: kMessageLabelTopMargin), 45 | messageLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: kMessageLabelLeadingMargin), 46 | messageLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: kMessageLabelTrailingMargin), 47 | messageLabel.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: kMessageLabelBottomMargin), 48 | messageLabel.heightAnchor.constraint(equalToConstant: kMessageLabelHeight) 49 | ]) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Pokemon Challenge/Scenes/Pokemons/PokemonsCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PokemonsCoordinator.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | import UIKit 9 | 10 | protocol PokemonsCoordinating: AnyObject { 11 | var controller: UIViewController? { get } 12 | func goToDetails(pokemon: Species) 13 | } 14 | 15 | class PokemonsCoordinator: PokemonsCoordinating { 16 | weak var controller: UIViewController? 17 | 18 | func goToDetails(pokemon: Species) { 19 | let detailsViewController = DetailsFactory.makeModule(pokemon: pokemon) 20 | self.controller?.navigationController?.pushViewController(detailsViewController, animated: true) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Pokemon Challenge/Scenes/Pokemons/PokemonsFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PokemonsFactory.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | import UIKit 9 | 10 | enum PokemonsFactory { 11 | static func makeModule() -> UIViewController { 12 | let API = ApiRequests() 13 | let service = PokemonsService(API: API) 14 | let coordinator = PokemonsCoordinator() 15 | let viewModel = PokemonsViewModel(service: service, coordinator: coordinator) 16 | let controller = PokemonsViewController(viewModel: viewModel) 17 | coordinator.controller = controller 18 | return controller 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Pokemon Challenge/Scenes/Pokemons/PokemonsService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PokemonsService.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol PokemonsServicing { 11 | var API: APIRequesting {get set} 12 | func getSpecies(offset: Int?, limit: Int?, completion: @escaping (Result) -> Void) 13 | } 14 | 15 | class PokemonsService: PokemonsServicing { 16 | var API: APIRequesting 17 | 18 | init(API: APIRequesting){ 19 | self.API = API 20 | } 21 | 22 | func getSpecies(offset: Int?, limit: Int?, completion: @escaping (Result) -> Void) { 23 | self.API.getSpecies(offset: offset, limit: limit) { result in 24 | DispatchQueue.main.async { 25 | completion(result) 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Pokemon Challenge/Scenes/Pokemons/PokemonsViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PokemonsViewController.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | import UIKit 9 | 10 | class PokemonsViewController: UIViewController { 11 | 12 | private let viewModel: PokemonsViewmodeling 13 | private let pokemonsCollectionDataSource = PokemonCollectionDataSource() 14 | 15 | private var portraitConstraints : [NSLayoutConstraint]? 16 | private var landscapeConstraints : [NSLayoutConstraint]? 17 | 18 | private lazy var headerComponent: HeaderComponent = { 19 | let header = HeaderComponent() 20 | header.translatesAutoresizingMaskIntoConstraints = false 21 | return header 22 | }() 23 | 24 | private lazy var listComponent: ListComponent = { 25 | let list = ListComponent() 26 | list.collectionView.delegate = pokemonsCollectionDataSource 27 | list.collectionView.dataSource = pokemonsCollectionDataSource 28 | list.translatesAutoresizingMaskIntoConstraints = false 29 | return list 30 | }() 31 | 32 | private lazy var authorComponent: AuthorComponent = { 33 | let author = AuthorComponent() 34 | author.translatesAutoresizingMaskIntoConstraints = false 35 | return author 36 | }() 37 | 38 | init(viewModel: PokemonsViewmodeling){ 39 | self.viewModel = viewModel 40 | super.init(nibName: nil, bundle: nil) 41 | } 42 | 43 | required init?(coder: NSCoder) { 44 | fatalError("init(coder:) has not been implemented") 45 | } 46 | 47 | override func viewDidLoad() { 48 | super.viewDidLoad() 49 | setupViews() 50 | setupConstraints() 51 | setupClicks() 52 | 53 | viewModel.onPokemonsUpdated = { [weak self] pokemons in 54 | self?.pokemonsCollectionDataSource.updatePokemons(pokemons) 55 | self?.listComponent.collectionView.reloadData() 56 | self?.listComponent.refresher.endRefreshing() 57 | } 58 | 59 | viewModel.onRequestError = { [weak self] errorMessage in 60 | self?.showErrorAlert(message: errorMessage) 61 | } 62 | 63 | self.viewModel.viewDidLoad() 64 | } 65 | 66 | override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { 67 | super.viewWillTransition(to: size, with: coordinator) 68 | 69 | if let portraitConstraints = portraitConstraints, let landscapeConstraints = landscapeConstraints { 70 | coordinator.animate { [weak self] _ in 71 | self?.updateConstraintsWithOrientation(portraitConstraints, landscapeConstraints) 72 | } 73 | } 74 | } 75 | 76 | private func setupClicks() { 77 | pokemonsCollectionDataSource.didClick = { pokemon in 78 | self.viewModel.goToDetails(pokemon: pokemon) 79 | } 80 | 81 | pokemonsCollectionDataSource.didListFinishScroll = { 82 | DispatchQueue.main.asyncAfter(deadline: .now() + 2) { 83 | self.viewModel.getSpecies(offset: nil, limit: nil) 84 | } 85 | } 86 | 87 | listComponent.refresher.addTarget(self, action: #selector(handleRefresh), for: .valueChanged) 88 | } 89 | 90 | @objc private func handleRefresh() { 91 | viewModel.viewDidLoad() 92 | } 93 | } 94 | 95 | extension PokemonsViewController: ViewCode { 96 | func setupViews() { 97 | view.backgroundColor = .white 98 | view.addSubview(headerComponent) 99 | view.addSubview(listComponent) 100 | view.addSubview(authorComponent) 101 | } 102 | 103 | func setupConstraints() { 104 | portraitConstraints = [ 105 | headerComponent.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), 106 | headerComponent.leadingAnchor.constraint(equalTo: view.leadingAnchor), 107 | headerComponent.trailingAnchor.constraint(equalTo: view.trailingAnchor), 108 | 109 | listComponent.topAnchor.constraint(equalTo: headerComponent.bottomAnchor), 110 | listComponent.leadingAnchor.constraint(equalTo: view.leadingAnchor), 111 | listComponent.trailingAnchor.constraint(equalTo: view.trailingAnchor), 112 | listComponent.bottomAnchor.constraint(equalTo: authorComponent.topAnchor), 113 | 114 | authorComponent.leadingAnchor.constraint(equalTo: view.leadingAnchor), 115 | authorComponent.trailingAnchor.constraint(equalTo: view.trailingAnchor), 116 | authorComponent.bottomAnchor.constraint(equalTo: view.bottomAnchor) 117 | ] 118 | 119 | landscapeConstraints = [ 120 | headerComponent.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), 121 | headerComponent.leadingAnchor.constraint(equalTo: view.leadingAnchor), 122 | headerComponent.bottomAnchor.constraint(equalTo: authorComponent.topAnchor), 123 | headerComponent.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.5), 124 | 125 | listComponent.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), 126 | listComponent.leadingAnchor.constraint(equalTo: headerComponent.trailingAnchor), 127 | listComponent.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), 128 | listComponent.bottomAnchor.constraint(equalTo: authorComponent.topAnchor), 129 | 130 | authorComponent.leadingAnchor.constraint(equalTo: view.leadingAnchor), 131 | authorComponent.trailingAnchor.constraint(equalTo: view.trailingAnchor), 132 | authorComponent.bottomAnchor.constraint(equalTo: view.bottomAnchor) 133 | ] 134 | 135 | if let _ = portraitConstraints, let _ = landscapeConstraints { 136 | updateConstraintsWithOrientation(portraitConstraints!, landscapeConstraints!) 137 | } 138 | } 139 | } 140 | 141 | extension PokemonsViewController { 142 | func showErrorAlert(message: String) { 143 | let alert = UIAlertController(title: "Oops!", message: message, preferredStyle: .alert) 144 | let okAction = UIAlertAction(title: "Ok, gotcha!", style: .default) 145 | alert.addAction(okAction) 146 | present(alert, animated: true) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Pokemon Challenge/Scenes/Pokemons/PokemonsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PokemonsViewModel.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | 9 | // To respect Dependency Injection I created this abstraction, to not implement concret class ViewModel 10 | protocol PokemonsViewmodeling: AnyObject { 11 | var onPokemonsUpdated: (([Species]) -> Void)? { get set } 12 | var onRequestError: ((String) -> Void)? { get set } 13 | func viewDidLoad() 14 | func getSpecies(offset: Int?, limit: Int?) 15 | func goToDetails(pokemon: Species) 16 | } 17 | 18 | class PokemonsViewModel: PokemonsViewmodeling { 19 | 20 | private let service: PokemonsServicing 21 | private let coordinator: PokemonsCoordinating 22 | var pokemons: [Species] = [] 23 | var offset = 20 24 | var limit = 20 25 | 26 | var onPokemonsUpdated: (([Species]) -> Void)? 27 | var onRequestError: ((String) -> Void)? 28 | 29 | init(service: PokemonsServicing, coordinator: PokemonsCoordinating){ 30 | self.service = service 31 | self.coordinator = coordinator 32 | } 33 | 34 | func viewDidLoad() { 35 | offset = 0 36 | pokemons = [] 37 | getSpecies(offset: offset, limit: nil) 38 | } 39 | 40 | func getSpecies(offset: Int?, limit: Int?) { 41 | self.offset = offset ?? self.offset + self.limit 42 | print(self.offset) 43 | service.getSpecies(offset: self.offset, limit: self.limit) { [weak self] result in 44 | switch result { 45 | case .success(let response): 46 | self?.pokemons.append(contentsOf: response.results) 47 | if let pokemons = self?.pokemons { 48 | self?.onPokemonsUpdated?(pokemons) 49 | } 50 | case .failure(let error): 51 | switch error { 52 | case .noConnection: 53 | self?.onRequestError?("No internet connection. Please check your network and try again.") 54 | case .notFound: 55 | self?.onRequestError?("Pokemon data not found. Please try again later.") 56 | default: 57 | self?.onRequestError?("An unexpected error occurred. Please try again later.") 58 | } 59 | print("Error fetching pokemons: \(error)") 60 | } 61 | } 62 | } 63 | 64 | func goToDetails(pokemon: Species) { 65 | self.coordinator.goToDetails(pokemon: pokemon) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/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 | -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evenstaian/PokemonChallenge/a618d20e9c183761c94acde380b2bc25cdc14312/Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/100.png -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evenstaian/PokemonChallenge/a618d20e9c183761c94acde380b2bc25cdc14312/Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/1024.png -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evenstaian/PokemonChallenge/a618d20e9c183761c94acde380b2bc25cdc14312/Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/114.png -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evenstaian/PokemonChallenge/a618d20e9c183761c94acde380b2bc25cdc14312/Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/120.png -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evenstaian/PokemonChallenge/a618d20e9c183761c94acde380b2bc25cdc14312/Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/144.png -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evenstaian/PokemonChallenge/a618d20e9c183761c94acde380b2bc25cdc14312/Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/152.png -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evenstaian/PokemonChallenge/a618d20e9c183761c94acde380b2bc25cdc14312/Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/167.png -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evenstaian/PokemonChallenge/a618d20e9c183761c94acde380b2bc25cdc14312/Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/180.png -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evenstaian/PokemonChallenge/a618d20e9c183761c94acde380b2bc25cdc14312/Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/20.png -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evenstaian/PokemonChallenge/a618d20e9c183761c94acde380b2bc25cdc14312/Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/29.png -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evenstaian/PokemonChallenge/a618d20e9c183761c94acde380b2bc25cdc14312/Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/40.png -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evenstaian/PokemonChallenge/a618d20e9c183761c94acde380b2bc25cdc14312/Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/50.png -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evenstaian/PokemonChallenge/a618d20e9c183761c94acde380b2bc25cdc14312/Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/57.png -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evenstaian/PokemonChallenge/a618d20e9c183761c94acde380b2bc25cdc14312/Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/58.png -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evenstaian/PokemonChallenge/a618d20e9c183761c94acde380b2bc25cdc14312/Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/60.png -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evenstaian/PokemonChallenge/a618d20e9c183761c94acde380b2bc25cdc14312/Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/72.png -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evenstaian/PokemonChallenge/a618d20e9c183761c94acde380b2bc25cdc14312/Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/76.png -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evenstaian/PokemonChallenge/a618d20e9c183761c94acde380b2bc25cdc14312/Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/80.png -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/87.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evenstaian/PokemonChallenge/a618d20e9c183761c94acde380b2bc25cdc14312/Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/87.png -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "40.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "60.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "29.png", 17 | "idiom" : "iphone", 18 | "scale" : "1x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "58.png", 23 | "idiom" : "iphone", 24 | "scale" : "2x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "87.png", 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "29x29" 32 | }, 33 | { 34 | "filename" : "80.png", 35 | "idiom" : "iphone", 36 | "scale" : "2x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "120.png", 41 | "idiom" : "iphone", 42 | "scale" : "3x", 43 | "size" : "40x40" 44 | }, 45 | { 46 | "filename" : "57.png", 47 | "idiom" : "iphone", 48 | "scale" : "1x", 49 | "size" : "57x57" 50 | }, 51 | { 52 | "filename" : "114.png", 53 | "idiom" : "iphone", 54 | "scale" : "2x", 55 | "size" : "57x57" 56 | }, 57 | { 58 | "filename" : "120.png", 59 | "idiom" : "iphone", 60 | "scale" : "2x", 61 | "size" : "60x60" 62 | }, 63 | { 64 | "filename" : "180.png", 65 | "idiom" : "iphone", 66 | "scale" : "3x", 67 | "size" : "60x60" 68 | }, 69 | { 70 | "filename" : "20.png", 71 | "idiom" : "ipad", 72 | "scale" : "1x", 73 | "size" : "20x20" 74 | }, 75 | { 76 | "filename" : "40.png", 77 | "idiom" : "ipad", 78 | "scale" : "2x", 79 | "size" : "20x20" 80 | }, 81 | { 82 | "filename" : "29.png", 83 | "idiom" : "ipad", 84 | "scale" : "1x", 85 | "size" : "29x29" 86 | }, 87 | { 88 | "filename" : "58.png", 89 | "idiom" : "ipad", 90 | "scale" : "2x", 91 | "size" : "29x29" 92 | }, 93 | { 94 | "filename" : "40.png", 95 | "idiom" : "ipad", 96 | "scale" : "1x", 97 | "size" : "40x40" 98 | }, 99 | { 100 | "filename" : "80.png", 101 | "idiom" : "ipad", 102 | "scale" : "2x", 103 | "size" : "40x40" 104 | }, 105 | { 106 | "filename" : "50.png", 107 | "idiom" : "ipad", 108 | "scale" : "1x", 109 | "size" : "50x50" 110 | }, 111 | { 112 | "filename" : "100.png", 113 | "idiom" : "ipad", 114 | "scale" : "2x", 115 | "size" : "50x50" 116 | }, 117 | { 118 | "filename" : "72.png", 119 | "idiom" : "ipad", 120 | "scale" : "1x", 121 | "size" : "72x72" 122 | }, 123 | { 124 | "filename" : "144.png", 125 | "idiom" : "ipad", 126 | "scale" : "2x", 127 | "size" : "72x72" 128 | }, 129 | { 130 | "filename" : "76.png", 131 | "idiom" : "ipad", 132 | "scale" : "1x", 133 | "size" : "76x76" 134 | }, 135 | { 136 | "filename" : "152.png", 137 | "idiom" : "ipad", 138 | "scale" : "2x", 139 | "size" : "76x76" 140 | }, 141 | { 142 | "filename" : "167.png", 143 | "idiom" : "ipad", 144 | "scale" : "2x", 145 | "size" : "83.5x83.5" 146 | }, 147 | { 148 | "filename" : "1024.png", 149 | "idiom" : "ios-marketing", 150 | "scale" : "1x", 151 | "size" : "1024x1024" 152 | } 153 | ], 154 | "info" : { 155 | "author" : "xcode", 156 | "version" : 1 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/Assets.xcassets/pokemon-logo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "pokemon-logo.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "preserves-vector-representation" : true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/Assets.xcassets/pokemon-logo.imageset/pokemon-logo.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evenstaian/PokemonChallenge/a618d20e9c183761c94acde380b2bc25cdc14312/Pokemon Challenge/UIResources/Assets.xcassets/pokemon-logo.imageset/pokemon-logo.pdf -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/LaunchScreenViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LaunchScreenViewController.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 20/01/25. 6 | // 7 | 8 | import UIKit 9 | 10 | class LaunchScreenViewController: UIViewController, ViewCode { 11 | 12 | private lazy var logoImageView: UIImageView = { 13 | let imageView = UIImageView(image: UIImage(named: "pokemon-logo")) 14 | imageView.contentMode = .scaleAspectFit 15 | imageView.translatesAutoresizingMaskIntoConstraints = false 16 | imageView.transform = CGAffineTransform(scaleX: 0.5, y: 0.5) 17 | return imageView 18 | }() 19 | 20 | var onFinishAnimation: (() -> Void)? 21 | 22 | override func viewDidLoad() { 23 | super.viewDidLoad() 24 | setupViews() 25 | } 26 | 27 | override func viewDidAppear(_ animated: Bool) { 28 | super.viewDidAppear(animated) 29 | animateLogo() 30 | } 31 | 32 | func setupViews() { 33 | view.backgroundColor = .white 34 | view.addSubview(logoImageView) 35 | 36 | setupConstraints() 37 | } 38 | 39 | func setupConstraints() { 40 | NSLayoutConstraint.activate([ 41 | logoImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor), 42 | logoImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), 43 | logoImageView.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.7), 44 | logoImageView.heightAnchor.constraint(equalTo: logoImageView.widthAnchor, multiplier: 0.5) 45 | ]) 46 | } 47 | 48 | private func animateLogo() { 49 | UIView.animate(withDuration: 0.7, delay: 0.3, options: .curveEaseInOut) { 50 | self.logoImageView.transform = .identity 51 | } completion: { _ in 52 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 53 | self.onFinishAnimation?() 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Pokemon Challenge/UIResources/Orientation/OrientationManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OrientationManager.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 20/01/25. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIViewController { 11 | public func updateConstraintsWithOrientation(_ portraitConstraints:[NSLayoutConstraint], _ landscapeConstraints:[NSLayoutConstraint]) { 12 | var orientation : UIInterfaceOrientation = UIInterfaceOrientation.portrait 13 | 14 | if UIDevice.current.orientation.isLandscape { 15 | orientation = .landscapeLeft 16 | } 17 | 18 | if orientation.isLandscape { 19 | NSLayoutConstraint.deactivate(portraitConstraints) 20 | NSLayoutConstraint.activate(landscapeConstraints) 21 | self.view.layoutIfNeeded() 22 | self.updateViewConstraints() 23 | } else { 24 | NSLayoutConstraint.deactivate(landscapeConstraints) 25 | NSLayoutConstraint.activate(portraitConstraints) 26 | self.updateViewConstraints() 27 | self.view.layoutIfNeeded() 28 | } 29 | } 30 | 31 | public func isPortrait() -> Bool{ 32 | return UIDevice.current.orientation.isPortrait 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Pokemon Challenge/Utils/Helpers/Extracters/Extracters.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extracters.swift 3 | // Pokemon Challenge 4 | // 5 | // Created by Evens Taian on 19/01/25. 6 | // 7 | 8 | struct Extracters { 9 | static func extractPokemonId(from url: String) -> String? { 10 | let components = url.split(separator: "/") 11 | return components.last.map(String.init) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | #Pokemon Challenge - MVVM-C 2 | #### _Using PokeAPI public API (https://pokeapi.co/)_ 3 | __Author: Evens Taian__ 4 | 5 | This project implements the __MVVM-C__ (Model-View-ViewModel-Coordinator) architectural pattern, enabling clean code maintenance, readability, well-defined responsibilities, and testability. The application was built following SOLID principles: 6 | 7 | - Single Responsibility Principle 8 | - Open-Closed Principle 9 | - Liskov Substitution Principle 10 | - Interface Segregation Principle 11 | - Dependency Inversion Principle 12 | 13 | View development method: __ViewCode__ (UIKit) 14 | 15 | ### Project Architecture 16 | 17 | The project is structured using MVVM-C pattern with the following components: 18 | 19 | - **View (ViewController)**: Responsible for UI elements and user interactions 20 | - **ViewModel**: Handles business logic and data transformation 21 | - **Model**: Represents data structures 22 | - **Coordinator**: Manages navigation flow between screens 23 | - **Service**: Handles API communication 24 | 25 | ### Key Features 26 | 27 | - Pokemon list with infinite scroll 28 | - Pull to refresh functionality 29 | - Pokemon details view 30 | - Evolution chain visualization 31 | - Support for both portrait and landscape orientations 32 | - Network caching 33 | - Mock data support for testing 34 | 35 | ### ViewCode Implementation 36 | 37 | All custom views conform to the ViewCode protocol, ensuring consistent view setup. This approach provides: 38 | 39 | - Full code control 40 | - High customization 41 | - Better readability 42 | - Easy maintenance 43 | - Minimal version control conflicts 44 | 45 | ### Dependency Injection & Abstractions 46 | 47 | The project uses protocol-oriented programming and dependency injection. This approach: 48 | 49 | - Enables easier testing through mock objects 50 | - Reduces coupling between components 51 | - Improves code maintainability 52 | - Follows SOLID principles 53 | 54 | ### Network Layer 55 | 56 | The networking layer is built with: 57 | - Protocol-based API requests 58 | - Response caching 59 | - Error handling 60 | - Mock data support for testing 61 | - Reachability checking 62 | 63 | ### Screen Orientation Support 64 | 65 | The application supports both portrait and landscape orientations with dynamic layout constraints. The layout automatically adjusts when the device orientation changes, providing a seamless user experience in any orientation. 66 | 67 | ### Testing Support 68 | 69 | The project includes: 70 | - Mock API implementation for testing 71 | - Protocol-based dependencies for easy mocking 72 | - Separate testing targets 73 | - Network response simulation 74 | 75 | ### Requirements 76 | 77 | - iOS 13.0+ 78 | - Xcode 13.0+ 79 | - Swift 5.0+ 80 | 81 | ### Installation 82 | 83 | 1. Clone the repository 84 | 2. Open Pokemon Challenge.xcodeproj 85 | 3. Build and run the project 86 | 87 | ### Credits 88 | 89 | This project uses the [PokeAPI](https://pokeapi.co/) for Pokemon data. 90 | --------------------------------------------------------------------------------