diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index fa2896d..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore index 312d1f6..030d1ea 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ DerivedData/ *.perspectivev3 !default.perspectivev3 xcuserdata/ +*.DS_Store +##xcshareddata ## Other *.moved-aside @@ -46,7 +48,9 @@ playground.xcworkspace # you should judge for yourself, the pros and cons are mentioned at: # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control # -# Pods/ +iOSBooks/Pods/ +iOSBooks/iOSBooks.xcworkspace +iOSBooks/Podfile.lock # Carthage # diff --git a/README.md b/README.md index bb1897d..e3438fe 100644 --- a/README.md +++ b/README.md @@ -1 +1,27 @@ -# iOSBooks \ No newline at end of file +# iOSBooks +# Requisitos +- Xcode 10 +- Swift 5 +- Cocoapods + +# Antes de rodar o projeto +Antes de rodar o projeto, por favor, rode 'pod install' dentro da pasta onde se encontra o Podfile através do terminal e abra o arquivo .xcworkspace após a instalação das dependências. + +# Arquitetura +- **Model:** Representa os dados da aplicação +- **View:** Camada de apresentação visual +- **ViewModel:** Camada de regras de apresentação e regras de negócio (se houver alguma no frontend) +- **Service:** Camada que busca os dados da internet +- **Coordinator:** Camada que orquestra os fluxos de apresentação do app +- **Injector:** Camada que injeta as dependências de cada tela/view model do app + +# Fluxo de camadas do app (Como as camadas se comunicam) + +AppDelegate -> Coordinator (Injector) <-> ViewModel (Service) <-> View + +# Dependencias +- **Kingfisher:** Biblioteca de image loader e cacher para baixar as imagens da Internet +- **PromisesKit:** Biblioteca para tratar código assíncrono com mais facilidade +- **SwiftLint:** Linter para análise de code style +- **Quick:** Biblioteca para auxiliar em criação de cenários em testes unitários +- **Nimble:** Biblioteca para auxiliar em criação de cenários em testes unitários diff --git a/iOSBooks/.DS_Store b/iOSBooks/.DS_Store deleted file mode 100644 index 7cb1ea8..0000000 Binary files a/iOSBooks/.DS_Store and /dev/null differ diff --git a/iOSBooks/.swiftlint.yml b/iOSBooks/.swiftlint.yml new file mode 100644 index 0000000..0e5584f --- /dev/null +++ b/iOSBooks/.swiftlint.yml @@ -0,0 +1,46 @@ +excluded: # paths to ignore during linting. Takes precedence over `included`. + - Pods + - iOSBooksTests +included: + - iOSBooks +whitelist_rules: + - colon + - implicitly_unwrapped_optional + - empty_count + - force_cast + - force_try + - force_unwrapping + - first_where + - last_where + - legacy_random + - nslocalizedstring_key + - overridden_super_call + - private_over_fileprivate + - redundant_nil_coalescing + - redundant_optional_initialization + - redundant_string_enum_value + - sorted_first_last + - sorted_imports + - statement_position +colon: warning +empty_count: warning +force_cast: error +force_try: error +force_unwrapping: error +first_where: warning +implicitly_unwrapped_optional: + severity: error +last_where: warning +legacy_random: warning +nslocalizedstring_key: warning +overridden_super_call: + severity: warning +private_over_fileprivate: + severity: warning +redundant_nil_coalescing: warning +redundant_optional_initialization: warning +redundant_string_enum_value: warning +sorted_first_last: warning +sorted_imports: warning +statement_position: + severity: warning diff --git a/iOSBooks/Podfile b/iOSBooks/Podfile new file mode 100644 index 0000000..98d6539 --- /dev/null +++ b/iOSBooks/Podfile @@ -0,0 +1,20 @@ +# Uncomment the next line to define a global platform for your project +platform :ios, '12.0' + +target 'iOSBooks' do + # Comment the next line if you don't want to use dynamic frameworks + use_frameworks! + + # Pods for iOSBooks + pod 'PromiseKit' + pod 'Kingfisher', "~>5.1" + pod 'SwiftLint' + + target 'iOSBooksTests' do + inherit! :search_paths + # Pods for testing + pod 'Quick', '~> 2.1.0' + pod 'Nimble', '~> 8.0.1' + end + +end diff --git a/iOSBooks/iOSBooks.xcodeproj/project.pbxproj b/iOSBooks/iOSBooks.xcodeproj/project.pbxproj index b0482bb..7ea6356 100644 --- a/iOSBooks/iOSBooks.xcodeproj/project.pbxproj +++ b/iOSBooks/iOSBooks.xcodeproj/project.pbxproj @@ -3,16 +3,43 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 51; objects = { /* Begin PBXBuildFile section */ 0E3964D82314BB470093738B /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3964D72314BB470093738B /* AppDelegate.swift */; }; - 0E3964DA2314BB470093738B /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3964D92314BB470093738B /* ViewController.swift */; }; - 0E3964DD2314BB470093738B /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0E3964DB2314BB470093738B /* Main.storyboard */; }; 0E3964DF2314BB4A0093738B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0E3964DE2314BB4A0093738B /* Assets.xcassets */; }; 0E3964E22314BB4A0093738B /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0E3964E02314BB4A0093738B /* LaunchScreen.storyboard */; }; - 0E3964ED2314BB4A0093738B /* iOSBooksTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3964EC2314BB4A0093738B /* iOSBooksTests.swift */; }; + 0E3964ED2314BB4A0093738B /* BookDetailViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3964EC2314BB4A0093738B /* BookDetailViewModelTests.swift */; }; + 477DA993F6CBE89C179034EE /* Pods_iOSBooksTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 28F20F388C60D311258C5680 /* Pods_iOSBooksTests.framework */; }; + 7447D35E2316C91700E01BD3 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7447D35D2316C91700E01BD3 /* APIClient.swift */; }; + 7447D3602316CB1200E01BD3 /* Reachability.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7447D35F2316CB1200E01BD3 /* Reachability.swift */; }; + 7447D3622316DECA00E01BD3 /* BooksAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7447D3612316DECA00E01BD3 /* BooksAPI.swift */; }; + 7447D3642316E02500E01BD3 /* Endpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7447D3632316E02500E01BD3 /* Endpoint.swift */; }; + 7447D3662316E3C100E01BD3 /* BooksClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7447D3652316E3C100E01BD3 /* BooksClient.swift */; }; + 7447D3682316E57500E01BD3 /* Book.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7447D3672316E57500E01BD3 /* Book.swift */; }; + 7447D36A2316F11800E01BD3 /* BooksList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7447D3692316F11800E01BD3 /* BooksList.swift */; }; + 7447D36C2316F12800E01BD3 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7447D36B2316F12800E01BD3 /* Item.swift */; }; + 7447D36E2316F13A00E01BD3 /* ImageLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7447D36D2316F13A00E01BD3 /* ImageLinks.swift */; }; + 7447D3702316F14D00E01BD3 /* SalesInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7447D36F2316F14D00E01BD3 /* SalesInfo.swift */; }; + 7447D37A2316F71200E01BD3 /* BooksListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7447D3792316F71200E01BD3 /* BooksListViewModel.swift */; }; + 7447D37C2316F71D00E01BD3 /* BooksListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7447D37B2316F71D00E01BD3 /* BooksListViewController.swift */; }; + 7447D37E2316F72A00E01BD3 /* BooksList.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 7447D37D2316F72A00E01BD3 /* BooksList.storyboard */; }; + 74ADE9B823196A7A006D2644 /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74ADE9B723196A7A006D2644 /* AppCoordinator.swift */; }; + 74ADE9BE23196CE3006D2644 /* AppCoordinatorDependencyInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74ADE9BD23196CE3006D2644 /* AppCoordinatorDependencyInjector.swift */; }; + 74ADE9C023196E44006D2644 /* UIViewController+QuickInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74ADE9BF23196E44006D2644 /* UIViewController+QuickInstance.swift */; }; + 74ADE9C223196E99006D2644 /* Identifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74ADE9C123196E99006D2644 /* Identifiable.swift */; }; + 74ADE9C52319730F006D2644 /* BooksCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74ADE9C42319730F006D2644 /* BooksCell.swift */; }; + 74ADE9C7231973B5006D2644 /* BooksListViewController+CollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74ADE9C6231973B5006D2644 /* BooksListViewController+CollectionView.swift */; }; + 74ADE9C9231981F5006D2644 /* BooksListViewController+View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74ADE9C8231981F5006D2644 /* BooksListViewController+View.swift */; }; + 74ADE9D023199C1F006D2644 /* BookDetail.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 74ADE9CF23199C1F006D2644 /* BookDetail.storyboard */; }; + 74ADE9D223199D97006D2644 /* BookDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74ADE9D123199D97006D2644 /* BookDetailViewController.swift */; }; + 74B9185223199E8F00ADDEA4 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B9185123199E8F00ADDEA4 /* BookDetailViewModel.swift */; }; + 74B9185423199F4500ADDEA4 /* BookDetailViewController+View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B9185323199F4500ADDEA4 /* BookDetailViewController+View.swift */; }; + 74B918562319AE0300ADDEA4 /* BookDetailViewController+Safari.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B918552319AE0300ADDEA4 /* BookDetailViewController+Safari.swift */; }; + 74B918582319B2CF00ADDEA4 /* CoreDataClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B918572319B2CF00ADDEA4 /* CoreDataClient.swift */; }; + 74B9185B2319B2DD00ADDEA4 /* Book.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 74B918592319B2DD00ADDEA4 /* Book.xcdatamodeld */; }; + 768F74E39672B1F50C632913 /* Pods_iOSBooks.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2D2FDB2EC5907498F6A3C173 /* Pods_iOSBooks.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -28,14 +55,45 @@ /* Begin PBXFileReference section */ 0E3964D42314BB470093738B /* iOSBooks.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iOSBooks.app; sourceTree = BUILT_PRODUCTS_DIR; }; 0E3964D72314BB470093738B /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 0E3964D92314BB470093738B /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; - 0E3964DC2314BB470093738B /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 0E3964DE2314BB4A0093738B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 0E3964E12314BB4A0093738B /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 0E3964E32314BB4A0093738B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 0E3964E82314BB4A0093738B /* iOSBooksTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = iOSBooksTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 0E3964EC2314BB4A0093738B /* iOSBooksTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSBooksTests.swift; sourceTree = ""; }; + 0E3964EC2314BB4A0093738B /* BookDetailViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailViewModelTests.swift; sourceTree = ""; }; 0E3964EE2314BB4A0093738B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 18F9E4DBC4A549B0B6E5DFAB /* Pods-iOSBooksTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOSBooksTests.release.xcconfig"; path = "Target Support Files/Pods-iOSBooksTests/Pods-iOSBooksTests.release.xcconfig"; sourceTree = ""; }; + 275296C36B2B5A1EBD78A5F1 /* Pods-iOSBooks.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOSBooks.release.xcconfig"; path = "Target Support Files/Pods-iOSBooks/Pods-iOSBooks.release.xcconfig"; sourceTree = ""; }; + 28F20F388C60D311258C5680 /* Pods_iOSBooksTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOSBooksTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 2D2FDB2EC5907498F6A3C173 /* Pods_iOSBooks.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOSBooks.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 48CC3F84588BF21024860E64 /* Pods-iOSBooksTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOSBooksTests.debug.xcconfig"; path = "Target Support Files/Pods-iOSBooksTests/Pods-iOSBooksTests.debug.xcconfig"; sourceTree = ""; }; + 7447D35D2316C91700E01BD3 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; + 7447D35F2316CB1200E01BD3 /* Reachability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reachability.swift; sourceTree = ""; }; + 7447D3612316DECA00E01BD3 /* BooksAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BooksAPI.swift; sourceTree = ""; }; + 7447D3632316E02500E01BD3 /* Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Endpoint.swift; sourceTree = ""; }; + 7447D3652316E3C100E01BD3 /* BooksClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BooksClient.swift; sourceTree = ""; }; + 7447D3672316E57500E01BD3 /* Book.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Book.swift; sourceTree = ""; }; + 7447D3692316F11800E01BD3 /* BooksList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BooksList.swift; sourceTree = ""; }; + 7447D36B2316F12800E01BD3 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = ""; }; + 7447D36D2316F13A00E01BD3 /* ImageLinks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLinks.swift; sourceTree = ""; }; + 7447D36F2316F14D00E01BD3 /* SalesInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SalesInfo.swift; sourceTree = ""; }; + 7447D3792316F71200E01BD3 /* BooksListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BooksListViewModel.swift; sourceTree = ""; }; + 7447D37B2316F71D00E01BD3 /* BooksListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BooksListViewController.swift; sourceTree = ""; }; + 7447D37D2316F72A00E01BD3 /* BooksList.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = BooksList.storyboard; sourceTree = ""; }; + 74ADE9B723196A7A006D2644 /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; + 74ADE9BD23196CE3006D2644 /* AppCoordinatorDependencyInjector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinatorDependencyInjector.swift; sourceTree = ""; }; + 74ADE9BF23196E44006D2644 /* UIViewController+QuickInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+QuickInstance.swift"; sourceTree = ""; }; + 74ADE9C123196E99006D2644 /* Identifiable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Identifiable.swift; sourceTree = ""; }; + 74ADE9C42319730F006D2644 /* BooksCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BooksCell.swift; sourceTree = ""; }; + 74ADE9C6231973B5006D2644 /* BooksListViewController+CollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BooksListViewController+CollectionView.swift"; sourceTree = ""; }; + 74ADE9C8231981F5006D2644 /* BooksListViewController+View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BooksListViewController+View.swift"; sourceTree = ""; }; + 74ADE9CF23199C1F006D2644 /* BookDetail.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = BookDetail.storyboard; sourceTree = ""; }; + 74ADE9D123199D97006D2644 /* BookDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailViewController.swift; sourceTree = ""; }; + 74B9185123199E8F00ADDEA4 /* BookDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailViewModel.swift; sourceTree = ""; }; + 74B9185323199F4500ADDEA4 /* BookDetailViewController+View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BookDetailViewController+View.swift"; sourceTree = ""; }; + 74B918552319AE0300ADDEA4 /* BookDetailViewController+Safari.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BookDetailViewController+Safari.swift"; sourceTree = ""; }; + 74B918572319B2CF00ADDEA4 /* CoreDataClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataClient.swift; sourceTree = ""; }; + 74B9185A2319B2DD00ADDEA4 /* Book.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Book.xcdatamodel; sourceTree = ""; }; + E6741F045EBC12BB315EE1CB /* Pods-iOSBooks.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOSBooks.debug.xcconfig"; path = "Target Support Files/Pods-iOSBooks/Pods-iOSBooks.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -43,6 +101,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 768F74E39672B1F50C632913 /* Pods_iOSBooks.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -50,6 +109,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 477DA993F6CBE89C179034EE /* Pods_iOSBooksTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -62,6 +122,8 @@ 0E3964D62314BB470093738B /* iOSBooks */, 0E3964EB2314BB4A0093738B /* iOSBooksTests */, 0E3964D52314BB470093738B /* Products */, + E4186846F20EA500EF61D605 /* Pods */, + 46A8A270FB95E2805738E1EE /* Frameworks */, ); sourceTree = ""; }; @@ -77,12 +139,16 @@ 0E3964D62314BB470093738B /* iOSBooks */ = { isa = PBXGroup; children = ( + 74ADE9B5231969F0006D2644 /* Coordinators */, + 7447D3762316F6C700E01BD3 /* BooksList */, + 74ADE9D323199E0F006D2644 /* BookDetail */, + 7447D3712316F16E00E01BD3 /* Data */, 0E3964D72314BB470093738B /* AppDelegate.swift */, - 0E3964D92314BB470093738B /* ViewController.swift */, - 0E3964DB2314BB470093738B /* Main.storyboard */, 0E3964DE2314BB4A0093738B /* Assets.xcassets */, 0E3964E02314BB4A0093738B /* LaunchScreen.storyboard */, 0E3964E32314BB4A0093738B /* Info.plist */, + 74ADE9BF23196E44006D2644 /* UIViewController+QuickInstance.swift */, + 74ADE9C123196E99006D2644 /* Identifiable.swift */, ); path = iOSBooks; sourceTree = ""; @@ -90,12 +156,165 @@ 0E3964EB2314BB4A0093738B /* iOSBooksTests */ = { isa = PBXGroup; children = ( - 0E3964EC2314BB4A0093738B /* iOSBooksTests.swift */, + 0E3964EC2314BB4A0093738B /* BookDetailViewModelTests.swift */, 0E3964EE2314BB4A0093738B /* Info.plist */, ); path = iOSBooksTests; sourceTree = ""; }; + 46A8A270FB95E2805738E1EE /* Frameworks */ = { + isa = PBXGroup; + children = ( + 2D2FDB2EC5907498F6A3C173 /* Pods_iOSBooks.framework */, + 28F20F388C60D311258C5680 /* Pods_iOSBooksTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 7447D3712316F16E00E01BD3 /* Data */ = { + isa = PBXGroup; + children = ( + 7447D3752316F1A200E01BD3 /* Clients */, + 7447D3742316F19000E01BD3 /* Network */, + 7447D3732316F18400E01BD3 /* Endpoints */, + 7447D3722316F17900E01BD3 /* Models */, + ); + path = Data; + sourceTree = ""; + }; + 7447D3722316F17900E01BD3 /* Models */ = { + isa = PBXGroup; + children = ( + 7447D3672316E57500E01BD3 /* Book.swift */, + 7447D3692316F11800E01BD3 /* BooksList.swift */, + 7447D36B2316F12800E01BD3 /* Item.swift */, + 7447D36D2316F13A00E01BD3 /* ImageLinks.swift */, + 7447D36F2316F14D00E01BD3 /* SalesInfo.swift */, + ); + path = Models; + sourceTree = ""; + }; + 7447D3732316F18400E01BD3 /* Endpoints */ = { + isa = PBXGroup; + children = ( + 7447D3632316E02500E01BD3 /* Endpoint.swift */, + 7447D3612316DECA00E01BD3 /* BooksAPI.swift */, + ); + path = Endpoints; + sourceTree = ""; + }; + 7447D3742316F19000E01BD3 /* Network */ = { + isa = PBXGroup; + children = ( + 7447D35D2316C91700E01BD3 /* APIClient.swift */, + 7447D35F2316CB1200E01BD3 /* Reachability.swift */, + ); + path = Network; + sourceTree = ""; + }; + 7447D3752316F1A200E01BD3 /* Clients */ = { + isa = PBXGroup; + children = ( + 7447D3652316E3C100E01BD3 /* BooksClient.swift */, + 74B918572319B2CF00ADDEA4 /* CoreDataClient.swift */, + 74B918592319B2DD00ADDEA4 /* Book.xcdatamodeld */, + ); + path = Clients; + sourceTree = ""; + }; + 7447D3762316F6C700E01BD3 /* BooksList */ = { + isa = PBXGroup; + children = ( + 7447D3782316F6D700E01BD3 /* View */, + 7447D3772316F6CF00E01BD3 /* ViewModel */, + ); + path = BooksList; + sourceTree = ""; + }; + 7447D3772316F6CF00E01BD3 /* ViewModel */ = { + isa = PBXGroup; + children = ( + 7447D3792316F71200E01BD3 /* BooksListViewModel.swift */, + ); + path = ViewModel; + sourceTree = ""; + }; + 7447D3782316F6D700E01BD3 /* View */ = { + isa = PBXGroup; + children = ( + 7447D37D2316F72A00E01BD3 /* BooksList.storyboard */, + 7447D37B2316F71D00E01BD3 /* BooksListViewController.swift */, + 74ADE9C6231973B5006D2644 /* BooksListViewController+CollectionView.swift */, + 74ADE9C8231981F5006D2644 /* BooksListViewController+View.swift */, + 74ADE9C3231972ED006D2644 /* Subviews */, + ); + path = View; + sourceTree = ""; + }; + 74ADE9B5231969F0006D2644 /* Coordinators */ = { + isa = PBXGroup; + children = ( + 74ADE9B6231969F7006D2644 /* AppCoordinator */, + ); + path = Coordinators; + sourceTree = ""; + }; + 74ADE9B6231969F7006D2644 /* AppCoordinator */ = { + isa = PBXGroup; + children = ( + 74ADE9B723196A7A006D2644 /* AppCoordinator.swift */, + 74ADE9BD23196CE3006D2644 /* AppCoordinatorDependencyInjector.swift */, + ); + path = AppCoordinator; + sourceTree = ""; + }; + 74ADE9C3231972ED006D2644 /* Subviews */ = { + isa = PBXGroup; + children = ( + 74ADE9C42319730F006D2644 /* BooksCell.swift */, + ); + path = Subviews; + sourceTree = ""; + }; + 74ADE9D323199E0F006D2644 /* BookDetail */ = { + isa = PBXGroup; + children = ( + 74B9184F23199E6D00ADDEA4 /* View */, + 74B9185023199E7700ADDEA4 /* ViewModel */, + ); + path = BookDetail; + sourceTree = ""; + }; + 74B9184F23199E6D00ADDEA4 /* View */ = { + isa = PBXGroup; + children = ( + 74ADE9CF23199C1F006D2644 /* BookDetail.storyboard */, + 74ADE9D123199D97006D2644 /* BookDetailViewController.swift */, + 74B9185323199F4500ADDEA4 /* BookDetailViewController+View.swift */, + 74B918552319AE0300ADDEA4 /* BookDetailViewController+Safari.swift */, + ); + path = View; + sourceTree = ""; + }; + 74B9185023199E7700ADDEA4 /* ViewModel */ = { + isa = PBXGroup; + children = ( + 74B9185123199E8F00ADDEA4 /* BookDetailViewModel.swift */, + ); + path = ViewModel; + sourceTree = ""; + }; + E4186846F20EA500EF61D605 /* Pods */ = { + isa = PBXGroup; + children = ( + E6741F045EBC12BB315EE1CB /* Pods-iOSBooks.debug.xcconfig */, + 275296C36B2B5A1EBD78A5F1 /* Pods-iOSBooks.release.xcconfig */, + 48CC3F84588BF21024860E64 /* Pods-iOSBooksTests.debug.xcconfig */, + 18F9E4DBC4A549B0B6E5DFAB /* Pods-iOSBooksTests.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -103,9 +322,12 @@ isa = PBXNativeTarget; buildConfigurationList = 0E3964F12314BB4A0093738B /* Build configuration list for PBXNativeTarget "iOSBooks" */; buildPhases = ( + 15C7FF8C9D9E1A03AF7AE485 /* [CP] Check Pods Manifest.lock */, 0E3964D02314BB470093738B /* Sources */, 0E3964D12314BB470093738B /* Frameworks */, 0E3964D22314BB470093738B /* Resources */, + 29B8A3AC18D11FEF43C48244 /* [CP] Embed Pods Frameworks */, + 74B9187D231CBF9800ADDEA4 /* ShellScript */, ); buildRules = ( ); @@ -120,9 +342,11 @@ isa = PBXNativeTarget; buildConfigurationList = 0E3964F42314BB4A0093738B /* Build configuration list for PBXNativeTarget "iOSBooksTests" */; buildPhases = ( + B1160ABF1429D7BD27416220 /* [CP] Check Pods Manifest.lock */, 0E3964E42314BB4A0093738B /* Sources */, 0E3964E52314BB4A0093738B /* Frameworks */, 0E3964E62314BB4A0093738B /* Resources */, + 6A60DF844467B985FBAD6155 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -178,8 +402,9 @@ buildActionMask = 2147483647; files = ( 0E3964E22314BB4A0093738B /* LaunchScreen.storyboard in Resources */, + 7447D37E2316F72A00E01BD3 /* BooksList.storyboard in Resources */, + 74ADE9D023199C1F006D2644 /* BookDetail.storyboard in Resources */, 0E3964DF2314BB4A0093738B /* Assets.xcassets in Resources */, - 0E3964DD2314BB470093738B /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -192,13 +417,135 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 15C7FF8C9D9E1A03AF7AE485 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-iOSBooks-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 29B8A3AC18D11FEF43C48244 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-iOSBooks/Pods-iOSBooks-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-iOSBooks/Pods-iOSBooks-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iOSBooks/Pods-iOSBooks-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 6A60DF844467B985FBAD6155 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-iOSBooksTests/Pods-iOSBooksTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-iOSBooksTests/Pods-iOSBooksTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iOSBooksTests/Pods-iOSBooksTests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 74B9187D231CBF9800ADDEA4 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if which swiftlint >/dev/null; then\nswiftlint\nelse\necho \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + }; + B1160ABF1429D7BD27416220 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-iOSBooksTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 0E3964D02314BB470093738B /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 0E3964DA2314BB470093738B /* ViewController.swift in Sources */, + 74ADE9C9231981F5006D2644 /* BooksListViewController+View.swift in Sources */, + 74ADE9C223196E99006D2644 /* Identifiable.swift in Sources */, + 7447D36C2316F12800E01BD3 /* Item.swift in Sources */, + 7447D3682316E57500E01BD3 /* Book.swift in Sources */, + 74B9185423199F4500ADDEA4 /* BookDetailViewController+View.swift in Sources */, + 74ADE9BE23196CE3006D2644 /* AppCoordinatorDependencyInjector.swift in Sources */, + 7447D3702316F14D00E01BD3 /* SalesInfo.swift in Sources */, + 7447D37A2316F71200E01BD3 /* BooksListViewModel.swift in Sources */, + 74B918582319B2CF00ADDEA4 /* CoreDataClient.swift in Sources */, + 7447D36E2316F13A00E01BD3 /* ImageLinks.swift in Sources */, + 7447D37C2316F71D00E01BD3 /* BooksListViewController.swift in Sources */, + 74ADE9C7231973B5006D2644 /* BooksListViewController+CollectionView.swift in Sources */, + 74B9185B2319B2DD00ADDEA4 /* Book.xcdatamodeld in Sources */, + 74ADE9C52319730F006D2644 /* BooksCell.swift in Sources */, + 74ADE9D223199D97006D2644 /* BookDetailViewController.swift in Sources */, + 74ADE9B823196A7A006D2644 /* AppCoordinator.swift in Sources */, + 74B918562319AE0300ADDEA4 /* BookDetailViewController+Safari.swift in Sources */, 0E3964D82314BB470093738B /* AppDelegate.swift in Sources */, + 74ADE9C023196E44006D2644 /* UIViewController+QuickInstance.swift in Sources */, + 7447D3602316CB1200E01BD3 /* Reachability.swift in Sources */, + 7447D35E2316C91700E01BD3 /* APIClient.swift in Sources */, + 7447D3642316E02500E01BD3 /* Endpoint.swift in Sources */, + 74B9185223199E8F00ADDEA4 /* BookDetailViewModel.swift in Sources */, + 7447D36A2316F11800E01BD3 /* BooksList.swift in Sources */, + 7447D3662316E3C100E01BD3 /* BooksClient.swift in Sources */, + 7447D3622316DECA00E01BD3 /* BooksAPI.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -206,7 +553,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 0E3964ED2314BB4A0093738B /* iOSBooksTests.swift in Sources */, + 0E3964ED2314BB4A0093738B /* BookDetailViewModelTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -221,14 +568,6 @@ /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ - 0E3964DB2314BB470093738B /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 0E3964DC2314BB470093738B /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; 0E3964E02314BB4A0093738B /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( @@ -358,11 +697,13 @@ }; 0E3964F22314BB4A0093738B /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = E6741F045EBC12BB315EE1CB /* Pods-iOSBooks.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 7AH86L34EQ; INFOPLIST_FILE = iOSBooks/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -376,11 +717,13 @@ }; 0E3964F32314BB4A0093738B /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 275296C36B2B5A1EBD78A5F1 /* Pods-iOSBooks.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 7AH86L34EQ; INFOPLIST_FILE = iOSBooks/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -394,12 +737,14 @@ }; 0E3964F52314BB4A0093738B /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 48CC3F84588BF21024860E64 /* Pods-iOSBooksTests.debug.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 7AH86L34EQ; INFOPLIST_FILE = iOSBooksTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -415,12 +760,14 @@ }; 0E3964F62314BB4A0093738B /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 18F9E4DBC4A549B0B6E5DFAB /* Pods-iOSBooksTests.release.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 7AH86L34EQ; INFOPLIST_FILE = iOSBooksTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -465,6 +812,19 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCVersionGroup section */ + 74B918592319B2DD00ADDEA4 /* Book.xcdatamodeld */ = { + isa = XCVersionGroup; + children = ( + 74B9185A2319B2DD00ADDEA4 /* Book.xcdatamodel */, + ); + currentVersion = 74B9185A2319B2DD00ADDEA4 /* Book.xcdatamodel */; + path = Book.xcdatamodeld; + sourceTree = ""; + versionGroupType = wrapper.xcdatamodel; + }; +/* End XCVersionGroup section */ }; rootObject = 0E3964CC2314BB470093738B /* Project object */; } diff --git a/iOSBooks/iOSBooks/AppDelegate.swift b/iOSBooks/iOSBooks/AppDelegate.swift index cc5fb9d..f821be6 100644 --- a/iOSBooks/iOSBooks/AppDelegate.swift +++ b/iOSBooks/iOSBooks/AppDelegate.swift @@ -6,16 +6,25 @@ // Copyright © 2019 Guilherme Antunes. All rights reserved. // +import CoreData import UIKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? - - + var coordinator: AppCoordinator? + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. + + window = UIWindow(frame: UIScreen.main.bounds) + guard let window = window else { + print("window is unexpectedly nil") + return false + } + coordinator = AppCoordinator(window: window) + coordinator?.start() + return true } @@ -38,9 +47,53 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } func applicationWillTerminate(_ application: UIApplication) { - // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + window = nil } + + // MARK: - Core Data stack + + lazy var persistentContainer: NSPersistentContainer = { + /* + The persistent container for the application. This implementation + creates and returns a container, having loaded the store for the + application to it. This property is optional since there are legitimate + error conditions that could cause the creation of the store to fail. + */ + let container = NSPersistentContainer(name: "Book") + container.loadPersistentStores(completionHandler: { (storeDescription, error) in + if let error = error as NSError? { + // Replace this implementation with code to handle the error appropriately. + // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. + + /* + Typical reasons for an error here include: + * The parent directory does not exist, cannot be created, or disallows writing. + * The persistent store is not accessible, due to permissions or data protection when the device is locked. + * The device is out of space. + * The store could not be migrated to the current model version. + Check the error message to determine what the actual problem was. + */ + fatalError("Unresolved error \(error), \(error.userInfo)") + } + }) + return container + }() + // MARK: - Core Data Saving support + + func saveContext () { + let context = persistentContainer.viewContext + if context.hasChanges { + do { + try context.save() + } catch { + // Replace this implementation with code to handle the error appropriately. + // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. + let nserror = error as NSError + fatalError("Unresolved error \(nserror), \(nserror.userInfo)") + } + } + } } diff --git a/iOSBooks/iOSBooks/Assets.xcassets/back_arrow.imageset/Contents.json b/iOSBooks/iOSBooks/Assets.xcassets/back_arrow.imageset/Contents.json new file mode 100644 index 0000000..b7b948c --- /dev/null +++ b/iOSBooks/iOSBooks/Assets.xcassets/back_arrow.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "back_arrow.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "back_arrow@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "back_arrow@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/iOSBooks/iOSBooks/Assets.xcassets/back_arrow.imageset/back_arrow.png b/iOSBooks/iOSBooks/Assets.xcassets/back_arrow.imageset/back_arrow.png new file mode 100644 index 0000000..6575439 Binary files /dev/null and b/iOSBooks/iOSBooks/Assets.xcassets/back_arrow.imageset/back_arrow.png differ diff --git a/iOSBooks/iOSBooks/Assets.xcassets/back_arrow.imageset/back_arrow@2x.png b/iOSBooks/iOSBooks/Assets.xcassets/back_arrow.imageset/back_arrow@2x.png new file mode 100644 index 0000000..11c6c4c Binary files /dev/null and b/iOSBooks/iOSBooks/Assets.xcassets/back_arrow.imageset/back_arrow@2x.png differ diff --git a/iOSBooks/iOSBooks/Assets.xcassets/back_arrow.imageset/back_arrow@3x.png b/iOSBooks/iOSBooks/Assets.xcassets/back_arrow.imageset/back_arrow@3x.png new file mode 100644 index 0000000..1ce9897 Binary files /dev/null and b/iOSBooks/iOSBooks/Assets.xcassets/back_arrow.imageset/back_arrow@3x.png differ diff --git a/iOSBooks/iOSBooks/Base.lproj/Main.storyboard b/iOSBooks/iOSBooks/Base.lproj/Main.storyboard deleted file mode 100644 index f1bcf38..0000000 --- a/iOSBooks/iOSBooks/Base.lproj/Main.storyboard +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/iOSBooks/iOSBooks/BookDetail/View/BookDetail.storyboard b/iOSBooks/iOSBooks/BookDetail/View/BookDetail.storyboard new file mode 100644 index 0000000..0b0379b --- /dev/null +++ b/iOSBooks/iOSBooks/BookDetail/View/BookDetail.storyboard @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOSBooks/iOSBooks/BookDetail/View/BookDetailViewController+Safari.swift b/iOSBooks/iOSBooks/BookDetail/View/BookDetailViewController+Safari.swift new file mode 100644 index 0000000..cfaad1a --- /dev/null +++ b/iOSBooks/iOSBooks/BookDetail/View/BookDetailViewController+Safari.swift @@ -0,0 +1,16 @@ +// +// BookDetailViewController+Safari.swift +// iOSBooks +// +// Created by Guilherme Antunes Ferreira on 30/08/19. +// Copyright © 2019 Guilherme Antunes. All rights reserved. +// + +import UIKit + +extension BookDetailViewController { + @objc func openBuyLink() { + guard let buyLink = viewModel?.getBookBuyLinkURL() else { return } + UIApplication.shared.open(buyLink, options: [:], completionHandler: nil) + } +} diff --git a/iOSBooks/iOSBooks/BookDetail/View/BookDetailViewController+View.swift b/iOSBooks/iOSBooks/BookDetail/View/BookDetailViewController+View.swift new file mode 100644 index 0000000..d3072da --- /dev/null +++ b/iOSBooks/iOSBooks/BookDetail/View/BookDetailViewController+View.swift @@ -0,0 +1,50 @@ +// +// BookDetailViewController+View.swift +// iOSBooks +// +// Created by Guilherme Antunes Ferreira on 30/08/19. +// Copyright © 2019 Guilherme Antunes. All rights reserved. +// + +import Kingfisher +import UIKit + +protocol BookDetailViewControllerPresentable: class { + +} + +extension BookDetailViewController: BookDetailViewControllerPresentable { + + func configureTap() { + let tap = UITapGestureRecognizer(target: self, action: #selector(openBuyLink)) + buyLinkLabel?.addGestureRecognizer(tap) + } + + func setupView() { + if let button = favoriteButton { + toggleFavoriteButton(button) + } + title = viewModel?.getScreenTitle() + bookImageView?.kf.setImage(with: viewModel?.getBookImageLink(), options: [.cacheOriginalImage]) + bookTitleLabel?.text = viewModel?.getBookTitle() + bookAuthorsLabel?.text = viewModel?.getBookAuthors() + buyLinkLabel?.text = viewModel?.getBookBuyLink() + descriptionTextView?.text = viewModel?.getBookDescription() + } + + func setupNavigation() { + navigationController?.navigationBar.prefersLargeTitles = false + setBackButton(#selector(dismissScreen)) + } + + func toggleFavoriteButton(_ button: UIButton) { + button.layer.cornerRadius = 10 + guard let viewModel = viewModel else { return } + if viewModel.savedBook { + button.backgroundColor = .yellow + return + } + button.backgroundColor = .white + } + +} diff --git a/iOSBooks/iOSBooks/BookDetail/View/BookDetailViewController.swift b/iOSBooks/iOSBooks/BookDetail/View/BookDetailViewController.swift new file mode 100644 index 0000000..f1ccb35 --- /dev/null +++ b/iOSBooks/iOSBooks/BookDetail/View/BookDetailViewController.swift @@ -0,0 +1,47 @@ +// +// BookDetailViewController.swift +// iOSBooks +// +// Created by Guilherme Antunes Ferreira on 30/08/19. +// Copyright © 2019 Guilherme Antunes. All rights reserved. +// + +import UIKit + +class BookDetailViewController: UIViewController { + + // MARK: - Outlets + @IBOutlet weak var bookImageView: UIImageView? + @IBOutlet weak var bookTitleLabel: UILabel? + @IBOutlet weak var bookAuthorsLabel: UILabel? + @IBOutlet weak var buyLinkLabel: UILabel? + @IBOutlet weak var descriptionTextView: UITextView? + @IBOutlet weak var favoriteButton: UIButton? + + // MARK: - Properties + var viewModel: BookDetailViewModelProtocol? + + // MARK: - View Life Cycle + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + setupView() + setupNavigation() + configureTap() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + descriptionTextView?.scrollRangeToVisible(NSRange(location: 0, length: 0)) + } + + @objc func dismissScreen() { + viewModel?.presentPreviousStep() + } + + // MARK: - Actions + @IBAction func saveBook(_ sender: UIButton) { + viewModel?.saveBookIfNeeded(andImage: bookImageView?.image) + toggleFavoriteButton(sender) + } + +} diff --git a/iOSBooks/iOSBooks/BookDetail/ViewModel/BookDetailViewModel.swift b/iOSBooks/iOSBooks/BookDetail/ViewModel/BookDetailViewModel.swift new file mode 100644 index 0000000..a0790bc --- /dev/null +++ b/iOSBooks/iOSBooks/BookDetail/ViewModel/BookDetailViewModel.swift @@ -0,0 +1,94 @@ +// +// BookDetailViewModel.swift +// iOSBooks +// +// Created by Guilherme Antunes Ferreira on 30/08/19. +// Copyright © 2019 Guilherme Antunes. All rights reserved. +// + +import UIKit + +protocol BookDetailViewModelProtocol { + func getBookTitle() -> String + func getBookAuthors() -> String + func getBookBuyLink() -> String + func getBookDescription() -> String + func getBookImageLink() -> URL? + func presentPreviousStep() + func getScreenTitle() -> String? + func getBookBuyLinkURL() -> URL? + func saveBookIfNeeded(andImage image: UIImage?) + var savedBook: Bool { get } +} + +class BookDetailViewModel: BookDetailViewModelProtocol { + + // MARK: - Properties + var selectedBook: Item? + weak var view: BookDetailViewControllerPresentable? + var coordinator: AppCoordinatorProtocol? + var service: CoreDataClientProtocol? + var savedBook: Bool = false + + // MARK: - ViewModel Protocol Methods + func getBookTitle() -> String { + guard let bookTitle = selectedBook?.book?.title else { return "Título indisponível" } + var title = "Título: " + title.append(bookTitle) + return title + } + + func getBookAuthors() -> String { + guard let bookAuthor = selectedBook?.book?.authors?.first else { return "Autores não disponíveis" } + var author = "Autor: " + author.append(bookAuthor) + return author + } + + func getBookBuyLink() -> String { + return selectedBook?.salesInfo?.buyLink ?? "Link de compra não disponível" + } + + func getBookDescription() -> String { + return selectedBook?.book?.description ?? "Sem descrição disponível" + } + + func getBookImageLink() -> URL? { + if let bookURLString = selectedBook?.book?.imageLinks?.thumbnail { + return URL(string: bookURLString) + } + + return nil + } + + func getScreenTitle() -> String? { + return selectedBook?.book?.title + } + + func presentPreviousStep() { + coordinator?.presentPreviousStep() + } + + func getBookBuyLinkURL() -> URL? { + if let urlString = selectedBook?.salesInfo?.buyLink { + return URL(string: urlString) + } + + return nil + } + + func saveBookIfNeeded(andImage image: UIImage? = nil) { + if savedBook { + deleteBook() + return + } + savedBook = true + service?.saveBook(selectedBook, withThumbnail: image) + } + + func deleteBook() { + savedBook = false + service?.deleteBook(selectedBook) + + } +} diff --git a/iOSBooks/iOSBooks/BooksList/View/BooksList.storyboard b/iOSBooks/iOSBooks/BooksList/View/BooksList.storyboard new file mode 100644 index 0000000..14bcfd6 --- /dev/null +++ b/iOSBooks/iOSBooks/BooksList/View/BooksList.storyboard @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOSBooks/iOSBooks/BooksList/View/BooksListViewController+CollectionView.swift b/iOSBooks/iOSBooks/BooksList/View/BooksListViewController+CollectionView.swift new file mode 100644 index 0000000..d22a042 --- /dev/null +++ b/iOSBooks/iOSBooks/BooksList/View/BooksListViewController+CollectionView.swift @@ -0,0 +1,27 @@ +// +// BooksListViewController+CollectionView.swift +// iOSBooks +// +// Created by Guilherme Antunes Ferreira on 30/08/19. +// Copyright © 2019 Guilherme Antunes. All rights reserved. +// + +import UIKit + +extension BooksListViewController: UICollectionViewDataSource, UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return viewModel?.numberOfItemsInSection(section) ?? 0 + } + + func numberOfSections(in collectionView: UICollectionView) -> Int { + return viewModel?.numberOfSections() ?? 0 + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + return viewModel?.cellForItem(inCollectionView: collectionView, atIndexPath: indexPath) ?? UICollectionViewCell() + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + viewModel?.selectBook(atIndexPath: indexPath) + } +} diff --git a/iOSBooks/iOSBooks/BooksList/View/BooksListViewController+View.swift b/iOSBooks/iOSBooks/BooksList/View/BooksListViewController+View.swift new file mode 100644 index 0000000..05a7121 --- /dev/null +++ b/iOSBooks/iOSBooks/BooksList/View/BooksListViewController+View.swift @@ -0,0 +1,52 @@ +// +// BooksListViewController+View.swift +// iOSBooks +// +// Created by Guilherme Antunes Ferreira on 30/08/19. +// Copyright © 2019 Guilherme Antunes. All rights reserved. +// + +import UIKit + +protocol BooksListViewControllerPresentable: class { + func reloadView() + func presentError(message: String) +} + +extension BooksListViewController: BooksListViewControllerPresentable { + func reloadView() { + if let viewModel = viewModel { + title = viewModel.showFavorites ? "Favorites iOS Books" : "iOS Books" + } + booksCollectionView?.reloadData() + } + + func presentError(message: String) { + alert(message: message) + } + + func setupCollectionView() { + booksCollectionView?.dataSource = self + booksCollectionView?.delegate = self + } + + func setupNavigationBar() { + navigationController?.navigationBar.prefersLargeTitles = true + } + + @objc func fillWithSavedBooks() { + guard var viewModel = viewModel else { return } + if !viewModel.showFavorites { + viewModel.showFavorites.toggle() + viewModel.loadSavedBooks() + reloadView() + return + } + viewModel.showFavorites.toggle() + reloadView() + } + + func setupFavoritesFilterButton() { + navigationItem.rightBarButtonItem = button + } +} diff --git a/iOSBooks/iOSBooks/BooksList/View/BooksListViewController.swift b/iOSBooks/iOSBooks/BooksList/View/BooksListViewController.swift new file mode 100644 index 0000000..e3c47c6 --- /dev/null +++ b/iOSBooks/iOSBooks/BooksList/View/BooksListViewController.swift @@ -0,0 +1,34 @@ +// +// BooksListViewController.swift +// iOSBooks +// +// Created by Guilherme Antunes Ferreira on 28/08/19. +// Copyright © 2019 Guilherme Antunes. All rights reserved. +// + +import UIKit + +class BooksListViewController: UIViewController { + + // MARK: - Outlets + @IBOutlet weak var booksCollectionView: UICollectionView? + + // MARK: - Properties + var viewModel: BooksListViewModelProtocol? + lazy var button = UIBarButtonItem(barButtonSystemItem: .bookmarks, target: self, action: #selector(fillWithSavedBooks)) + + // MARK: - View Life Cycle + override func viewDidLoad() { + super.viewDidLoad() + setupCollectionView() + setupFavoritesFilterButton() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + setupNavigationBar() + viewModel?.loadSavedBooks() + viewModel?.showFavorites = false + viewModel?.loadBooks() + } +} diff --git a/iOSBooks/iOSBooks/BooksList/View/Subviews/BooksCell.swift b/iOSBooks/iOSBooks/BooksList/View/Subviews/BooksCell.swift new file mode 100644 index 0000000..6238cd9 --- /dev/null +++ b/iOSBooks/iOSBooks/BooksList/View/Subviews/BooksCell.swift @@ -0,0 +1,43 @@ +// +// BooksCell.swift +// iOSBooks +// +// Created by Guilherme Antunes Ferreira on 30/08/19. +// Copyright © 2019 Guilherme Antunes. All rights reserved. +// + +import Kingfisher +import UIKit + +class BooksCell: UICollectionViewCell { + + // MARK: - Outlets + @IBOutlet weak var bookImageView: UIImageView? + + // MARK: - Properties + var selectedBook: Book? + + func setup(withBook book: Book?, andImage image: UIImage? = nil) { + selectedBook = book + if let image = image { + bookImageView?.image = image + } else { + if let book = book, let bookURLString = book.imageLinks?.smallThumbnail, let url = URL(string: bookURLString) { + bookImageView?.kf.setImage(with: url) + } + } + + } + + override func prepareForReuse() { + super.prepareForReuse() + bookImageView?.image = nil + bookImageView?.kf.cancelDownloadTask() + } + + deinit { + bookImageView?.image = nil + bookImageView?.kf.cancelDownloadTask() + } + +} diff --git a/iOSBooks/iOSBooks/BooksList/ViewModel/BooksListViewModel.swift b/iOSBooks/iOSBooks/BooksList/ViewModel/BooksListViewModel.swift new file mode 100644 index 0000000..19fbdef --- /dev/null +++ b/iOSBooks/iOSBooks/BooksList/ViewModel/BooksListViewModel.swift @@ -0,0 +1,84 @@ +// +// BooksListViewModel.swift +// iOSBooks +// +// Created by Guilherme Antunes Ferreira on 28/08/19. +// Copyright © 2019 Guilherme Antunes. All rights reserved. +// + +import UIKit + +protocol BooksListViewModelProtocol { + func loadBooks() + func loadSavedBooks() + func selectBook(atIndexPath indexPath: IndexPath) + func numberOfSections() -> Int + func numberOfItemsInSection(_ section: Int) -> Int + func cellForItem(inCollectionView collectionView: UICollectionView, atIndexPath indexPath: IndexPath) -> UICollectionViewCell + var showFavorites: Bool { get set } +} + +class BooksListViewModel: BooksListViewModelProtocol { + + var items: [Item] = [] + var savedItems: (books: [Item], images: [UIImage]) = ([],[]) + var service: BooksClientProtocol? + var startingIndex = 0 + weak var view: BooksListViewControllerPresentable? + var selectedBook: Item? + var selectedBookIsFavorite = false + var coordinator: AppCoordinatorProtocol? + var showFavorites: Bool = false + + func numberOfSections() -> Int { + return 1 + } + + func numberOfItemsInSection(_ section: Int = 0) -> Int { + return showFavorites ? savedItems.books.count : items.count + } + + func cellForItem(inCollectionView collectionView: UICollectionView, atIndexPath indexPath: IndexPath) -> UICollectionViewCell { + let cell: BooksCell = collectionView.dequeueReusableCell(for: indexPath) + let book: Book? + var image: UIImage? + if showFavorites { + book = savedItems.books[indexPath.item].book + image = savedItems.images[indexPath.item] + } else { + book = items[indexPath.item].book + } + cell.setup(withBook: book, andImage: image) + if indexPath.item == (items.count - 1) && !showFavorites { + loadBooks() + } + return cell + } + + func loadSavedBooks() { + savedItems = service?.fetchSavedBooks() ?? ([],[]) + } + + func loadBooks() { + service?.fetchBooksList(startingIndex: startingIndex).done { [weak self] (list) in + guard let self = self, let items = list.items else { return } + self.items.append(contentsOf: items) + self.startingIndex = items.count + DispatchQueue.main.async { + self.view?.reloadView() + } + }.catch { (error) in + DispatchQueue.main.async { + self.view?.presentError(message: error.localizedDescription) + } + } + + } + + func selectBook(atIndexPath indexPath: IndexPath) { + selectedBook = nil + selectedBook = showFavorites ? savedItems.books[indexPath.item] : items[indexPath.item] + selectedBookIsFavorite = savedItems.books.contains(where: { $0.id == selectedBook?.id }) + coordinator?.presentNextStep() + } +} diff --git a/iOSBooks/iOSBooks/Coordinators/AppCoordinator/AppCoordinator.swift b/iOSBooks/iOSBooks/Coordinators/AppCoordinator/AppCoordinator.swift new file mode 100644 index 0000000..eb21a1e --- /dev/null +++ b/iOSBooks/iOSBooks/Coordinators/AppCoordinator/AppCoordinator.swift @@ -0,0 +1,63 @@ +// +// AppCoordinator.swift +// iOSBooks +// +// Created by Guilherme Antunes Ferreira on 30/08/19. +// Copyright © 2019 Guilherme Antunes. All rights reserved. +// + +import UIKit + +protocol AppCoordinatorProtocol: class { + func presentNextStep() + func presentPreviousStep() +} + +enum RoutingState { + case list + case detail +} + +class AppCoordinator: AppCoordinatorProtocol { + + lazy var injector = AppCoordinatorDependencyInjector() + var window: UIWindow + var state: RoutingState = .list + + init(window: UIWindow) { + self.window = window + } + + func start() { + setupNavigationController() + window.rootViewController = injector.navigationController + window.makeKeyAndVisible() + } + + func setupNavigationController() { + injector.navigationController.viewControllers.append(injector.booksListViewController) + injector.booksListViewModel.coordinator = self + } + + func presentNextStep() { + switch state { + case .list: + injector.injectNewBook() + injector.bookDetailViewModel.coordinator = self + state = .detail + injector.navigationController.pushViewController(injector.bookDetailViewController, animated: true) + case.detail: + print("Nenhuma tela para frente neste fluxo") + } + } + + func presentPreviousStep() { + switch state { + case .list: + print("Nenhuma tela para trás neste fluxo") + case.detail: + state = .list + injector.navigationController.popViewController(animated: true) + } + } +} diff --git a/iOSBooks/iOSBooks/Coordinators/AppCoordinator/AppCoordinatorDependencyInjector.swift b/iOSBooks/iOSBooks/Coordinators/AppCoordinator/AppCoordinatorDependencyInjector.swift new file mode 100644 index 0000000..48f2e24 --- /dev/null +++ b/iOSBooks/iOSBooks/Coordinators/AppCoordinator/AppCoordinatorDependencyInjector.swift @@ -0,0 +1,51 @@ +// +// AppCoordinatorDependencyInjector.swift +// iOSBooks +// +// Created by Guilherme Antunes Ferreira on 30/08/19. +// Copyright © 2019 Guilherme Antunes. All rights reserved. +// + +import UIKit + +class AppCoordinatorDependencyInjector { + lazy var navigationController: UINavigationController = { + let navigation = UINavigationController() + navigation.navigationBar.prefersLargeTitles = true + navigation.navigationBar.barStyle = .black + navigation.navigationBar.tintColor = .white + return navigation + }() + + lazy var booksListViewController: BooksListViewController = { + let controller: BooksListViewController = BooksListViewController.instantiate() + booksListViewModel.view = controller + controller.viewModel = booksListViewModel + return controller + }() + + lazy var booksListViewModel: BooksListViewModel = { + let viewModel = BooksListViewModel() + viewModel.service = BooksClient() + return viewModel + }() + + lazy var bookDetailViewController: BookDetailViewController = { + let controller: BookDetailViewController = BookDetailViewController.instantiate() + bookDetailViewModel.view = controller + controller.viewModel = bookDetailViewModel + return controller + }() + + lazy var bookDetailViewModel: BookDetailViewModel = { + let viewModel = BookDetailViewModel() + viewModel.selectedBook = booksListViewModel.selectedBook + viewModel.service = CoreDataClient() + return viewModel + }() + + func injectNewBook() { + bookDetailViewModel.selectedBook = booksListViewModel.selectedBook + bookDetailViewModel.savedBook = booksListViewModel.selectedBookIsFavorite + } +} diff --git a/iOSBooks/iOSBooks/Data/Clients/Book.xcdatamodeld/Book.xcdatamodel/contents b/iOSBooks/iOSBooks/Data/Clients/Book.xcdatamodeld/Book.xcdatamodel/contents new file mode 100644 index 0000000..c71747d --- /dev/null +++ b/iOSBooks/iOSBooks/Data/Clients/Book.xcdatamodeld/Book.xcdatamodel/contents @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/iOSBooks/iOSBooks/Data/Clients/BooksClient.swift b/iOSBooks/iOSBooks/Data/Clients/BooksClient.swift new file mode 100644 index 0000000..2e69b8c --- /dev/null +++ b/iOSBooks/iOSBooks/Data/Clients/BooksClient.swift @@ -0,0 +1,35 @@ +// +// BooksClient.swift +// iOSBooks +// +// Created by Guilherme Antunes Ferreira on 28/08/19. +// Copyright © 2019 Guilherme Antunes. All rights reserved. +// + +import Foundation +import PromiseKit + +protocol BooksClientProtocol: class { + func fetchBooksList(startingIndex index: Int) -> Promise + func fetchSavedBooks() -> (books: [Item], images: [UIImage]) +} + +class BooksClient: BooksClientProtocol { + + let apiClient: APIClient + let coreDataClient: CoreDataClient + + init(apiClient: APIClient = APIClient(), coreData: CoreDataClient = CoreDataClient()) { + self.apiClient = apiClient + self.coreDataClient = coreData + } + + func fetchBooksList(startingIndex index: Int) -> Promise { + return apiClient.request(model: BooksList.self, BooksAPI.list(startingIndex: index).request) + } + + func fetchSavedBooks() -> (books: [Item], images: [UIImage]) { + return coreDataClient.fetchAllSavedBooks() + } + +} diff --git a/iOSBooks/iOSBooks/Data/Clients/CoreDataClient.swift b/iOSBooks/iOSBooks/Data/Clients/CoreDataClient.swift new file mode 100644 index 0000000..5942a61 --- /dev/null +++ b/iOSBooks/iOSBooks/Data/Clients/CoreDataClient.swift @@ -0,0 +1,90 @@ +// +// CoreDataClient.swift +// iOSBooks +// +// Created by Guilherme Antunes Ferreira on 30/08/19. +// Copyright © 2019 Guilherme Antunes. All rights reserved. +// + +import CoreData +import UIKit + +protocol CoreDataClientProtocol: class { + func saveBook(_ item: Item?, withThumbnail thumbnail: UIImage?) + func fetchAllSavedBooks() -> (books: [Item], images: [UIImage]) + func deleteBook(_ book: Item?) +} + +class CoreDataClient: CoreDataClientProtocol { + + func saveBook(_ item: Item?, withThumbnail thumbnail: UIImage? = nil) { + guard let item = item, let context = context, let entity = NSEntityDescription.entity(forEntityName: "CoreDataBook", in: context), let imageData = thumbnail?.pngData() else { return } + + let coreDataBook = NSManagedObject(entity: entity, insertInto: context) + coreDataBook.setValue(item.book?.title, forKey: "title") + coreDataBook.setValue(item.id, forKey: "id") + coreDataBook.setValue(item.book?.description, forKey: "bookDescription") + coreDataBook.setValue(item.salesInfo?.buyLink, forKey: "buyLink") + coreDataBook.setValue(true, forKey: "isFavorite") + coreDataBook.setValue(item.book?.authors?.first, forKey: "author") + coreDataBook.setValue(imageData, forKey: "image") + + do { + try context.save() + } catch { + print("error on saving") + } + } + + func fetchAllSavedBooks() -> (books: [Item], images: [UIImage]) { + var books: [Item] = [] + var images: [UIImage] = [] + guard let context = context else { return ([], []) } + let fetchRequest = NSFetchRequest(entityName: "CoreDataBook") + do { + guard let result = try context.fetch(fetchRequest) as? [NSManagedObject] else { return ([], []) } + for data in result { + let item = Item() + item.book = Book() + item.book?.imageLinks = ImageLinks() + item.salesInfo = SalesInfo() + item.book?.authors = [] + item.book?.authors?.append(data.value(forKey: "author") as? String ?? "") + item.id = data.value(forKey: "id") as? String + item.book?.description = data.value(forKey: "bookDescription") as? String + item.salesInfo?.buyLink = data.value(forKey: "buyLink") as? String + item.book?.title = data.value(forKey: "title") as? String + books.append(item) + + if let bookImageData = data.value(forKey: "image") as? Data, let bookImage = UIImage(data: bookImageData) { + images.append(bookImage) + } else { + images.append(UIImage()) + } + } + return (books, images) + } catch { + print("Failed to load data from CoreData") + return ([], []) + } + } + + func deleteBook(_ book: Item?) { + guard let book = book, let context = context else { return } + let fetchRequest = NSFetchRequest(entityName: "CoreDataBook") + fetchRequest.predicate = NSPredicate(format: "id = %@", book.id ?? "") + guard let bookToDelete = try? context.fetch(fetchRequest).first as? NSManagedObject else { return } + context.delete(bookToDelete) + do { + try context.save() + + } catch { + print("Failed to fetch book from CoreData and delete") + } + } + + private var context: NSManagedObjectContext? { + guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return nil } + return appDelegate.persistentContainer.viewContext + } +} diff --git a/iOSBooks/iOSBooks/Data/Endpoints/BooksAPI.swift b/iOSBooks/iOSBooks/Data/Endpoints/BooksAPI.swift new file mode 100644 index 0000000..b88a4a7 --- /dev/null +++ b/iOSBooks/iOSBooks/Data/Endpoints/BooksAPI.swift @@ -0,0 +1,74 @@ +// +// BooksAPI.swift +// iOSBooks +// +// Created by Guilherme Antunes Ferreira on 28/08/19. +// Copyright © 2019 Guilherme Antunes. All rights reserved. +// + +import Foundation + +enum BooksAPI: Endpoint { + + case list(startingIndex: Int) + + var path: String { + switch self { + case .list: + return "https://www.googleapis.com/books/v1/volumes" + } + } + + var startingIndex: Int { + switch self { + case .list(let index): + return index + } + } + + var headers: [String: String]? { + switch self { + case .list: + return nil + } + } + + var body: Data? { + switch self { + case .list: + return nil + } + } + + var httpMethod: String { + switch self { + case .list: + return "GET" + } + } + + var queryItems: [URLQueryItem]? { + switch self { + case .list: + let subjectItem = URLQueryItem(name: "q", value: "ios") + let maxResultsItem = URLQueryItem(name: "maxResults", value: "20") + let startIndexItem = URLQueryItem(name: "startIndex", value: String(startingIndex)) + return [subjectItem, startIndexItem, maxResultsItem] + } + } + + var request: URLRequest { + switch self { + case .list: + guard var components = URLComponents(string: path) else { fatalError() } + components.queryItems = queryItems + guard let url = components.url else { fatalError() } + var request = URLRequest(url: url) + request.allHTTPHeaderFields = headers + request.httpMethod = httpMethod + request.httpBody = body + return request + } + } +} + diff --git a/iOSBooks/iOSBooks/Data/Endpoints/Endpoint.swift b/iOSBooks/iOSBooks/Data/Endpoints/Endpoint.swift new file mode 100644 index 0000000..61de764 --- /dev/null +++ b/iOSBooks/iOSBooks/Data/Endpoints/Endpoint.swift @@ -0,0 +1,18 @@ +// +// Endpoint.swift +// iOSBooks +// +// Created by Guilherme Antunes Ferreira on 28/08/19. +// Copyright © 2019 Guilherme Antunes. All rights reserved. +// + +import Foundation + +protocol Endpoint { + var path: String { get } + var headers: [String: String]? { get } + var body: Data? { get } + var httpMethod: String { get } + var request: URLRequest { get } + var queryItems: [URLQueryItem]? { get } +} diff --git a/iOSBooks/iOSBooks/Data/Models/Book.swift b/iOSBooks/iOSBooks/Data/Models/Book.swift new file mode 100644 index 0000000..d909cfd --- /dev/null +++ b/iOSBooks/iOSBooks/Data/Models/Book.swift @@ -0,0 +1,32 @@ +// +// Book.swift +// iOSBooks +// +// Created by Guilherme Antunes Ferreira on 28/08/19. +// Copyright © 2019 Guilherme Antunes. All rights reserved. +// + +import Foundation + +class Book: Codable { + var authors: [String]? + var title: String? + var subtitle: String? + var description: String? + var imageLinks: ImageLinks? + + init() { + authors = nil + title = nil + subtitle = nil + description = nil + imageLinks = nil + } + + enum CodingKeys: String, CodingKey { + case authors, title, subtitle, description, imageLinks + } +} + + + diff --git a/iOSBooks/iOSBooks/Data/Models/BooksList.swift b/iOSBooks/iOSBooks/Data/Models/BooksList.swift new file mode 100644 index 0000000..d0fc590 --- /dev/null +++ b/iOSBooks/iOSBooks/Data/Models/BooksList.swift @@ -0,0 +1,18 @@ +// +// BooksList.swift +// iOSBooks +// +// Created by Guilherme Antunes Ferreira on 28/08/19. +// Copyright © 2019 Guilherme Antunes. All rights reserved. +// + +import Foundation + +class BooksList: Codable { + let items: [Item]? + let totalItems: Int? + + enum CodingKeys: String, CodingKey { + case items, totalItems + } +} diff --git a/iOSBooks/iOSBooks/Data/Models/ImageLinks.swift b/iOSBooks/iOSBooks/Data/Models/ImageLinks.swift new file mode 100644 index 0000000..1a4b273 --- /dev/null +++ b/iOSBooks/iOSBooks/Data/Models/ImageLinks.swift @@ -0,0 +1,24 @@ +// +// ImageLinks.swift +// iOSBooks +// +// Created by Guilherme Antunes Ferreira on 28/08/19. +// Copyright © 2019 Guilherme Antunes. All rights reserved. +// + +import Foundation + +class ImageLinks: Codable { + var smallThumbnail: String? + var thumbnail: String? + + init() { + smallThumbnail = nil + thumbnail = nil + } + + enum CodingKeys: String, CodingKey { + case smallThumbnail, thumbnail + } + +} diff --git a/iOSBooks/iOSBooks/Data/Models/Item.swift b/iOSBooks/iOSBooks/Data/Models/Item.swift new file mode 100644 index 0000000..b68e222 --- /dev/null +++ b/iOSBooks/iOSBooks/Data/Models/Item.swift @@ -0,0 +1,27 @@ +// +// Item.swift +// iOSBooks +// +// Created by Guilherme Antunes Ferreira on 28/08/19. +// Copyright © 2019 Guilherme Antunes. All rights reserved. +// + +import Foundation + +class Item: Codable { + var id: String? + var book: Book? + var salesInfo: SalesInfo? + + init() { + id = nil + book = nil + salesInfo = nil + } + + enum CodingKeys: String, CodingKey { + case book = "volumeInfo" + case id + case salesInfo = "saleInfo" + } +} diff --git a/iOSBooks/iOSBooks/Data/Models/SalesInfo.swift b/iOSBooks/iOSBooks/Data/Models/SalesInfo.swift new file mode 100644 index 0000000..1852163 --- /dev/null +++ b/iOSBooks/iOSBooks/Data/Models/SalesInfo.swift @@ -0,0 +1,19 @@ +// +// SalesInfo.swift +// iOSBooks +// +// Created by Guilherme Antunes Ferreira on 28/08/19. +// Copyright © 2019 Guilherme Antunes. All rights reserved. +// + +import Foundation + +class SalesInfo: Codable { + var country: String? + var buyLink: String? + + init() { + country = nil + buyLink = nil + } +} diff --git a/iOSBooks/iOSBooks/Data/Network/APIClient.swift b/iOSBooks/iOSBooks/Data/Network/APIClient.swift new file mode 100644 index 0000000..e5d2682 --- /dev/null +++ b/iOSBooks/iOSBooks/Data/Network/APIClient.swift @@ -0,0 +1,61 @@ +// +// APIClient.swift +// iOSBooks +// +// Created by Guilherme Antunes Ferreira on 28/08/19. +// Copyright © 2019 Guilherme Antunes. All rights reserved. +// + +import Foundation +import PromiseKit + +enum CustomError: Error { + case connectionError(String) + case mappingError(String) + case APIError(String) + case generalError(String) + case deallocatedClass(String) +} + +class APIClient { + + let connectionChecker: Reachability + let decoder: JSONDecoder + let session: URLSession + + init(connectionChecker: Reachability = Reachability(), decoder: JSONDecoder = JSONDecoder(), session: URLSession = URLSession.shared) { + self.connectionChecker = connectionChecker + self.decoder = decoder + self.session = session + } + + func request(model: T.Type, _ request: URLRequest) -> Promise { + return Promise { [weak self] seal in + guard let _self = self else { + print("Class deinitialized!") + return + } + + guard connectionChecker.isConnected else { + seal.reject(CustomError.connectionError("Por favor, verifique sua conexão com a internet!")) + return + } + + session.dataTask(with: request, completionHandler: { (data, response, error) in + guard let data = data else { + seal.reject(CustomError.APIError(error?.localizedDescription ?? "Ocorreu um erro inesperado, por favor, tente novamente!")) + return + } + + guard let model = try? _self.decoder.decode(T.self, from: data) else { + seal.reject(CustomError.mappingError("Houve um erro ao fazer o parse do modelo \(T.self)!")) + return + } + + seal.fulfill(model) + }).resume() + } + } + +} + diff --git a/iOSBooks/iOSBooks/Data/Network/Reachability.swift b/iOSBooks/iOSBooks/Data/Network/Reachability.swift new file mode 100644 index 0000000..7657b05 --- /dev/null +++ b/iOSBooks/iOSBooks/Data/Network/Reachability.swift @@ -0,0 +1,36 @@ +// +// Reachability.swift +// iOSBooks +// +// Created by Guilherme Antunes Ferreira on 28/08/19. +// Copyright © 2019 Guilherme Antunes. All rights reserved. +// + +import Foundation +import SystemConfiguration + +class Reachability { + var isConnected: Bool { + var zeroAddress = sockaddr_in() + zeroAddress.sin_len = UInt8(MemoryLayout.size) + zeroAddress.sin_family = sa_family_t(AF_INET) + + guard let defaultRouteReachability = withUnsafePointer(to: &zeroAddress, { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + SCNetworkReachabilityCreateWithAddress(nil, $0) + } + }) else { + return false + } + + var flags: SCNetworkReachabilityFlags = [] + if !SCNetworkReachabilityGetFlags(defaultRouteReachability, &flags) { + return false + } + + let isReachable = flags.contains(.reachable) + let needsConnection = flags.contains(.connectionRequired) + + return (isReachable && !needsConnection) + } +} diff --git a/iOSBooks/iOSBooks/Identifiable.swift b/iOSBooks/iOSBooks/Identifiable.swift new file mode 100644 index 0000000..a3ff390 --- /dev/null +++ b/iOSBooks/iOSBooks/Identifiable.swift @@ -0,0 +1,71 @@ +// +// Identifiable.swift +// iOSBooks +// +// Created by Guilherme Antunes Ferreira on 30/08/19. +// Copyright © 2019 Guilherme Antunes. All rights reserved. +// + +import UIKit + +protocol Identifiable: class {} + +extension Identifiable where Self: UIViewController { + static var storyboardIdentifier: String { + return String(describing: self) + } + static var xibIdentifier: String { + return String(describing: self) + } +} + +extension Identifiable where Self: UITableViewCell { + static var reuseIdentifier: String { + return String(describing: self) + } +} + +extension Identifiable where Self: UICollectionViewCell { + static var reuseIdentifier: String { + return String(describing: self) + } +} + +extension Identifiable where Self: UITableViewHeaderFooterView { + static var reuseIdentifier: String { + return String(describing: self) + } +} + + +extension UICollectionView { + func dequeueReusableCell(for indexPath: IndexPath) -> T { + guard let cell = dequeueReusableCell(withReuseIdentifier: T.reuseIdentifier, for: indexPath) as? T else { + // TODO: set crashlytics to warn here + fatalError("Could not dequeue cell with identifier: \(T.reuseIdentifier)") + } + return cell + } +} + +extension UITableView { + func dequeueReusableCell(for indexPath: IndexPath) -> T { + guard let cell = dequeueReusableCell(withIdentifier: T.reuseIdentifier, for: indexPath) as? T else { + fatalError("Could not dequeue cell with identifier: \(T.reuseIdentifier)") + } + return cell + } + + func dequeueReusableHeaderFooterView() -> T { + guard let headerFooterView = dequeueReusableHeaderFooterView(withIdentifier: T.reuseIdentifier) as? T else { + fatalError("Could not dequeue cell with identifier: \(T.reuseIdentifier)") + } + return headerFooterView + } +} + +extension UICollectionViewCell: Identifiable {} +extension UITableViewCell: Identifiable {} +extension UIViewController: Identifiable {} +extension UITableViewHeaderFooterView: Identifiable {} + diff --git a/iOSBooks/iOSBooks/Info.plist b/iOSBooks/iOSBooks/Info.plist index 16be3b6..1c45a97 100644 --- a/iOSBooks/iOSBooks/Info.plist +++ b/iOSBooks/iOSBooks/Info.plist @@ -2,6 +2,11 @@ + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable @@ -23,7 +28,7 @@ UILaunchStoryboardName LaunchScreen UIMainStoryboardFile - Main + BooksList UIRequiredDeviceCapabilities armv7 @@ -31,8 +36,6 @@ UISupportedInterfaceOrientations UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight UISupportedInterfaceOrientations~ipad diff --git a/iOSBooks/iOSBooks/UIViewController+QuickInstance.swift b/iOSBooks/iOSBooks/UIViewController+QuickInstance.swift new file mode 100644 index 0000000..dd6e636 --- /dev/null +++ b/iOSBooks/iOSBooks/UIViewController+QuickInstance.swift @@ -0,0 +1,47 @@ +// +// UIViewController+QuickInstance.swift +// iOSBooks +// +// Created by Guilherme Antunes Ferreira on 30/08/19. +// Copyright © 2019 Guilherme Antunes. All rights reserved. +// +import UIKit + +extension UIViewController { + + static func instantiate() -> T { + guard let controller = UIStoryboard(name: T.storyboardIdentifier.replacingOccurrences(of: "Controller", with: "").replacingOccurrences(of: "View", with: ""), bundle: T.bundle).instantiateViewController(withIdentifier: T.storyboardIdentifier) as? T else { + fatalError("failed to create storyboard")} + return controller + } + + static func instantiateFromXIB() -> T { + return T(nibName: T.xibIdentifier.replacingOccurrences(of: "Controller", with: "").replacingOccurrences(of: "View", with: ""), bundle: .main) + } + + static var bundle: Bundle { + return Bundle(for: self) + } +} + +extension UIViewController { + + func setBackButton(_ backFunction: Selector) { + navigationItem.hidesBackButton = true + let newBackButton = UIBarButtonItem(image: UIImage(named: "back_arrow"), style: .plain, target: self, action: backFunction) + newBackButton.title = "Voltar" + navigationItem.leftBarButtonItem = newBackButton + } + +} + +extension UIViewController { + + func alert(title: String = "", message: String, completion: (() -> Void)? = nil, okActionHandler: ((UIAlertAction) -> Void)? = nil) { + let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) + let OKAction = UIAlertAction(title: "OK", style: .default, handler: okActionHandler) + alertController.addAction(OKAction) + present(alertController, animated: true, completion: completion) + } + +} diff --git a/iOSBooks/iOSBooks/ViewController.swift b/iOSBooks/iOSBooks/ViewController.swift deleted file mode 100644 index da791b1..0000000 --- a/iOSBooks/iOSBooks/ViewController.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// ViewController.swift -// iOSBooks -// -// Created by Guilherme Antunes on 26/08/19. -// Copyright © 2019 Guilherme Antunes. All rights reserved. -// - -import UIKit - -class ViewController: UIViewController { - - override func viewDidLoad() { - super.viewDidLoad() - // Do any additional setup after loading the view. - } - - -} - diff --git a/iOSBooks/iOSBooksTests/BookDetailViewModelTests.swift b/iOSBooks/iOSBooksTests/BookDetailViewModelTests.swift new file mode 100644 index 0000000..cb61bc8 --- /dev/null +++ b/iOSBooks/iOSBooksTests/BookDetailViewModelTests.swift @@ -0,0 +1,126 @@ +// +// BookDetailViewModelTests.swift +// iOSBooksTests +// +// Created by Guilherme Antunes on 26/08/19. +// Copyright © 2019 Guilherme Antunes. All rights reserved. +// + +import Quick +import Nimble +@testable import iOSBooks + +class BookDetailViewModelTests: QuickSpec { + + class MockedCoordinator: AppCoordinatorProtocol { + var presentNextStepCalled = false + var presentPreviousStepCalled = false + + func presentNextStep() { + presentNextStepCalled = true + } + + func presentPreviousStep() { + presentPreviousStepCalled = true + } + + + } + + class MockedService: CoreDataClientProtocol { + var saveBookCalled = false + var fetchAllSavedBooksCalled = false + var deleteBookCalled = false + + func saveBook(_ item: Item?, withThumbnail thumbnail: UIImage?) { + saveBookCalled = true + } + + func fetchAllSavedBooks() -> (books: [Item], images: [UIImage]) { + fetchAllSavedBooksCalled = true + return ([],[]) + } + + func deleteBook(_ book: Item?) { + deleteBookCalled = true + } + + + } + + private func createFilledMockedItem() -> Item { + let mockedItem = Item() + let mockedBook = Book() + let mockedSalesInfo = SalesInfo() + let mockedImageLinks = ImageLinks() + mockedBook.title = "Book title" + mockedBook.description = "Book description" + mockedBook.authors = ["Book author"] + mockedImageLinks.thumbnail = "http://www.google.com/bookthumbnail" + mockedBook.imageLinks = mockedImageLinks + mockedSalesInfo.buyLink = "http://www.google.com" + mockedItem.salesInfo = mockedSalesInfo + mockedItem.book = mockedBook + return mockedItem + } + + override func spec() { + + var subject: BookDetailViewModel! + let mockedCoordinator = MockedCoordinator() + var mockedService = MockedService() + + beforeEach { + subject = BookDetailViewModel() + subject.coordinator = mockedCoordinator + subject.selectedBook = nil + mockedService = MockedService() + subject.service = mockedService + } + + it("should test successfully item retriving") { + subject.selectedBook = self.createFilledMockedItem() + expect(subject.getBookAuthors()).to(equal("Autor: Book author")) + expect(subject.getBookTitle()).to(equal("Título: Book title")) + expect(subject.getBookBuyLink()).to(equal("http://www.google.com")) + expect(subject.getScreenTitle()).to(equal("Book title")) + expect(subject.getBookBuyLinkURL()).to(equal(URL(string: "http://www.google.com")!)) + expect(subject.getBookImageLink()).to(equal(URL(string: "http://www.google.com/bookthumbnail")!)) + expect(subject.getBookDescription()).to(equal("Book description")) + } + + it("should test a nil item") { + expect(subject.getBookAuthors()).to(equal("Autores não disponíveis")) + expect(subject.getBookTitle()).to(equal("Título indisponível")) + expect(subject.getBookBuyLink()).to(equal("Link de compra não disponível")) + expect(subject.getScreenTitle()).to(beNil()) + expect(subject.getBookBuyLinkURL()).to(beNil()) + expect(subject.getBookImageLink()).to(beNil()) + expect(subject.getBookDescription()).to(equal("Sem descrição disponível")) + } + + it("should test back button function call") { + subject.presentPreviousStep() + expect(mockedCoordinator.presentPreviousStepCalled).to(beTrue()) + expect(mockedCoordinator.presentNextStepCalled).to(beFalse()) + } + + it("should test book saving") { + subject.savedBook = false + subject.saveBookIfNeeded() + expect(subject.savedBook).to(beTrue()) + expect(mockedService.saveBookCalled).to(beTrue()) + expect(mockedService.deleteBookCalled).to(beFalse()) + } + + it("should test book deletion") { + subject.savedBook = true + subject.saveBookIfNeeded() + expect(subject.savedBook).to(beFalse()) + expect(mockedService.saveBookCalled).to(beFalse()) + expect(mockedService.deleteBookCalled).to(beTrue()) + } + + } + +} diff --git a/iOSBooks/iOSBooksTests/iOSBooksTests.swift b/iOSBooks/iOSBooksTests/iOSBooksTests.swift deleted file mode 100644 index 9c97b0e..0000000 --- a/iOSBooks/iOSBooksTests/iOSBooksTests.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// iOSBooksTests.swift -// iOSBooksTests -// -// Created by Guilherme Antunes on 26/08/19. -// Copyright © 2019 Guilherme Antunes. All rights reserved. -// - -import XCTest -@testable import iOSBooks - -class iOSBooksTests: XCTestCase { - - override func setUp() { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDown() { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - } - - func testPerformanceExample() { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } - } - -}