diff --git a/SimpleFlickrBrowser/Extensions/String.swift b/SimpleFlickrBrowser/Extensions/String.swift new file mode 100644 index 0000000..67f3e6f --- /dev/null +++ b/SimpleFlickrBrowser/Extensions/String.swift @@ -0,0 +1,16 @@ +// +// Created by Maxim Berezhnoy on 26/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +import Foundation.NSDate + +extension String { + func toInt() -> Int? { + Int(self) + } + + func toDate(formatter: DateFormatter) -> Date? { + formatter.date(from: self) + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser.xcodeproj/project.pbxproj b/SimpleFlickrBrowser/SimpleFlickrBrowser.xcodeproj/project.pbxproj index ce1fcd9..7ed3b8e 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser.xcodeproj/project.pbxproj +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser.xcodeproj/project.pbxproj @@ -7,53 +7,67 @@ objects = { /* Begin PBXBuildFile section */ - 62DE9072B9CEDE9C92CBF7AF /* BrowserPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE98FFE252C3D837867816 /* BrowserPresenter.swift */; }; - 62DE90A60057F36A04993DE3 /* BrowserCellFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE92C6C8EA6BC8A24E8EBC /* BrowserCellFactory.swift */; }; - 62DE90B3B714B21C13925831 /* BrowserCellConfigurator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9B272F1AC1988F71AF6E /* BrowserCellConfigurator.swift */; }; + 62DE90247F9DE029191EAEB0 /* MockFullImagePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9EA282C378A8A45AC310 /* MockFullImagePresenter.swift */; }; 62DE90E73B9BFCA4A54B76C2 /* MockPhotoDataRetriever.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE96B5EBB14213DC9E7804 /* MockPhotoDataRetriever.swift */; }; + 62DE90F215A6D5285CDB3D6F /* BackgroundURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9B27E27EA0257A2A7F3D /* BackgroundURLSession.swift */; }; 62DE90F40E919CCFF876D1A6 /* MockFlickrPhotoService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9225FFEBB4968F60A987 /* MockFlickrPhotoService.swift */; }; 62DE911E1EB4D78BCA9085CB /* FlickrCollectionFetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9A2EBADCA5EE9059C3DD /* FlickrCollectionFetcherTests.swift */; }; - 62DE91E5B84C14466618EAC1 /* ConfigurableBrowserCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE91396D5EDB77544792D3 /* ConfigurableBrowserCell.swift */; }; + 62DE91F2D0080AF13460E47C /* FeedInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9DB2850E842E22C4CC84 /* FeedInteractor.swift */; }; 62DE91F96D06BDB6AE8D40A1 /* XCTestCase+QueueAwait.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE924CEDF37B8D5BC86D48 /* XCTestCase+QueueAwait.swift */; }; - 62DE927526937FB6BEDCE8BC /* MockBrowserCellPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9B3E9AAA8D0661DEEFFE /* MockBrowserCellPresenter.swift */; }; + 62DE924525362E9212DE0671 /* FullImageFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9EB1763D78889BAD9753 /* FullImageFactory.swift */; }; + 62DE925F04793F3F2CCF355A /* NetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE964E7B317C90B5B5DE7B /* NetworkSession.swift */; }; 62DE92A1A0D21DCC9FC97F7E /* NetworkPhotoDataRetriever.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9868FFC49BF89E9F9192 /* NetworkPhotoDataRetriever.swift */; }; + 62DE92CC1AB4CFA092F2DB10 /* FullImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE95E529D91B15DE6E0C58 /* FullImageViewController.swift */; }; 62DE92DECC87D7DACFB69CC9 /* PhotoDataRetrieving.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE99F1F322B9F470256B1E /* PhotoDataRetrieving.swift */; }; 62DE93261A41C5DDC9BACDB0 /* MockPhotoDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE91C1A5EBF975C64A37B3 /* MockPhotoDataCache.swift */; }; - 62DE9387D309D2DD3F2AC102 /* BrowserPresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE915A672C1C41DCCF9C8D /* BrowserPresenterTests.swift */; }; - 62DE93E9065F0528B8577C0E /* BrowserCellInteractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE93A95BC1DB3D74EC66D8 /* BrowserCellInteractorTests.swift */; }; - 62DE9427597853C3B0A5E661 /* BrowserCellModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE969E8F3F30D004171974 /* BrowserCellModels.swift */; }; + 62DE9387D309D2DD3F2AC102 /* FeedPresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE915A672C1C41DCCF9C8D /* FeedPresenterTests.swift */; }; + 62DE940CB3E25C0A9AEFE252 /* FeedCellFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9927EE51CE772D3DD171 /* FeedCellFactory.swift */; }; 62DE944EF838C8C313469CCB /* MockPhotoDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE92EE74C81312D3DB90B0 /* MockPhotoDataProvider.swift */; }; - 62DE9452E6FC1D01DD8976DA /* BrowserFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9CC652A43A976EDE4FF6 /* BrowserFactory.swift */; }; - 62DE946CFE4B0C1C5B9E1324 /* MockBrowserDisplaying.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9810EDB0220E74893A89 /* MockBrowserDisplaying.swift */; }; + 62DE946CFE4B0C1C5B9E1324 /* MockFeedDisplaying.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9810EDB0220E74893A89 /* MockFeedDisplaying.swift */; }; + 62DE947BBB3CC8AC189E0F4F /* FlickrCollectionDataFetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE94AB83D36878355DAA8C /* FlickrCollectionDataFetching.swift */; }; + 62DE94F27746C528BFA5C787 /* FeedRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9BF2A2C0391D9FFA480B /* FeedRouter.swift */; }; + 62DE9521164585DF38F0C460 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE99C6333390A5CB4F91B0 /* String.swift */; }; + 62DE959A03CFDDCC0107EB65 /* FlickrCollectionDataFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE92B5279132DA6C6FA657 /* FlickrCollectionDataFetcher.swift */; }; 62DE959CA829F11A51767E30 /* FlickrCollectionFetcherFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9B8BC72A4AB8A5A34B65 /* FlickrCollectionFetcherFactory.swift */; }; 62DE95BAA18D1A52C35EF040 /* FlickrPhotos.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE96CE6B44327361D73D24 /* FlickrPhotos.swift */; }; + 62DE96046383936ED2BDE433 /* FeedModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9D31EAF1CFCCDE85B8DE /* FeedModels.swift */; }; 62DE966944A50351E4087F92 /* FlickrPhotosService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE954BE0BFF3D2EE91DE12 /* FlickrPhotosService.swift */; }; + 62DE96C60A976276F0E3D60E /* FeedPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9673FC376E2BC90087D6 /* FeedPresenter.swift */; }; 62DE971066062BAA9D8C9AE4 /* FlickrPhotoURLResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE986497C5FB223DA10256 /* FlickrPhotoURLResolver.swift */; }; 62DE97140AE23BE5CD384EF0 /* MockHttpClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9E19BC1900DC8648FD5E /* MockHttpClient.swift */; }; 62DE971FF8AB9DC74BA9B87C /* FlickrApiValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE987D3B885CA1A25FB693 /* FlickrApiValues.swift */; }; + 62DE97615F6230CBFF9F92A4 /* FeedFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE96216E4D360CAB68AF75 /* FeedFactory.swift */; }; 62DE97B6EC739349BC9A6D2F /* FlickrPhotoServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE97953D19EE0C13151316 /* FlickrPhotoServiceTests.swift */; }; 62DE97CDCFCD2718C1BBC1E8 /* PhotoCollectionFetcherFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9A0F5F5E76B6AFE7159B /* PhotoCollectionFetcherFactory.swift */; }; - 62DE983EF92FD18305C80488 /* BrowserCellInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE997B53C61FE26593A99A /* BrowserCellInteractor.swift */; }; - 62DE9866170F4BCE2B93E2E0 /* MockBrowserPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE944604E007DD383ABDE1 /* MockBrowserPresenter.swift */; }; - 62DE98D6E300CD17DD7D23A3 /* BrowserCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9648DCA09C364860E8BB /* BrowserCellView.swift */; }; + 62DE9866170F4BCE2B93E2E0 /* MockFeedPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE944604E007DD383ABDE1 /* MockFeedPresenter.swift */; }; 62DE9921C1443189F640771E /* PhotoDataProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9C85502219A9858DCF22 /* PhotoDataProviderTests.swift */; }; 62DE9927B234F05FAB311B8D /* RootDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE94A9AA78F83D54078368 /* RootDependencies.swift */; }; 62DE9934B5A84CB2A3736360 /* MockPhotoCollectionFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9001FE62BE27C3D938B3 /* MockPhotoCollectionFetcher.swift */; }; + 62DE9A474D891C506B86BF8A /* ViewsLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE97EF27BCB2A06EB35FDF /* ViewsLabel.swift */; }; 62DE9A8C69E9E0A200181E5C /* FlickrApiURLBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9D790ECF920DF2C9802B /* FlickrApiURLBuilder.swift */; }; 62DE9A9250BD0AC541234841 /* PhotoDataProviderFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE99EC2B14DB9F0C554529 /* PhotoDataProviderFactory.swift */; }; - 62DE9B36C2A72575C7802A24 /* BrowserInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9E2159757D6020248C32 /* BrowserInteractor.swift */; }; - 62DE9B6C8EDB44A25EEE77A0 /* BrowserViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE900EADCC52560B246392 /* BrowserViewController.swift */; }; + 62DE9ADB8EF31EA3CD79A2B6 /* FlickrCollectionDataFetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE99120A20109EF4E3A62B /* FlickrCollectionDataFetcherTests.swift */; }; + 62DE9B3C2CAC899A108EFA70 /* DateLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE963F12E663C923326C82 /* DateLabel.swift */; }; + 62DE9BC9A5B77235063C5BCE /* FullImageModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9F51F8C74F47616FAA4A /* FullImageModels.swift */; }; 62DE9C2CEA5B44E24BB2D6F5 /* PhotoCollectionFetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE90B754DC827D73A31B9E /* PhotoCollectionFetching.swift */; }; - 62DE9C8EC1EBD9B055EF5388 /* BrowserViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9817F61216E36044EA42 /* BrowserViewDataSource.swift */; }; - 62DE9CBE1FB6013A700EB635 /* BrowserCellPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9591CAA01E3F3C91A977 /* BrowserCellPresenter.swift */; }; + 62DE9C82855405B5A0BA6B57 /* FeedCellConfigurator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE970B2B651FC6FDE03FC1 /* FeedCellConfigurator.swift */; }; + 62DE9C8CF78EA38A820A8862 /* FullImagePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE94B072B2C7ABF725A761 /* FullImagePresenter.swift */; }; + 62DE9CACADCC0438B0091A5C /* FeedCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE98ED3E76A320E0BF36D3 /* FeedCellView.swift */; }; + 62DE9CD65849E14B67A5AF42 /* PhotoParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9031E5EEBEA8EBA4D644 /* PhotoParameters.swift */; }; 62DE9CE6366FD6ECE740C55C /* FlickrCollectionFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9C90249CCE2FAF835CF5 /* FlickrCollectionFetcher.swift */; }; + 62DE9D29B400373915DB581A /* FeedViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE925A929D79068D7BE7CB /* FeedViewDataSource.swift */; }; + 62DE9D3B49411AE049F480B6 /* FullImageInteractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE97F7CB8E1F85FFC0EEEC /* FullImageInteractorTests.swift */; }; + 62DE9D8224882F3BAB4798A5 /* Routing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9506DAADD74BB93C3549 /* Routing.swift */; }; 62DE9E63C0B530594A5B150B /* Style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9C8D791DA5418ED32FCF /* Style.swift */; }; 62DE9EBE33EDC3D97D929FF2 /* Photo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE970A3FD09743A03DAF33 /* Photo.swift */; }; 62DE9EC2FE48A5A204531EEA /* HttpClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE93C78893DAA0E46DBD6A /* HttpClient.swift */; }; - 62DE9F2E46CDDC60B4A5DEAE /* BrowserModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9E652D625ECC302D66F9 /* BrowserModels.swift */; }; + 62DE9EF2BE05068AD50ACC44 /* FullImageInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9A6628BCBFFE088E4420 /* FullImageInteractor.swift */; }; + 62DE9F3410EB4A39040BBC55 /* TagsLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE940D06ADFF0BDAC17ADC /* TagsLabel.swift */; }; 62DE9F5B37C5FE4BED753FB2 /* PhotoDataNSCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9805D6D4DBDDBAD078E2 /* PhotoDataNSCache.swift */; }; 62DE9F69E85302202E6884B3 /* PhotoDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9FF6B55060E5204AC907 /* PhotoDataProvider.swift */; }; - 62DE9FA335C6F1E04D690818 /* BrowserInteractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9EA9D6ACC68E32F8A681 /* BrowserInteractorTests.swift */; }; + 62DE9F6D54B5EA2DA23BED9C /* MockFlickrCollectionDataFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE97BAB29B5E48A31A7687 /* MockFlickrCollectionDataFetcher.swift */; }; + 62DE9FA335C6F1E04D690818 /* FeedInteractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9EA9D6ACC68E32F8A681 /* FeedInteractorTests.swift */; }; + 62DE9FAF86BD3911FC84BF72 /* FeedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9C09B259CEA839DAE2B9 /* FeedViewController.swift */; }; 62DE9FDF59C5BAB0F8D43494 /* PhotoDataCaching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62DE9B49CABD228CCFBCE936 /* PhotoDataCaching.swift */; }; 944AC01E23CE0E9C009FD611 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 944AC01D23CE0E9C009FD611 /* AppDelegate.swift */; }; 944AC02723CE0E9E009FD611 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 944AC02623CE0E9E009FD611 /* Assets.xcassets */; }; @@ -73,52 +87,66 @@ /* Begin PBXFileReference section */ 62DE9001FE62BE27C3D938B3 /* MockPhotoCollectionFetcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockPhotoCollectionFetcher.swift; sourceTree = ""; }; - 62DE900EADCC52560B246392 /* BrowserViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BrowserViewController.swift; sourceTree = ""; }; + 62DE9031E5EEBEA8EBA4D644 /* PhotoParameters.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoParameters.swift; sourceTree = ""; }; 62DE90B754DC827D73A31B9E /* PhotoCollectionFetching.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoCollectionFetching.swift; sourceTree = ""; }; - 62DE91396D5EDB77544792D3 /* ConfigurableBrowserCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigurableBrowserCell.swift; sourceTree = ""; }; - 62DE915A672C1C41DCCF9C8D /* BrowserPresenterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BrowserPresenterTests.swift; sourceTree = ""; }; + 62DE915A672C1C41DCCF9C8D /* FeedPresenterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedPresenterTests.swift; sourceTree = ""; }; 62DE91C1A5EBF975C64A37B3 /* MockPhotoDataCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockPhotoDataCache.swift; sourceTree = ""; }; 62DE9225FFEBB4968F60A987 /* MockFlickrPhotoService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockFlickrPhotoService.swift; sourceTree = ""; }; 62DE924CEDF37B8D5BC86D48 /* XCTestCase+QueueAwait.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "XCTestCase+QueueAwait.swift"; sourceTree = ""; }; - 62DE92C6C8EA6BC8A24E8EBC /* BrowserCellFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BrowserCellFactory.swift; sourceTree = ""; }; + 62DE925A929D79068D7BE7CB /* FeedViewDataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedViewDataSource.swift; sourceTree = ""; }; + 62DE92B5279132DA6C6FA657 /* FlickrCollectionDataFetcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FlickrCollectionDataFetcher.swift; sourceTree = ""; }; 62DE92EE74C81312D3DB90B0 /* MockPhotoDataProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockPhotoDataProvider.swift; sourceTree = ""; }; - 62DE93A95BC1DB3D74EC66D8 /* BrowserCellInteractorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BrowserCellInteractorTests.swift; sourceTree = ""; }; 62DE93C78893DAA0E46DBD6A /* HttpClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HttpClient.swift; sourceTree = ""; }; - 62DE944604E007DD383ABDE1 /* MockBrowserPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockBrowserPresenter.swift; sourceTree = ""; }; + 62DE940D06ADFF0BDAC17ADC /* TagsLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TagsLabel.swift; sourceTree = ""; }; + 62DE944604E007DD383ABDE1 /* MockFeedPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockFeedPresenter.swift; sourceTree = ""; }; 62DE94A9AA78F83D54078368 /* RootDependencies.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RootDependencies.swift; sourceTree = ""; }; + 62DE94AB83D36878355DAA8C /* FlickrCollectionDataFetching.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FlickrCollectionDataFetching.swift; sourceTree = ""; }; + 62DE94B072B2C7ABF725A761 /* FullImagePresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FullImagePresenter.swift; sourceTree = ""; }; + 62DE9506DAADD74BB93C3549 /* Routing.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Routing.swift; sourceTree = ""; }; 62DE954BE0BFF3D2EE91DE12 /* FlickrPhotosService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FlickrPhotosService.swift; sourceTree = ""; }; - 62DE9591CAA01E3F3C91A977 /* BrowserCellPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BrowserCellPresenter.swift; sourceTree = ""; }; - 62DE9648DCA09C364860E8BB /* BrowserCellView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BrowserCellView.swift; sourceTree = ""; }; - 62DE969E8F3F30D004171974 /* BrowserCellModels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BrowserCellModels.swift; sourceTree = ""; }; + 62DE95E529D91B15DE6E0C58 /* FullImageViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FullImageViewController.swift; sourceTree = ""; }; + 62DE96216E4D360CAB68AF75 /* FeedFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedFactory.swift; sourceTree = ""; }; + 62DE963F12E663C923326C82 /* DateLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DateLabel.swift; sourceTree = ""; }; + 62DE964E7B317C90B5B5DE7B /* NetworkSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkSession.swift; sourceTree = ""; }; + 62DE9673FC376E2BC90087D6 /* FeedPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedPresenter.swift; sourceTree = ""; }; 62DE96B5EBB14213DC9E7804 /* MockPhotoDataRetriever.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockPhotoDataRetriever.swift; sourceTree = ""; }; 62DE96CE6B44327361D73D24 /* FlickrPhotos.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FlickrPhotos.swift; sourceTree = ""; }; 62DE970A3FD09743A03DAF33 /* Photo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Photo.swift; sourceTree = ""; }; + 62DE970B2B651FC6FDE03FC1 /* FeedCellConfigurator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedCellConfigurator.swift; sourceTree = ""; }; 62DE97953D19EE0C13151316 /* FlickrPhotoServiceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FlickrPhotoServiceTests.swift; sourceTree = ""; }; + 62DE97BAB29B5E48A31A7687 /* MockFlickrCollectionDataFetcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockFlickrCollectionDataFetcher.swift; sourceTree = ""; }; + 62DE97EF27BCB2A06EB35FDF /* ViewsLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewsLabel.swift; sourceTree = ""; }; + 62DE97F7CB8E1F85FFC0EEEC /* FullImageInteractorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FullImageInteractorTests.swift; sourceTree = ""; }; 62DE9805D6D4DBDDBAD078E2 /* PhotoDataNSCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoDataNSCache.swift; sourceTree = ""; }; - 62DE9810EDB0220E74893A89 /* MockBrowserDisplaying.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockBrowserDisplaying.swift; sourceTree = ""; }; - 62DE9817F61216E36044EA42 /* BrowserViewDataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BrowserViewDataSource.swift; sourceTree = ""; }; + 62DE9810EDB0220E74893A89 /* MockFeedDisplaying.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockFeedDisplaying.swift; sourceTree = ""; }; 62DE986497C5FB223DA10256 /* FlickrPhotoURLResolver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FlickrPhotoURLResolver.swift; sourceTree = ""; }; 62DE9868FFC49BF89E9F9192 /* NetworkPhotoDataRetriever.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkPhotoDataRetriever.swift; sourceTree = ""; }; 62DE987D3B885CA1A25FB693 /* FlickrApiValues.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FlickrApiValues.swift; sourceTree = ""; }; - 62DE98FFE252C3D837867816 /* BrowserPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BrowserPresenter.swift; sourceTree = ""; }; - 62DE997B53C61FE26593A99A /* BrowserCellInteractor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BrowserCellInteractor.swift; sourceTree = ""; }; + 62DE98ED3E76A320E0BF36D3 /* FeedCellView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedCellView.swift; sourceTree = ""; }; + 62DE99120A20109EF4E3A62B /* FlickrCollectionDataFetcherTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FlickrCollectionDataFetcherTests.swift; sourceTree = ""; }; + 62DE9927EE51CE772D3DD171 /* FeedCellFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedCellFactory.swift; sourceTree = ""; }; + 62DE99C6333390A5CB4F91B0 /* String.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; 62DE99EC2B14DB9F0C554529 /* PhotoDataProviderFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoDataProviderFactory.swift; sourceTree = ""; }; 62DE99F1F322B9F470256B1E /* PhotoDataRetrieving.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoDataRetrieving.swift; sourceTree = ""; }; 62DE9A0F5F5E76B6AFE7159B /* PhotoCollectionFetcherFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoCollectionFetcherFactory.swift; sourceTree = ""; }; 62DE9A2EBADCA5EE9059C3DD /* FlickrCollectionFetcherTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FlickrCollectionFetcherTests.swift; sourceTree = ""; }; - 62DE9B272F1AC1988F71AF6E /* BrowserCellConfigurator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BrowserCellConfigurator.swift; sourceTree = ""; }; - 62DE9B3E9AAA8D0661DEEFFE /* MockBrowserCellPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockBrowserCellPresenter.swift; sourceTree = ""; }; + 62DE9A6628BCBFFE088E4420 /* FullImageInteractor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FullImageInteractor.swift; sourceTree = ""; }; + 62DE9B27E27EA0257A2A7F3D /* BackgroundURLSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundURLSession.swift; sourceTree = ""; }; 62DE9B49CABD228CCFBCE936 /* PhotoDataCaching.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoDataCaching.swift; sourceTree = ""; }; 62DE9B8BC72A4AB8A5A34B65 /* FlickrCollectionFetcherFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FlickrCollectionFetcherFactory.swift; sourceTree = ""; }; + 62DE9BF2A2C0391D9FFA480B /* FeedRouter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedRouter.swift; sourceTree = ""; }; + 62DE9C09B259CEA839DAE2B9 /* FeedViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedViewController.swift; sourceTree = ""; }; 62DE9C85502219A9858DCF22 /* PhotoDataProviderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoDataProviderTests.swift; sourceTree = ""; }; 62DE9C8D791DA5418ED32FCF /* Style.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Style.swift; sourceTree = ""; }; 62DE9C90249CCE2FAF835CF5 /* FlickrCollectionFetcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FlickrCollectionFetcher.swift; sourceTree = ""; }; - 62DE9CC652A43A976EDE4FF6 /* BrowserFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BrowserFactory.swift; sourceTree = ""; }; + 62DE9D31EAF1CFCCDE85B8DE /* FeedModels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedModels.swift; sourceTree = ""; }; 62DE9D790ECF920DF2C9802B /* FlickrApiURLBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FlickrApiURLBuilder.swift; sourceTree = ""; }; + 62DE9DB2850E842E22C4CC84 /* FeedInteractor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedInteractor.swift; sourceTree = ""; }; 62DE9E19BC1900DC8648FD5E /* MockHttpClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockHttpClient.swift; sourceTree = ""; }; - 62DE9E2159757D6020248C32 /* BrowserInteractor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BrowserInteractor.swift; sourceTree = ""; }; - 62DE9E652D625ECC302D66F9 /* BrowserModels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BrowserModels.swift; sourceTree = ""; }; - 62DE9EA9D6ACC68E32F8A681 /* BrowserInteractorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BrowserInteractorTests.swift; sourceTree = ""; }; + 62DE9EA282C378A8A45AC310 /* MockFullImagePresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockFullImagePresenter.swift; sourceTree = ""; }; + 62DE9EA9D6ACC68E32F8A681 /* FeedInteractorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedInteractorTests.swift; sourceTree = ""; }; + 62DE9EB1763D78889BAD9753 /* FullImageFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FullImageFactory.swift; sourceTree = ""; }; + 62DE9F51F8C74F47616FAA4A /* FullImageModels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FullImageModels.swift; sourceTree = ""; }; 62DE9FF6B55060E5204AC907 /* PhotoDataProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoDataProvider.swift; sourceTree = ""; }; 944AC01A23CE0E9C009FD611 /* SimpleFlickrBrowser.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SimpleFlickrBrowser.app; sourceTree = BUILT_PRODUCTS_DIR; }; 944AC01D23CE0E9C009FD611 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -168,17 +196,35 @@ 62DE92353EFEFAB905C719AC /* Scenes */ = { isa = PBXGroup; children = ( - 62DE983292E5D66368CF497E /* Browser */, + 62DE983292E5D66368CF497E /* Feed */, + 62DE9A45435D656998BEDE7A /* FullImage */, ); path = Scenes; sourceTree = ""; }; + 62DE923C7B6825D8B8E3001A /* Feed */ = { + isa = PBXGroup; + children = ( + 62DE9C09B259CEA839DAE2B9 /* FeedViewController.swift */, + 62DE9673FC376E2BC90087D6 /* FeedPresenter.swift */, + 62DE9DB2850E842E22C4CC84 /* FeedInteractor.swift */, + 62DE9D31EAF1CFCCDE85B8DE /* FeedModels.swift */, + 62DE96216E4D360CAB68AF75 /* FeedFactory.swift */, + 62DE925A929D79068D7BE7CB /* FeedViewDataSource.swift */, + 62DE98914295E0F2A4403FBE /* Cell */, + 62DE9BF2A2C0391D9FFA480B /* FeedRouter.swift */, + 62DE964E422134BBFB88C94F /* FullImage */, + ); + path = Feed; + sourceTree = ""; + }; 62DE932EFD760F664BE020DD /* PhotoCollectionFetcher */ = { isa = PBXGroup; children = ( 62DE90B754DC827D73A31B9E /* PhotoCollectionFetching.swift */, 62DE9A0F5F5E76B6AFE7159B /* PhotoCollectionFetcherFactory.swift */, 62DE9F3D2015C40F75DEED96 /* Flickr */, + 62DE9031E5EEBEA8EBA4D644 /* PhotoParameters.swift */, ); path = PhotoCollectionFetcher; sourceTree = ""; @@ -199,6 +245,18 @@ path = PhotoDataCache; sourceTree = ""; }; + 62DE964E422134BBFB88C94F /* FullImage */ = { + isa = PBXGroup; + children = ( + 62DE95E529D91B15DE6E0C58 /* FullImageViewController.swift */, + 62DE9EB1763D78889BAD9753 /* FullImageFactory.swift */, + 62DE9A6628BCBFFE088E4420 /* FullImageInteractor.swift */, + 62DE94B072B2C7ABF725A761 /* FullImagePresenter.swift */, + 62DE9F51F8C74F47616FAA4A /* FullImageModels.swift */, + ); + path = FullImage; + sourceTree = ""; + }; 62DE964EA55B5AFD5FFAF651 /* Photos */ = { isa = PBXGroup; children = ( @@ -209,10 +267,20 @@ path = Photos; sourceTree = ""; }; + 62DE9688CB88553090DF24CC /* Extensions */ = { + isa = PBXGroup; + children = ( + 62DE99C6333390A5CB4F91B0 /* String.swift */, + ); + path = Extensions; + sourceTree = ""; + }; 62DE96CD430BE02619B01ABC /* Flickr */ = { isa = PBXGroup; children = ( 62DE9A2EBADCA5EE9059C3DD /* FlickrCollectionFetcherTests.swift */, + 62DE97BAB29B5E48A31A7687 /* MockFlickrCollectionDataFetcher.swift */, + 62DE99120A20109EF4E3A62B /* FlickrCollectionDataFetcherTests.swift */, ); path = Flickr; sourceTree = ""; @@ -237,16 +305,15 @@ path = PhotoDataProvider; sourceTree = ""; }; - 62DE983292E5D66368CF497E /* Browser */ = { + 62DE983292E5D66368CF497E /* Feed */ = { isa = PBXGroup; children = ( - 62DE9F9AB6B2EBDB591C446A /* Cell */, - 62DE9EA9D6ACC68E32F8A681 /* BrowserInteractorTests.swift */, - 62DE944604E007DD383ABDE1 /* MockBrowserPresenter.swift */, - 62DE915A672C1C41DCCF9C8D /* BrowserPresenterTests.swift */, - 62DE9810EDB0220E74893A89 /* MockBrowserDisplaying.swift */, + 62DE9EA9D6ACC68E32F8A681 /* FeedInteractorTests.swift */, + 62DE944604E007DD383ABDE1 /* MockFeedPresenter.swift */, + 62DE915A672C1C41DCCF9C8D /* FeedPresenterTests.swift */, + 62DE9810EDB0220E74893A89 /* MockFeedDisplaying.swift */, ); - path = Browser; + path = Feed; sourceTree = ""; }; 62DE986DB8FB2B4857D0B727 /* PhotoDataRetriever */ = { @@ -257,6 +324,17 @@ path = PhotoDataRetriever; sourceTree = ""; }; + 62DE98914295E0F2A4403FBE /* Cell */ = { + isa = PBXGroup; + children = ( + 62DE98ED3E76A320E0BF36D3 /* FeedCellView.swift */, + 62DE9EA0E878692CF74D79CB /* Metadata */, + 62DE970B2B651FC6FDE03FC1 /* FeedCellConfigurator.swift */, + 62DE9927EE51CE772D3DD171 /* FeedCellFactory.swift */, + ); + path = Cell; + sourceTree = ""; + }; 62DE98C735D3CC9E8C88071A /* FlickrApiServices */ = { isa = PBXGroup; children = ( @@ -284,13 +362,13 @@ path = Photos; sourceTree = ""; }; - 62DE99D691735A5674751646 /* Configurator */ = { + 62DE9A45435D656998BEDE7A /* FullImage */ = { isa = PBXGroup; children = ( - 62DE91396D5EDB77544792D3 /* ConfigurableBrowserCell.swift */, - 62DE9B272F1AC1988F71AF6E /* BrowserCellConfigurator.swift */, + 62DE97F7CB8E1F85FFC0EEEC /* FullImageInteractorTests.swift */, + 62DE9EA282C378A8A45AC310 /* MockFullImagePresenter.swift */, ); - path = Configurator; + path = FullImage; sourceTree = ""; }; 62DE9AABA2EDCDF4B92072F7 /* Extensions */ = { @@ -301,10 +379,20 @@ path = Extensions; sourceTree = ""; }; + 62DE9B38420B42751F63A44F /* Utils */ = { + isa = PBXGroup; + children = ( + 62DE9506DAADD74BB93C3549 /* Routing.swift */, + ); + path = Utils; + sourceTree = ""; + }; 62DE9C11F52BBF38DEB44438 /* Network */ = { isa = PBXGroup; children = ( 62DE93C78893DAA0E46DBD6A /* HttpClient.swift */, + 62DE9B27E27EA0257A2A7F3D /* BackgroundURLSession.swift */, + 62DE964E7B317C90B5B5DE7B /* NetworkSession.swift */, ); path = Network; sourceTree = ""; @@ -357,17 +445,14 @@ path = Workers; sourceTree = ""; }; - 62DE9ED1568E25802A697C02 /* Cell */ = { + 62DE9EA0E878692CF74D79CB /* Metadata */ = { isa = PBXGroup; children = ( - 62DE969E8F3F30D004171974 /* BrowserCellModels.swift */, - 62DE9648DCA09C364860E8BB /* BrowserCellView.swift */, - 62DE997B53C61FE26593A99A /* BrowserCellInteractor.swift */, - 62DE9591CAA01E3F3C91A977 /* BrowserCellPresenter.swift */, - 62DE99D691735A5674751646 /* Configurator */, - 62DE92C6C8EA6BC8A24E8EBC /* BrowserCellFactory.swift */, + 62DE963F12E663C923326C82 /* DateLabel.swift */, + 62DE97EF27BCB2A06EB35FDF /* ViewsLabel.swift */, + 62DE940D06ADFF0BDAC17ADC /* TagsLabel.swift */, ); - path = Cell; + path = Metadata; sourceTree = ""; }; 62DE9F3D2015C40F75DEED96 /* Flickr */ = { @@ -375,25 +460,19 @@ children = ( 62DE9C90249CCE2FAF835CF5 /* FlickrCollectionFetcher.swift */, 62DE9B8BC72A4AB8A5A34B65 /* FlickrCollectionFetcherFactory.swift */, + 62DE92B5279132DA6C6FA657 /* FlickrCollectionDataFetcher.swift */, + 62DE94AB83D36878355DAA8C /* FlickrCollectionDataFetching.swift */, ); path = Flickr; sourceTree = ""; }; - 62DE9F9AB6B2EBDB591C446A /* Cell */ = { - isa = PBXGroup; - children = ( - 62DE9B3E9AAA8D0661DEEFFE /* MockBrowserCellPresenter.swift */, - 62DE93A95BC1DB3D74EC66D8 /* BrowserCellInteractorTests.swift */, - ); - path = Cell; - sourceTree = ""; - }; 944AC01123CE0E9C009FD611 = { isa = PBXGroup; children = ( 944AC01C23CE0E9C009FD611 /* SimpleFlickrBrowser */, 944AC03323CE0E9E009FD611 /* SimpleFlickrBrowserTests */, 944AC01B23CE0E9C009FD611 /* Products */, + 62DE9688CB88553090DF24CC /* Extensions */, ); sourceTree = ""; }; @@ -416,6 +495,7 @@ 62DE9E4975BBE450619E428D /* Workers */, 62DE9C11F52BBF38DEB44438 /* Network */, 62DE9086A97F986E80E89C32 /* Models */, + 62DE9B38420B42751F63A44F /* Utils */, ); path = SimpleFlickrBrowser; sourceTree = ""; @@ -446,26 +526,12 @@ 944AC04123CFA5A1009FD611 /* Scenes */ = { isa = PBXGroup; children = ( - 944AC04523CFA643009FD611 /* Browser */, 62DE9C8D791DA5418ED32FCF /* Style.swift */, + 62DE923C7B6825D8B8E3001A /* Feed */, ); path = Scenes; sourceTree = ""; }; - 944AC04523CFA643009FD611 /* Browser */ = { - isa = PBXGroup; - children = ( - 62DE900EADCC52560B246392 /* BrowserViewController.swift */, - 62DE9E2159757D6020248C32 /* BrowserInteractor.swift */, - 62DE98FFE252C3D837867816 /* BrowserPresenter.swift */, - 62DE9E652D625ECC302D66F9 /* BrowserModels.swift */, - 62DE9817F61216E36044EA42 /* BrowserViewDataSource.swift */, - 62DE9CC652A43A976EDE4FF6 /* BrowserFactory.swift */, - 62DE9ED1568E25802A697C02 /* Cell */, - ); - path = Browser; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -511,7 +577,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 1130; - LastUpgradeCheck = 1130; + LastUpgradeCheck = 1220; ORGANIZATIONNAME = rencevio; TargetAttributes = { 944AC01923CE0E9C009FD611 = { @@ -568,13 +634,7 @@ files = ( 944AC04423CFA5CC009FD611 /* FlickrApiKey.swift in Sources */, 944AC01E23CE0E9C009FD611 /* AppDelegate.swift in Sources */, - 62DE9B6C8EDB44A25EEE77A0 /* BrowserViewController.swift in Sources */, - 62DE9B36C2A72575C7802A24 /* BrowserInteractor.swift in Sources */, - 62DE9072B9CEDE9C92CBF7AF /* BrowserPresenter.swift in Sources */, - 62DE9F2E46CDDC60B4A5DEAE /* BrowserModels.swift in Sources */, - 62DE9C8EC1EBD9B055EF5388 /* BrowserViewDataSource.swift in Sources */, 62DE9927B234F05FAB311B8D /* RootDependencies.swift in Sources */, - 62DE9452E6FC1D01DD8976DA /* BrowserFactory.swift in Sources */, 62DE9E63C0B530594A5B150B /* Style.swift in Sources */, 62DE9F69E85302202E6884B3 /* PhotoDataProvider.swift in Sources */, 62DE9FDF59C5BAB0F8D43494 /* PhotoDataCaching.swift in Sources */, @@ -582,13 +642,6 @@ 62DE92DECC87D7DACFB69CC9 /* PhotoDataRetrieving.swift in Sources */, 62DE92A1A0D21DCC9FC97F7E /* NetworkPhotoDataRetriever.swift in Sources */, 62DE9F5B37C5FE4BED753FB2 /* PhotoDataNSCache.swift in Sources */, - 62DE98D6E300CD17DD7D23A3 /* BrowserCellView.swift in Sources */, - 62DE9427597853C3B0A5E661 /* BrowserCellModels.swift in Sources */, - 62DE983EF92FD18305C80488 /* BrowserCellInteractor.swift in Sources */, - 62DE9CBE1FB6013A700EB635 /* BrowserCellPresenter.swift in Sources */, - 62DE91E5B84C14466618EAC1 /* ConfigurableBrowserCell.swift in Sources */, - 62DE90B3B714B21C13925831 /* BrowserCellConfigurator.swift in Sources */, - 62DE90A60057F36A04993DE3 /* BrowserCellFactory.swift in Sources */, 62DE9EC2FE48A5A204531EEA /* HttpClient.swift in Sources */, 62DE9C2CEA5B44E24BB2D6F5 /* PhotoCollectionFetching.swift in Sources */, 62DE97CDCFCD2718C1BBC1E8 /* PhotoCollectionFetcherFactory.swift in Sources */, @@ -600,6 +653,31 @@ 62DE966944A50351E4087F92 /* FlickrPhotosService.swift in Sources */, 62DE971FF8AB9DC74BA9B87C /* FlickrApiValues.swift in Sources */, 62DE95BAA18D1A52C35EF040 /* FlickrPhotos.swift in Sources */, + 62DE9FAF86BD3911FC84BF72 /* FeedViewController.swift in Sources */, + 62DE96C60A976276F0E3D60E /* FeedPresenter.swift in Sources */, + 62DE91F2D0080AF13460E47C /* FeedInteractor.swift in Sources */, + 62DE96046383936ED2BDE433 /* FeedModels.swift in Sources */, + 62DE9CD65849E14B67A5AF42 /* PhotoParameters.swift in Sources */, + 62DE97615F6230CBFF9F92A4 /* FeedFactory.swift in Sources */, + 62DE9D29B400373915DB581A /* FeedViewDataSource.swift in Sources */, + 62DE9CACADCC0438B0091A5C /* FeedCellView.swift in Sources */, + 62DE9B3C2CAC899A108EFA70 /* DateLabel.swift in Sources */, + 62DE9A474D891C506B86BF8A /* ViewsLabel.swift in Sources */, + 62DE9F3410EB4A39040BBC55 /* TagsLabel.swift in Sources */, + 62DE9521164585DF38F0C460 /* String.swift in Sources */, + 62DE959A03CFDDCC0107EB65 /* FlickrCollectionDataFetcher.swift in Sources */, + 62DE947BBB3CC8AC189E0F4F /* FlickrCollectionDataFetching.swift in Sources */, + 62DE9C82855405B5A0BA6B57 /* FeedCellConfigurator.swift in Sources */, + 62DE94F27746C528BFA5C787 /* FeedRouter.swift in Sources */, + 62DE9D8224882F3BAB4798A5 /* Routing.swift in Sources */, + 62DE92CC1AB4CFA092F2DB10 /* FullImageViewController.swift in Sources */, + 62DE924525362E9212DE0671 /* FullImageFactory.swift in Sources */, + 62DE940CB3E25C0A9AEFE252 /* FeedCellFactory.swift in Sources */, + 62DE9EF2BE05068AD50ACC44 /* FullImageInteractor.swift in Sources */, + 62DE9C8CF78EA38A820A8862 /* FullImagePresenter.swift in Sources */, + 62DE9BC9A5B77235063C5BCE /* FullImageModels.swift in Sources */, + 62DE90F215A6D5285CDB3D6F /* BackgroundURLSession.swift in Sources */, + 62DE925F04793F3F2CCF355A /* NetworkSession.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -610,19 +688,21 @@ 62DE93261A41C5DDC9BACDB0 /* MockPhotoDataCache.swift in Sources */, 62DE90E73B9BFCA4A54B76C2 /* MockPhotoDataRetriever.swift in Sources */, 62DE9921C1443189F640771E /* PhotoDataProviderTests.swift in Sources */, - 62DE927526937FB6BEDCE8BC /* MockBrowserCellPresenter.swift in Sources */, 62DE944EF838C8C313469CCB /* MockPhotoDataProvider.swift in Sources */, - 62DE93E9065F0528B8577C0E /* BrowserCellInteractorTests.swift in Sources */, 62DE91F96D06BDB6AE8D40A1 /* XCTestCase+QueueAwait.swift in Sources */, 62DE97140AE23BE5CD384EF0 /* MockHttpClient.swift in Sources */, 62DE911E1EB4D78BCA9085CB /* FlickrCollectionFetcherTests.swift in Sources */, 62DE90F40E919CCFF876D1A6 /* MockFlickrPhotoService.swift in Sources */, 62DE97B6EC739349BC9A6D2F /* FlickrPhotoServiceTests.swift in Sources */, - 62DE9FA335C6F1E04D690818 /* BrowserInteractorTests.swift in Sources */, + 62DE9FA335C6F1E04D690818 /* FeedInteractorTests.swift in Sources */, 62DE9934B5A84CB2A3736360 /* MockPhotoCollectionFetcher.swift in Sources */, - 62DE9866170F4BCE2B93E2E0 /* MockBrowserPresenter.swift in Sources */, - 62DE9387D309D2DD3F2AC102 /* BrowserPresenterTests.swift in Sources */, - 62DE946CFE4B0C1C5B9E1324 /* MockBrowserDisplaying.swift in Sources */, + 62DE9866170F4BCE2B93E2E0 /* MockFeedPresenter.swift in Sources */, + 62DE9387D309D2DD3F2AC102 /* FeedPresenterTests.swift in Sources */, + 62DE946CFE4B0C1C5B9E1324 /* MockFeedDisplaying.swift in Sources */, + 62DE9ADB8EF31EA3CD79A2B6 /* FlickrCollectionDataFetcherTests.swift in Sources */, + 62DE9F6D54B5EA2DA23BED9C /* MockFlickrCollectionDataFetcher.swift in Sources */, + 62DE9D3B49411AE049F480B6 /* FullImageInteractorTests.swift in Sources */, + 62DE90247F9DE029191EAEB0 /* MockFullImagePresenter.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -674,6 +754,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -705,6 +786,7 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; }; name = Debug; }; @@ -734,6 +816,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -758,6 +841,7 @@ SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; VALIDATE_PRODUCT = YES; }; name = Release; diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/AppDelegate.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/AppDelegate.swift index e601bff..5470eaf 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/AppDelegate.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/AppDelegate.swift @@ -10,8 +10,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? let dependencies = RootDependencies() - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - let viewController = dependencies.createBrowserViewController() + func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + let viewController = dependencies.createFeedViewController() let navigationController = UINavigationController(rootViewController: viewController) @@ -21,4 +21,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } + + func application(_: UIApplication, handleEventsForBackgroundURLSession _: String, completionHandler: @escaping () -> Void) { + BackgroundURLSession.shared.set(onBackgroundEventsHandledCallback: completionHandler) + } } diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Models/Photo.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Models/Photo.swift index 1409964..d23d4e6 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Models/Photo.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Models/Photo.swift @@ -3,11 +3,22 @@ // // Copyright (c) 2020 rencevio. All rights reserved. +import struct Foundation.Data +import struct Foundation.Date import struct Foundation.URL struct Photo { + struct Metadata { + let views: Int + let tags: [String] + let ownerName: String + let dateTaken: Date + } + typealias ID = String let id: ID - let imageURL: URL + let imageData: Data + let fullSizeImageURL: URL + let metadata: Metadata } diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Network/BackgroundURLSession.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Network/BackgroundURLSession.swift new file mode 100644 index 0000000..6875990 --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Network/BackgroundURLSession.swift @@ -0,0 +1,117 @@ +// +// Created by Maxim Berezhnoy on 29/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +import Foundation + +extension URLSession { + static let sharedBackground = BackgroundURLSession.shared +} + +final class BackgroundURLSession { + static let shared = BackgroundURLSession() + + private var onBackgroundEventsHandled: (() -> Void)? + + private let operatingQueue = DispatchQueue(label: "\(BackgroundURLSession.self)OperatingQueue") + + private let identifier = "\(BackgroundURLSession.self)Identifier" + + private var runningTasks = [URLSessionTask: Completion]() + + private lazy var session: URLSession = { + let configuration = URLSessionConfiguration.background(withIdentifier: identifier) + + return URLSession(configuration: configuration, delegate: sessionDelegate, delegateQueue: nil) + }() + + private lazy var sessionDelegate = { + BackgroundURLSessionDelegate(onTaskFinished: onTaskFinished(), onAllTasksFinished: onAllTasksFinished()) + }() + + private init() {} + + func set(onBackgroundEventsHandledCallback: @escaping () -> Void) { + DispatchQueue.main.async { [weak self] in + self?.onBackgroundEventsHandled = onBackgroundEventsHandledCallback + } + } + + private func onTaskFinished() -> (URLSessionTask, Data?, Error?) -> Void { + { [weak self] task, data, error in + self?.operatingQueue.async { [weak self] in + guard let self = self, let taskCallback = self.runningTasks[task] else { + return + } + + self.runningTasks.removeValue(forKey: task) + + taskCallback(data, error) + } + } + } + + private func onAllTasksFinished() -> () -> Void { + { + DispatchQueue.main.async { [weak self] in + self?.onBackgroundEventsHandled?() + self?.onBackgroundEventsHandled = nil + } + } + } +} + +// MARK: - NetworkSession + +extension BackgroundURLSession: NetworkSession { + func getData(from url: URL, _ completion: @escaping Completion) { + let task = session.downloadTask(with: url) + + operatingQueue.async { [weak self] in + self?.runningTasks[task] = completion + } + + task.resume() + } +} + +private final class BackgroundURLSessionDelegate: NSObject { + private let onTaskFinished: (URLSessionTask, Data?, Error?) -> Void + private let onAllTasksFinished: () -> Void + + init(onTaskFinished: @escaping (URLSessionTask, Data?, Error?) -> Void, onAllTasksFinished: @escaping () -> Void) { + self.onTaskFinished = onTaskFinished + self.onAllTasksFinished = onAllTasksFinished + } +} + +// MARK: - URLSessionDelegate + +extension BackgroundURLSessionDelegate: URLSessionDelegate { + func urlSessionDidFinishEvents(forBackgroundURLSession _: URLSession) { + onAllTasksFinished() + } +} + +// MARK: - URLSessionTaskDelegate + +extension BackgroundURLSessionDelegate: URLSessionTaskDelegate { + func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + onTaskFinished(task, nil, error) + } +} + +// MARK: - URLSessionDownloadDelegate + +extension BackgroundURLSessionDelegate: URLSessionDownloadDelegate { + func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + do { + let data = try Data(contentsOf: location) + + onTaskFinished(downloadTask, data, nil) + } catch { + onTaskFinished(downloadTask, nil, error) + } + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Network/HttpClient.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Network/HttpClient.swift index 86ea1d1..3b97038 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Network/HttpClient.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Network/HttpClient.swift @@ -7,7 +7,7 @@ import Foundation protocol HttpCommunicator { typealias Completion = (Result) -> Void - func get(url: URL, completion: @escaping Completion) + func get(url: URL, background: Bool, completion: @escaping Completion) } enum Http { @@ -18,9 +18,12 @@ enum Http { final class Client: HttpCommunicator { private let urlSession = URLSession.shared + private let backgroundUrlSession = URLSession.sharedBackground - func get(url: URL, completion: @escaping Completion) { - let task = urlSession.dataTask(with: url) { data, _, error in + func get(url: URL, background: Bool, completion: @escaping Completion) { + let session: NetworkSession = background ? backgroundUrlSession : urlSession + + session.getData(from: url) { data, error in guard let data = data else { if let error = error { completion(.failure(.httpError(error))) @@ -33,8 +36,6 @@ enum Http { completion(.success(data)) } - - task.resume() } } } diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Network/NetworkSession.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Network/NetworkSession.swift new file mode 100644 index 0000000..05c697c --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Network/NetworkSession.swift @@ -0,0 +1,20 @@ +// +// Created by Maxim Berezhnoy on 29/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +import Foundation + +protocol NetworkSession { + typealias Completion = (Data?, Error?) -> Void + + func getData(from url: URL, _ completion: @escaping Completion) +} + +extension URLSession: NetworkSession { + func getData(from url: URL, _ completion: @escaping Completion) { + dataTask(with: url) { data, _, error in + completion(data, error) + }.resume() + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Resources/Info.plist b/SimpleFlickrBrowser/SimpleFlickrBrowser/Resources/Info.plist index 5a63475..75404a2 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Resources/Info.plist +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Resources/Info.plist @@ -29,8 +29,6 @@ UISupportedInterfaceOrientations UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight UISupportedInterfaceOrientations~ipad diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/RootDependencies.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/RootDependencies.swift index 7dc9fd2..6cdaff2 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/RootDependencies.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/RootDependencies.swift @@ -12,13 +12,14 @@ final class RootDependencies { photoDataProvider = photoDataProviderFactory.createPhotoProvider() let photoCollectionFetcherFactory = FlickrCollectionFetcherFactory() - photoCollectionFetcher = photoCollectionFetcherFactory.createCollectionFetcher() + photoCollectionFetcher = photoCollectionFetcherFactory.createCollectionFetcher(photoDataProvider: photoDataProvider) } - func createBrowserViewController() -> BrowserViewController { - let browserFactory = BrowserFactory() + func createFeedViewController() -> FeedViewController { + let feedCellFactory = FeedCellFactory() + let feedFactory = FeedFactory(feedCellFactory: feedCellFactory) - return browserFactory.createViewController( + return feedFactory.createViewController( photoDataProvider: photoDataProvider, photoCollectionFetcher: photoCollectionFetcher ) diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/BrowserFactory.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/BrowserFactory.swift deleted file mode 100644 index c06e54e..0000000 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/BrowserFactory.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// Created by Maxim Berezhnoy on 16/01/2020. -// -// Copyright (c) 2020 rencevio. All rights reserved. - -final class BrowserFactory { - func createViewController(photoDataProvider: PhotoDataProviding, - photoCollectionFetcher: PhotoCollectionFetching) -> BrowserViewController { - let presenter = BrowserPresenter() - let interactor = BrowserInteractor(presenter: presenter, photoCollectionFetcher: photoCollectionFetcher) - - let dataSource = createDataSource(photoDataProvider: photoDataProvider) - - let viewController = BrowserViewController(interactor: interactor, dataSource: dataSource) - - presenter.view = viewController - - return viewController - } - - private func createDataSource(photoDataProvider: PhotoDataProviding) -> BrowserDataSourcing { - let cellFactory = BrowserCellFactory(photoDataProvider: photoDataProvider) - let cellConfigurator = cellFactory.createConfigurator() - - return BrowserViewDataSource(cellConfigurator: cellConfigurator) - } -} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/BrowserPresenter.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/BrowserPresenter.swift deleted file mode 100644 index ca9066a..0000000 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/BrowserPresenter.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// Created by Maxim Berezhnoy on 16/01/2020. -// -// Copyright (c) 2020 rencevio. All rights reserved. - -protocol BrowserPresenting: AnyObject { - func present(photos: BrowserModels.Photos.Response) -} - -final class BrowserPresenter: BrowserPresenting { - weak var view: BrowserDisplaying? - - func present(photos: BrowserModels.Photos.Response) { - let viewModel = BrowserModels.Photos.ViewModel(photos: photos.photos) - - if photos.startingPosition == 0 { - view?.displayNew(photos: viewModel) - } else { - view?.displayMore(photos: viewModel) - } - } -} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/BrowserViewController.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/BrowserViewController.swift deleted file mode 100644 index 4b70c5e..0000000 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/BrowserViewController.swift +++ /dev/null @@ -1,215 +0,0 @@ -// -// Created by Maxim Berezhnoy on 15/01/2020. -// -// Copyright (c) 2020 rencevio. All rights reserved. - -import UIKit - -protocol BrowserDisplaying: AnyObject { - func displayNew(photos: BrowserModels.Photos.ViewModel) - func displayMore(photos: BrowserModels.Photos.ViewModel) -} - -private struct LayoutConstants { - static let itemsPerRow = 3 - - static let padding: CGFloat = 10.0 - static let interitemSpacing: CGFloat = 10 - static let lineSpacing: CGFloat = 10 - - static let heightRatio: CGFloat = 1.0 -} - -final class BrowserViewController: UIViewController { - private let photosPerFetchRequest = LayoutConstants.itemsPerRow * 15 - - private let interactor: BrowserInteracting - private let dataSource: BrowserDataSourcing - - private lazy var searchController: UISearchController = { - let controller = UISearchController() - - controller.obscuresBackgroundDuringPresentation = false - controller.definesPresentationContext = true - - return controller - }() - - private lazy var collectionFlowLayout: UICollectionViewFlowLayout = { - let layout = UICollectionViewFlowLayout() - - layout.sectionInset = UIEdgeInsets( - top: LayoutConstants.padding, - left: LayoutConstants.padding, - bottom: LayoutConstants.padding, - right: LayoutConstants.padding - ) - - layout.minimumInteritemSpacing = LayoutConstants.interitemSpacing - layout.minimumLineSpacing = LayoutConstants.lineSpacing - - return layout - }() - - private lazy var collectionView: UICollectionView = { - let view = UICollectionView(frame: .zero, collectionViewLayout: collectionFlowLayout) - - view.alwaysBounceVertical = true - view.backgroundColor = Style.ScreenBackground.color - - return view - }() - - private lazy var refreshControl: UIRefreshControl = { - let control = UIRefreshControl(frame: .zero) - - return control - }() - - init(interactor: BrowserInteracting, dataSource: BrowserDataSourcing) { - self.interactor = interactor - self.dataSource = dataSource - - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - setupCollectionView() - setupSearchController() - setupRefreshControl() - - requestNewPhotos() - } - - // MARK: - View Setup - - private func setupCollectionView() { - view.addSubview(collectionView) - - collectionView.translatesAutoresizingMaskIntoConstraints = false - collectionView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true - collectionView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true - collectionView.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true - collectionView.heightAnchor.constraint(equalTo: view.heightAnchor).isActive = true - - collectionView.delegate = self - - dataSource.register(for: collectionView) - } - - private func setupSearchController() { - searchController.searchBar.delegate = self - searchController.searchBar.enablesReturnKeyAutomatically = false - - navigationItem.searchController = searchController - navigationItem.hidesSearchBarWhenScrolling = false - } - - private func setupRefreshControl() { - collectionView.refreshControl = refreshControl - - refreshControl.addTarget(self, action: #selector(handleRefreshControl), for: .valueChanged) - } - - // MARK: - Data requesting - - func requestMorePhotos() { - interactor.fetch( - photos: BrowserModels.Photos.Request( - startFromPosition: dataSource.photoCount, - fetchAtMost: photosPerFetchRequest, - searchCriteria: searchController.searchBar.text ?? "" - ) - ) - } - - func requestNewPhotos() { - interactor.fetch( - photos: BrowserModels.Photos.Request( - startFromPosition: 0, - fetchAtMost: photosPerFetchRequest, - searchCriteria: searchController.searchBar.text ?? "" - ) - ) - } -} - -// MARK: - BrowserDisplaying - -extension BrowserViewController: BrowserDisplaying { - func displayNew(photos: BrowserModels.Photos.ViewModel) { - dataSource.set(photos: photos.photos) - - refreshControl.endRefreshing() - - collectionView.reloadData() - collectionView.scrollToItem(at: IndexPath(row: 0, section: 0), at: .top, animated: false) - } - - func displayMore(photos: BrowserModels.Photos.ViewModel) { - let currentPhotoCount = dataSource.photoCount - - dataSource.add(photos: photos.photos) - - collectionView.insertItems(at: (0 ..< photos.photos.count).map { IndexPath(item: $0 + currentPhotoCount, section: 0) }) - } -} - -// MARK: - UICollectionViewDelegate - -extension BrowserViewController: UICollectionViewDelegate { - func collectionView(_ collectionView: UICollectionView, - willDisplay cell: UICollectionViewCell, - forItemAt indexPath: IndexPath) { - let itemToDisplay = indexPath.item - - let loadedPhotosCount = dataSource.photoCount - - if itemToDisplay == loadedPhotosCount - LayoutConstants.itemsPerRow * 4 { - requestMorePhotos() - } - } -} - -// MARK: - UICollectionViewDelegateFlowLayout - -extension BrowserViewController: UICollectionViewDelegateFlowLayout { - func collectionView(_ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - sizeForItemAt indexPath: IndexPath) -> CGSize { - let viewWidth = self.collectionView.bounds.width - LayoutConstants.padding * 2 - - let totalInteritemSpacing = CGFloat(LayoutConstants.itemsPerRow - 1) * LayoutConstants.interitemSpacing - - let itemWidth = ((viewWidth - totalInteritemSpacing) / CGFloat(LayoutConstants.itemsPerRow)).rounded(.down) - - return CGSize(width: itemWidth, height: itemWidth * LayoutConstants.heightRatio) - } -} - -// MARK: - UISearchBarDelegate - -extension BrowserViewController: UISearchBarDelegate { - public func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { - requestNewPhotos() - } - - public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { - searchBar.text = "" - requestNewPhotos() - } -} - -// MARK: - Refresh control - -extension BrowserViewController { - @objc func handleRefreshControl() { - requestNewPhotos() - } -} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/BrowserViewDataSource.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/BrowserViewDataSource.swift deleted file mode 100644 index 9462453..0000000 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/BrowserViewDataSource.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// Created by Maxim Berezhnoy on 16/01/2020. -// -// Copyright (c) 2020 rencevio. All rights reserved. - -import UIKit - -protocol BrowserDataSourcing: UICollectionViewDataSource { - var photoCount: Int { get } - - func register(for collectionView: UICollectionView) - func add(photos: [Photo]) - func set(photos: [Photo]) -} - -final class BrowserViewDataSource: NSObject { - private let cellConfigurator: BrowserCellConfiguring - - private var photos = [Photo]() - - init(cellConfigurator: BrowserCellConfiguring) { - self.cellConfigurator = cellConfigurator - - super.init() - } -} - -// MARK: - UICollectionViewDataSource - -extension BrowserViewDataSource: UICollectionViewDataSource { - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - photos.count - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell = dequeueBrowserCell(from: collectionView, at: indexPath) - - let photo = photos[indexPath.item] - - cellConfigurator.configure(cell: cell, with: photo) - - return cell - } -} - -// MARK: - BrowserDataSourcing - -extension BrowserViewDataSource: BrowserDataSourcing { - var photoCount: Int { - photos.count - } - - func register(for collectionView: UICollectionView) { - collectionView.dataSource = self - collectionView.register(BrowserViewCell.self, forCellWithReuseIdentifier: BrowserViewCell.identifier) - } - - func add(photos: [Photo]) { - self.photos.append(contentsOf: photos) - } - - func set(photos: [Photo]) { - self.photos = photos - } -} - -// MARK: - BrowserViewCell dequeuing - -extension BrowserViewDataSource { - func dequeueBrowserCell(from collectionView: UICollectionView, at indexPath: IndexPath) -> BrowserViewCell { - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: BrowserViewCell.identifier, for: indexPath) - - guard let browserCell = cell as? BrowserViewCell else { - fatalError("Unexpected cell type while dequeuing in \(BrowserViewDataSource.self): got \(cell)") - } - - return browserCell - } -} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/Cell/BrowserCellFactory.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/Cell/BrowserCellFactory.swift deleted file mode 100644 index 1e81600..0000000 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/Cell/BrowserCellFactory.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// Created by Maxim Berezhnoy on 17/01/2020. -// -// Copyright (c) 2020 rencevio. All rights reserved. - -protocol BrowserCellCreating { - func createConfigurator() -> BrowserCellConfiguring -} - -final class BrowserCellFactory: BrowserCellCreating { - private let photoDataProvider: PhotoDataProviding - - init(photoDataProvider: PhotoDataProviding) { - self.photoDataProvider = photoDataProvider - } - - func createConfigurator() -> BrowserCellConfiguring { - BrowserCellConfigurator(photoDataProvider: photoDataProvider) - } -} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/Cell/BrowserCellInteractor.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/Cell/BrowserCellInteractor.swift deleted file mode 100644 index d45210b..0000000 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/Cell/BrowserCellInteractor.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// Created by Maxim Berezhnoy on 16/01/2020. -// -// Copyright (c) 2020 rencevio. All rights reserved. - -import Foundation - -protocol BrowserCellInteracting { - func fetch(image: BrowserCellModels.PhotoImage.Request) -} - -final class BrowserCellInteractor: BrowserCellInteracting { - private let presenter: BrowserCellPresenting - private let photoDataProvider: PhotoDataProviding - - private var currentPhotoId: Photo.ID? - - init(presenter: BrowserCellPresenting, photoDataProvider: PhotoDataProviding) { - self.presenter = presenter - self.photoDataProvider = photoDataProvider - } - - func fetch(image: BrowserCellModels.PhotoImage.Request) { - presenter.presentLoading() - - currentPhotoId = image.photoID - - photoDataProvider.getPhotoData(from: image.url) { result in - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - - // Do not send a response if the ID has changed meanwhile. - // Another option would be to make requests to provider cancelable - if let currentPhotoId = self.currentPhotoId, currentPhotoId == image.photoID { - switch result { - case let .success(data): - self.presenter.present(image: BrowserCellModels.PhotoImage.Response(data: data)) - case .failure: - self.presenter.presentError() - } - } - } - } - } -} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/Cell/BrowserCellPresenter.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/Cell/BrowserCellPresenter.swift deleted file mode 100644 index 53e0508..0000000 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/Cell/BrowserCellPresenter.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// Created by Maxim Berezhnoy on 16/01/2020. -// -// Copyright (c) 2020 rencevio. All rights reserved. - -import UIKit - -protocol BrowserCellPresenting: AnyObject { - func present(image: BrowserCellModels.PhotoImage.Response) - func presentLoading() - func presentError() -} - -final class BrowserCellPresenter: BrowserCellPresenting { - weak var view: BrowserCellDisplaying? - - func present(image: BrowserCellModels.PhotoImage.Response) { - guard let view = view, - let image = UIImage(data: image.data) - else { return } - - view.display(image: BrowserCellModels.PhotoImage.ViewModel(image: image)) - } - - func presentLoading() { - view?.displayLoading() - } - - func presentError() { - view?.displayLoading() - } -} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/Cell/BrowserCellView.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/Cell/BrowserCellView.swift deleted file mode 100644 index 8087ab4..0000000 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/Cell/BrowserCellView.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// Created by Maxim Berezhnoy on 16/01/2020. -// -// Copyright (c) 2020 rencevio. All rights reserved. - -import UIKit - -protocol BrowserCellDisplaying: AnyObject { - func display(image: BrowserCellModels.PhotoImage.ViewModel) - func displayLoading() -} - -final class BrowserViewCell: UICollectionViewCell { - private enum ViewState { - case loading - case image(UIImage) - } - - static let identifier = "\(BrowserViewCell.self)" - - private var interactor: BrowserCellInteracting? - - lazy var imageView: UIImageView = { - let view = UIImageView() - - view.contentMode = .scaleAspectFit - - view.layer.masksToBounds = true - view.layer.cornerRadius = 7 - - return view - }() - - lazy var placeholderView: UIView = { - let view = UIView() - - view.backgroundColor = .white - - return view - }() - - override init(frame: CGRect) { - super.init(frame: frame) - - setupImageView() - setupPlaceholderView() - - set(state: .loading) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - View Setup - - private func setupImageView() { - addSubview(imageView) - - imageView.translatesAutoresizingMaskIntoConstraints = false - imageView.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true - imageView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true - imageView.widthAnchor.constraint(equalTo: widthAnchor).isActive = true - imageView.heightAnchor.constraint(equalTo: heightAnchor).isActive = true - } - - private func setupPlaceholderView() { - addSubview(placeholderView) - - placeholderView.translatesAutoresizingMaskIntoConstraints = false - placeholderView.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true - placeholderView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true - placeholderView.widthAnchor.constraint(equalTo: widthAnchor).isActive = true - placeholderView.heightAnchor.constraint(equalTo: heightAnchor).isActive = true - } - - private func set(state: ViewState) { - switch state { - case .loading: - placeholderView.isHidden = false - imageView.isHidden = true - - case let .image(image): - imageView.isHidden = false - placeholderView.isHidden = true - - imageView.image = image - } - } -} - -// MARK: - BrowserCellDisplaying - -extension BrowserViewCell: BrowserCellDisplaying { - func display(image: BrowserCellModels.PhotoImage.ViewModel) { - set(state: .image(image.image)) - } - - func displayLoading() { - set(state: .loading) - } -} - -// MARK: - ConfigurableBrowserCell - -extension BrowserViewCell: ConfigurableBrowserCell { - func configure(interactor: BrowserCellInteracting, photo: Photo) { - self.interactor = interactor - - interactor.fetch(image: BrowserCellModels.PhotoImage.Request(photoID: photo.id, url: photo.imageURL)) - } -} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/Cell/Configurator/BrowserCellConfigurator.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/Cell/Configurator/BrowserCellConfigurator.swift deleted file mode 100644 index 407c7d0..0000000 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/Cell/Configurator/BrowserCellConfigurator.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// Created by Maxim Berezhnoy on 17/01/2020. -// -// Copyright (c) 2020 rencevio. All rights reserved. - -protocol BrowserCellConfiguring { - func configure(cell: ConfigurableBrowserCell, with photo: Photo) -} - -final class BrowserCellConfigurator: BrowserCellConfiguring { - private let photoDataProvider: PhotoDataProviding - - init(photoDataProvider: PhotoDataProviding) { - self.photoDataProvider = photoDataProvider - } - - func configure(cell: ConfigurableBrowserCell, with photo: Photo) { - let presenter = BrowserCellPresenter() - let interactor = BrowserCellInteractor(presenter: presenter, photoDataProvider: photoDataProvider) - - presenter.view = cell - - cell.configure(interactor: interactor, photo: photo) - } -} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/Cell/Configurator/ConfigurableBrowserCell.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/Cell/Configurator/ConfigurableBrowserCell.swift deleted file mode 100644 index b2a3058..0000000 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/Cell/Configurator/ConfigurableBrowserCell.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// Created by Maxim Berezhnoy on 17/01/2020. -// -// Copyright (c) 2020 rencevio. All rights reserved. - -protocol ConfigurableBrowserCell: BrowserCellDisplaying { - func configure(interactor: BrowserCellInteracting, photo: Photo) -} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/Cell/FeedCellConfigurator.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/Cell/FeedCellConfigurator.swift new file mode 100644 index 0000000..9a5a19f --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/Cell/FeedCellConfigurator.swift @@ -0,0 +1,20 @@ +// +// Created by Maxim Berezhnoy on 27/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +protocol FeedCellConfiguring { + func configure(_ cell: FeedViewCell) +} + +final class FeedCellConfigurator: FeedCellConfiguring { + private let router: FeedRouting + + init(router: FeedRouting) { + self.router = router + } + + func configure(_ cell: FeedViewCell) { + cell.router = router + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/Cell/FeedCellFactory.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/Cell/FeedCellFactory.swift new file mode 100644 index 0000000..91dc053 --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/Cell/FeedCellFactory.swift @@ -0,0 +1,14 @@ +// +// Created by Maxim Berezhnoy on 29/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +protocol FeedCellCreating { + func createFeedCellConfigurator(feedRouter: FeedRouting) -> FeedCellConfigurator +} + +final class FeedCellFactory: FeedCellCreating { + func createFeedCellConfigurator(feedRouter: FeedRouting) -> FeedCellConfigurator { + FeedCellConfigurator(router: feedRouter) + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/Cell/FeedCellView.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/Cell/FeedCellView.swift new file mode 100644 index 0000000..e0f92f7 --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/Cell/FeedCellView.swift @@ -0,0 +1,202 @@ +// +// Created by Maxim Berezhnoy on 25/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +import UIKit + +private enum LayoutConstants { + static let ownerNameFontSize: CGFloat = 12 + static let tagsFontSize: CGFloat = 11 + static let viewsFontSize: CGFloat = 12 + static let dateTakenFontSize: CGFloat = 12 + + static let sideMarginRatio: CGFloat = 0.02 + static let tagsSideMarginRatio: CGFloat = 0.04 + static let topBottomMarginRatio: CGFloat = 0.2 + + static let cellBottomPadding: CGFloat = 30 + + static let dateTakenFormat = "MMM dd, yyyy" +} + +protocol FeedViewCellPhotoDisplaying { + func display(photo: Photo) +} + +final class FeedViewCell: UITableViewCell { + static let identifier = "\(FeedViewCell.self)" + + var router: FeedRouting? + var photo: Photo? + + private var imageHeightConstraint: NSLayoutConstraint? + + // MARK: - Subviews + + lazy var cellImageView: UIImageView = { + let view = UIImageView() + + view.contentMode = .scaleAspectFit + view.isUserInteractionEnabled = true + view.addGestureRecognizer(imageGestureRecognizer) + + return view + }() + + lazy var ownerNameView: UILabel = { + let view = UILabel() + + view.font = UIFont.systemFont(ofSize: LayoutConstants.ownerNameFontSize) + + return view + }() + + lazy var viewsView: ViewsLabel = { + let view = ViewsLabel() + + view.font = UIFont.systemFont(ofSize: LayoutConstants.viewsFontSize) + + return view + }() + + lazy var tagsView: TagsLabel = { + let view = TagsLabel() + + view.font = UIFont.boldSystemFont(ofSize: LayoutConstants.tagsFontSize) + view.lineBreakMode = .byWordWrapping + view.numberOfLines = 0 + + return view + }() + + lazy var dateTakenView: DateLabel = { + let view = DateLabel(dateFormat: LayoutConstants.dateTakenFormat) + + view.font = UIFont.systemFont(ofSize: LayoutConstants.dateTakenFontSize) + + return view + }() + + lazy var imageGestureRecognizer: UITapGestureRecognizer = { + let gesture = UITapGestureRecognizer(target: self, action: #selector(onImageTapped)) + + gesture.numberOfTapsRequired = 1 + + return gesture + }() + + // MARK: - Init + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + contentView.backgroundColor = Style.ScreenBackground.color + + setupImageView() + setupOwnerNameView() + setupViewsView() + setupTagsView() + setupDateTakenView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + lazy var viewWidthConstraint: NSLayoutConstraint = { + let constraint = contentView.widthAnchor.constraint(equalToConstant: bounds.size.width) + constraint.isActive = true + return constraint + }() + + override func systemLayoutSizeFitting(_ targetSize: CGSize, + withHorizontalFittingPriority _: UILayoutPriority, + verticalFittingPriority _: UILayoutPriority) -> CGSize + { + viewWidthConstraint.constant = bounds.size.width + return contentView.systemLayoutSizeFitting(CGSize(width: targetSize.width, height: 1)) + } + + // MARK: - View Setup + + private func setupImageView() { + contentView.addSubview(cellImageView) + + cellImageView.translatesAutoresizingMaskIntoConstraints = false + cellImageView.topAnchor.constraint(equalTo: contentView.topAnchor).isActive = true + cellImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor).isActive = true + cellImageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor).isActive = true + } + + private func setupOwnerNameView() { + contentView.addSubview(ownerNameView) + + ownerNameView.translatesAutoresizingMaskIntoConstraints = false + ownerNameView.topAnchor.constraint(equalTo: cellImageView.bottomAnchor, constant: frame.height * LayoutConstants.topBottomMarginRatio).isActive = true + ownerNameView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: frame.width * LayoutConstants.sideMarginRatio).isActive = true + } + + private func setupViewsView() { + contentView.addSubview(viewsView) + + viewsView.translatesAutoresizingMaskIntoConstraints = false + viewsView.topAnchor.constraint(equalTo: cellImageView.bottomAnchor, constant: frame.height * LayoutConstants.topBottomMarginRatio).isActive = true + viewsView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -1 * frame.width * LayoutConstants.sideMarginRatio).isActive = true + } + + private func setupTagsView() { + contentView.addSubview(tagsView) + + tagsView.translatesAutoresizingMaskIntoConstraints = false + tagsView.topAnchor.constraint(equalTo: ownerNameView.bottomAnchor, constant: frame.height * LayoutConstants.topBottomMarginRatio).isActive = true + tagsView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: frame.width * LayoutConstants.tagsSideMarginRatio).isActive = true + tagsView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -1 * frame.width * LayoutConstants.tagsSideMarginRatio).isActive = true + } + + private func setupDateTakenView() { + contentView.addSubview(dateTakenView) + + dateTakenView.translatesAutoresizingMaskIntoConstraints = false + dateTakenView.topAnchor.constraint(equalTo: tagsView.bottomAnchor, constant: frame.height * LayoutConstants.topBottomMarginRatio).isActive = true + dateTakenView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -1 * LayoutConstants.cellBottomPadding).isActive = true + dateTakenView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: frame.width * LayoutConstants.sideMarginRatio).isActive = true + } + + private func layoutImage() { + guard let image = cellImageView.image else { + return + } + + imageHeightConstraint?.isActive = false + imageHeightConstraint = cellImageView.heightAnchor.constraint(equalTo: cellImageView.widthAnchor, multiplier: image.size.height / image.size.width) + imageHeightConstraint?.priority = .defaultHigh + imageHeightConstraint?.isActive = true + } + + @objc private func onImageTapped() { + guard let router = router, let photo = photo else { + return + } + + router.displayFullImage(withInfoFrom: photo) + } +} + +// MARK: - FeedViewCellPhotoDisplaying + +extension FeedViewCell: FeedViewCellPhotoDisplaying { + func display(photo: Photo) { + self.photo = photo + + let metadata = photo.metadata + + ownerNameView.text = metadata.ownerName + dateTakenView.set(date: metadata.dateTaken) + viewsView.set(views: metadata.views) + tagsView.set(tags: metadata.tags) + + cellImageView.image = UIImage(data: photo.imageData) + layoutImage() + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/Cell/Metadata/DateLabel.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/Cell/Metadata/DateLabel.swift new file mode 100644 index 0000000..727dfdf --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/Cell/Metadata/DateLabel.swift @@ -0,0 +1,25 @@ +// +// Created by Maxim Berezhnoy on 26/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +import UIKit + +final class DateLabel: UILabel { + private let dateFormatter: DateFormatter + + init(dateFormat: String, frame: CGRect = .zero) { + dateFormatter = DateFormatter() + dateFormatter.dateFormat = dateFormat + + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func set(date: Date) { + text = dateFormatter.string(from: date) + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/Cell/Metadata/TagsLabel.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/Cell/Metadata/TagsLabel.swift new file mode 100644 index 0000000..807aaa3 --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/Cell/Metadata/TagsLabel.swift @@ -0,0 +1,12 @@ +// +// Created by Maxim Berezhnoy on 26/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +import UIKit + +class TagsLabel: UILabel { + func set(tags: [String]) { + super.text = tags.joined(separator: ", ") + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/Cell/Metadata/ViewsLabel.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/Cell/Metadata/ViewsLabel.swift new file mode 100644 index 0000000..1d4fb23 --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/Cell/Metadata/ViewsLabel.swift @@ -0,0 +1,13 @@ +// +// Created by Maxim Berezhnoy on 26/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +import UIKit + +final class ViewsLabel: UILabel { + func set(views: Int) { + let description = views == 1 ? "view" : "views" + super.text = "\(views) \(description)" + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FeedFactory.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FeedFactory.swift new file mode 100644 index 0000000..33b0d85 --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FeedFactory.swift @@ -0,0 +1,46 @@ +// +// Created by Maxim Berezhnoy on 23/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +protocol FeedCreating { + func createViewController(photoDataProvider: PhotoDataProviding, photoCollectionFetcher: PhotoCollectionFetching) -> FeedViewController +} + +final class FeedFactory: FeedCreating { + let feedCellFactory: FeedCellCreating + + init(feedCellFactory: FeedCellCreating) { + self.feedCellFactory = feedCellFactory + } + + func createViewController(photoDataProvider: PhotoDataProviding, photoCollectionFetcher: PhotoCollectionFetching) -> FeedViewController { + let presenter = FeedPresenter() + let interactor = FeedInteractor(presenter: presenter, photoCollectionFetcher: photoCollectionFetcher) + var router = createRouter(photoDataProvider: photoDataProvider) + let dataSource = createDataSource(router: router) + + let viewController = FeedViewController( + interactor: interactor, + dataSource: dataSource, + router: router + ) + + presenter.view = viewController + router.sourceVC = viewController + + return viewController + } + + private func createDataSource(router: FeedRouting) -> FeedDataSourcing { + let configurator = feedCellFactory.createFeedCellConfigurator(feedRouter: router) + + return FeedViewDataSource(feedCellConfigurator: configurator) + } + + private func createRouter(photoDataProvider: PhotoDataProviding) -> FeedRouting { + let fullImageFactory = FullImageFactory(photoDataProvider: photoDataProvider) + + return FeedRouter(fullImageFactory: fullImageFactory) + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/BrowserInteractor.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FeedInteractor.swift similarity index 62% rename from SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/BrowserInteractor.swift rename to SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FeedInteractor.swift index fe631c8..c5588ca 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/BrowserInteractor.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FeedInteractor.swift @@ -1,26 +1,26 @@ // -// Created by Maxim Berezhnoy on 16/01/2020. +// Created by Maxim Berezhnoy on 22/11/2020. // // Copyright (c) 2020 rencevio. All rights reserved. import Foundation -protocol BrowserInteracting { - func fetch(photos request: BrowserModels.Photos.Request) +protocol FeedInteracting { + func fetch(photos request: FeedModels.Photos.Request) } -final class BrowserInteractor: BrowserInteracting { - private let presenter: BrowserPresenting +final class FeedInteractor: FeedInteracting { + private let presenter: FeedPresenting private let photoCollectionFetcher: PhotoCollectionFetching - private var currentRequest: BrowserModels.Photos.Request? + private var currentRequest: FeedModels.Photos.Request? - init(presenter: BrowserPresenting, photoCollectionFetcher: PhotoCollectionFetching) { + init(presenter: FeedPresenting, photoCollectionFetcher: PhotoCollectionFetching) { self.presenter = presenter self.photoCollectionFetcher = photoCollectionFetcher } - func fetch(photos request: BrowserModels.Photos.Request) { + func fetch(photos request: FeedModels.Photos.Request) { if currentRequest == request { return } @@ -30,7 +30,7 @@ final class BrowserInteractor: BrowserInteracting { photoCollectionFetcher.fetchPhotos( startingFrom: request.startFromPosition, fetchAtMost: request.fetchAtMost, - matching: request.searchCriteria + withSize: request.size ) { result in switch result { case let .success(photos): @@ -41,10 +41,14 @@ final class BrowserInteractor: BrowserInteracting { self.currentRequest = nil } - let response = BrowserModels.Photos.Response(startingPosition: request.startFromPosition, photos: photos) + let response = FeedModels.Photos.Response( + startingPosition: request.startFromPosition, + photos: photos + ) self.presenter.present(photos: response) } + case let .failure(error): print("Error while retrieving photos (request: \(request)): \(error)") } diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/BrowserModels.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FeedModels.swift similarity index 67% rename from SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/BrowserModels.swift rename to SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FeedModels.swift index 71cf0e9..272a49b 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/BrowserModels.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FeedModels.swift @@ -1,14 +1,15 @@ // -// Created by Maxim Berezhnoy on 16/01/2020. +// Created by Maxim Berezhnoy on 22/11/2020. // // Copyright (c) 2020 rencevio. All rights reserved. -struct BrowserModels { - struct Photos { +enum FeedModels { + enum Photos { struct Request: Equatable { let startFromPosition: Int let fetchAtMost: Int - let searchCriteria: String + let size: PhotoParameters.Size + let metadata: [PhotoParameters.Metadata] } struct Response { diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FeedPresenter.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FeedPresenter.swift new file mode 100644 index 0000000..1e93e16 --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FeedPresenter.swift @@ -0,0 +1,22 @@ +// +// Created by Maxim Berezhnoy on 22/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +protocol FeedPresenting: AnyObject { + func present(photos: FeedModels.Photos.Response) +} + +final class FeedPresenter: FeedPresenting { + weak var view: FeedDisplaying? + + func present(photos: FeedModels.Photos.Response) { + let viewModel = FeedModels.Photos.ViewModel(photos: photos.photos) + + if photos.startingPosition == 0 { + view?.displayNew(photos: viewModel) + } else { + view?.displayMore(photos: viewModel) + } + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FeedRouter.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FeedRouter.swift new file mode 100644 index 0000000..f043764 --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FeedRouter.swift @@ -0,0 +1,32 @@ +// +// Created by Maxim Berezhnoy on 27/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +import UIKit + +protocol FeedRouting: Routing { + func displayFullImage(withInfoFrom photo: Photo) +} + +final class FeedRouter: FeedRouting { + var sourceVC: UIViewController? + + private let fullImageFactory: FullImageCreating + + init(fullImageFactory: FullImageCreating) { + self.fullImageFactory = fullImageFactory + } + + func displayFullImage(withInfoFrom photo: Photo) { + guard let sourceVC = sourceVC else { + return + } + + let fullImageViewController = fullImageFactory.createViewController(withInfoFrom: photo) + + fullImageViewController.modalPresentationStyle = .fullScreen + fullImageViewController.modalTransitionStyle = .crossDissolve + sourceVC.present(fullImageViewController, animated: true) + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FeedViewController.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FeedViewController.swift new file mode 100644 index 0000000..4f5c115 --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FeedViewController.swift @@ -0,0 +1,164 @@ +// +// Created by Maxim Berezhnoy on 22/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +import UIKit + +protocol FeedDisplaying: AnyObject { + func displayNew(photos: FeedModels.Photos.ViewModel) + func displayMore(photos: FeedModels.Photos.ViewModel) +} + +private enum LayoutConstants { + static let photoSize = PhotoParameters.Size.medium +} + +final class FeedViewController: UIViewController { + private let photosPerFetchRequest = 4 + private let metadataToFetch: [PhotoParameters.Metadata] = [ + .views, + .dateTaken, + .tags, + .ownerName, + ] + + private let interactor: FeedInteracting + private let dataSource: FeedDataSourcing + private let router: FeedRouting + + private lazy var tableView: UITableView = { + let view = UITableView(frame: .zero) + + view.alwaysBounceVertical = true + view.backgroundColor = Style.ScreenBackground.color + view.showsVerticalScrollIndicator = false + + view.tableFooterView = UIView() + view.separatorStyle = .none + view.estimatedRowHeight = 500 + + view.allowsSelection = false + + return view + }() + + private lazy var refreshControl: UIRefreshControl = { + let control = UIRefreshControl(frame: .zero) + + return control + }() + + init(interactor: FeedInteracting, dataSource: FeedDataSourcing, router: FeedRouting) { + self.interactor = interactor + self.dataSource = dataSource + self.router = router + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + setupTableView() + setupRefreshControl() + + requestNewPhotos() + } + + // MARK: - View Setup + + private func setupTableView() { + view.addSubview(tableView) + + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true + tableView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true + tableView.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true + tableView.heightAnchor.constraint(equalTo: view.heightAnchor).isActive = true + + tableView.delegate = self + + dataSource.register(for: tableView) + } + + private func setupRefreshControl() { + tableView.refreshControl = refreshControl + + refreshControl.addTarget(self, action: #selector(handleRefreshControl), for: .valueChanged) + } + + // MARK: - Data Requesting + + private func requestNewPhotos() { + interactor.fetch( + photos: FeedModels.Photos.Request( + startFromPosition: 0, + fetchAtMost: photosPerFetchRequest, + size: LayoutConstants.photoSize, + metadata: metadataToFetch + ) + ) + } + + func requestMorePhotos() { + interactor.fetch( + photos: FeedModels.Photos.Request( + startFromPosition: dataSource.photoCount, + fetchAtMost: photosPerFetchRequest, + size: LayoutConstants.photoSize, + metadata: metadataToFetch + ) + ) + } +} + +// MARK: - UITableViewDelegate + +extension FeedViewController: UITableViewDelegate { + public func tableView(_: UITableView, + willDisplay _: UITableViewCell, + forRowAt indexPath: IndexPath) + { + let itemToDisplay = indexPath.row + + let loadedPhotosCount = dataSource.photoCount + + if itemToDisplay == loadedPhotosCount - 1 { + requestMorePhotos() + } + } +} + +// MARK: - FeedDisplaying + +extension FeedViewController: FeedDisplaying { + func displayNew(photos: FeedModels.Photos.ViewModel) { + dataSource.set(photos: photos.photos) + + refreshControl.endRefreshing() + + tableView.reloadData() + tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .top, animated: false) + } + + func displayMore(photos: FeedModels.Photos.ViewModel) { + let currentPhotoCount = dataSource.photoCount + + dataSource.add(photos: photos.photos) + + tableView.insertRows(at: (0 ..< photos.photos.count).map { IndexPath(row: currentPhotoCount + $0, section: 0) }, with: .fade) + } +} + +// MARK: - Refresh control + +extension FeedViewController { + @objc func handleRefreshControl() { + requestNewPhotos() + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FeedViewDataSource.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FeedViewDataSource.swift new file mode 100644 index 0000000..48d7d97 --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FeedViewDataSource.swift @@ -0,0 +1,77 @@ +// +// Created by Maxim Berezhnoy on 23/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +import UIKit + +protocol FeedDataSourcing: UITableViewDataSource { + var photoCount: Int { get } + + func register(for tableView: UITableView) + func add(photos: [Photo]) + func set(photos: [Photo]) +} + +final class FeedViewDataSource: NSObject { + private let feedCellConfigurator: FeedCellConfiguring + private var photos = [Photo]() + + init(feedCellConfigurator: FeedCellConfiguring) { + self.feedCellConfigurator = feedCellConfigurator + } +} + +// MARK: - UITableViewDataSource + +extension FeedViewDataSource: UITableViewDataSource { + public func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { + photos.count + } + + public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = dequeueFeedCell(from: tableView, at: indexPath) + + let photo = photos[indexPath.row] + + feedCellConfigurator.configure(cell) + cell.display(photo: photo) + + return cell + } +} + +// MARK: - FeedDataSourcing + +extension FeedViewDataSource: FeedDataSourcing { + var photoCount: Int { + photos.count + } + + func register(for tableView: UITableView) { + tableView.dataSource = self + tableView.register(FeedViewCell.self, forCellReuseIdentifier: FeedViewCell.identifier) + } + + func add(photos: [Photo]) { + self.photos.append(contentsOf: photos) + } + + func set(photos: [Photo]) { + self.photos = photos + } +} + +// MARK: - FeedViewCell dequeuing + +extension FeedViewDataSource { + func dequeueFeedCell(from tableView: UITableView, at indexPath: IndexPath) -> FeedViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: FeedViewCell.identifier, for: indexPath) + + guard let feedCell = cell as? FeedViewCell else { + fatalError("Unexpected cell type while dequeuing in \(FeedViewDataSource.self): got \(cell)") + } + + return feedCell + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FullImage/FullImageFactory.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FullImage/FullImageFactory.swift new file mode 100644 index 0000000..1e6013f --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FullImage/FullImageFactory.swift @@ -0,0 +1,27 @@ +// +// Created by Maxim Berezhnoy on 29/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +protocol FullImageCreating { + func createViewController(withInfoFrom photo: Photo) -> FullImageViewController +} + +final class FullImageFactory: FullImageCreating { + private let photoDataProvider: PhotoDataProviding + + init(photoDataProvider: PhotoDataProviding) { + self.photoDataProvider = photoDataProvider + } + + func createViewController(withInfoFrom photo: Photo) -> FullImageViewController { + let presenter = FullImagePresenter() + let interactor = FullImageInteractor(presenter: presenter, photoDataProvider: photoDataProvider) + + let viewController = FullImageViewController(photo: photo, interactor: interactor) + + presenter.view = viewController + + return viewController + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FullImage/FullImageInteractor.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FullImage/FullImageInteractor.swift new file mode 100644 index 0000000..a4b87e6 --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FullImage/FullImageInteractor.swift @@ -0,0 +1,37 @@ +// +// Created by Maxim Berezhnoy on 29/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +import Foundation + +protocol FullImageInteracting { + func fetch(image: FullImageModels.Image.Request) +} + +final class FullImageInteractor: FullImageInteracting { + private let presenter: FullImagePresenting + private let photoDataProvider: PhotoDataProviding + + init(presenter: FullImagePresenting, photoDataProvider: PhotoDataProviding) { + self.presenter = presenter + self.photoDataProvider = photoDataProvider + } + + func fetch(image: FullImageModels.Image.Request) { + photoDataProvider.getPhotoData(from: image.url, backgroundDownload: true) { result in + DispatchQueue.main.async { [weak self] in + guard let self = self else { + return + } + + switch result { + case let .success(data): + self.presenter.present(image: FullImageModels.Image.Response(image: data)) + case .failure: + self.presenter.presentError() + } + } + } + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/Cell/BrowserCellModels.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FullImage/FullImageModels.swift similarity index 50% rename from SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/Cell/BrowserCellModels.swift rename to SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FullImage/FullImageModels.swift index ac0c57d..21aa5f6 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Browser/Cell/BrowserCellModels.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FullImage/FullImageModels.swift @@ -1,23 +1,22 @@ // -// Created by Maxim Berezhnoy on 16/01/2020. +// Created by Maxim Berezhnoy on 29/11/2020. // // Copyright (c) 2020 rencevio. All rights reserved. -import UIKit +import Foundation.NSData -struct BrowserCellModels { - struct PhotoImage { +struct FullImageModels { + enum Image { struct Request { - let photoID: Photo.ID let url: URL } struct Response { - let data: Data + let image: Data } struct ViewModel { - let image: UIImage + let image: Data } } } diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FullImage/FullImagePresenter.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FullImage/FullImagePresenter.swift new file mode 100644 index 0000000..10beb22 --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FullImage/FullImagePresenter.swift @@ -0,0 +1,21 @@ +// +// Created by Maxim Berezhnoy on 29/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +protocol FullImagePresenting { + func present(image: FullImageModels.Image.Response) + func presentError() +} + +final class FullImagePresenter: FullImagePresenting { + weak var view: FullImageDisplaying? + + func present(image: FullImageModels.Image.Response) { + view?.display(image: FullImageModels.Image.ViewModel(image: image.image)) + } + + func presentError() { + view?.displayError() + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FullImage/FullImageViewController.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FullImage/FullImageViewController.swift new file mode 100644 index 0000000..22ebbff --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Feed/FullImage/FullImageViewController.swift @@ -0,0 +1,129 @@ +// +// Created by Maxim Berezhnoy on 27/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +import UIKit + +protocol FullImageDisplaying: AnyObject { + func display(image: FullImageModels.Image.ViewModel) + func displayError() +} + +final class FullImageViewController: UIViewController { + private let interactor: FullImageInteracting + private let photo: Photo + + private enum State { + case loading + case image(image: UIImage) + case error + } + + private lazy var imageView: UIImageView = { + let view = UIImageView() + + view.clipsToBounds = true + view.contentMode = .scaleAspectFit + + view.isUserInteractionEnabled = true + view.addGestureRecognizer(imageGestureRecognizer) + + return view + }() + + private lazy var loadingIndicator: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView() + + indicator.hidesWhenStopped = true + indicator.color = Style.FullImage.LoadingIndicator.color + + return indicator + }() + + lazy var imageGestureRecognizer: UITapGestureRecognizer = { + let gesture = UITapGestureRecognizer(target: self, action: #selector(onImageTapped)) + + gesture.numberOfTapsRequired = 1 + + return gesture + }() + + init(photo: Photo, interactor: FullImageInteracting) { + self.photo = photo + self.interactor = interactor + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = Style.FullImage.Background.color + + setupImageView() + setupLoadingIndicator() + + set(state: .loading) + interactor.fetch(image: FullImageModels.Image.Request(url: photo.fullSizeImageURL)) + } + + // MARK: - UI Setup + + private func setupImageView() { + view.addSubview(imageView) + + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true + imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true + imageView.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true + imageView.heightAnchor.constraint(equalTo: view.heightAnchor).isActive = true + } + + private func setupLoadingIndicator() { + view.addSubview(loadingIndicator) + + loadingIndicator.translatesAutoresizingMaskIntoConstraints = false + loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true + loadingIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true + } + + private func set(state: State) { + switch state { + case .loading: + loadingIndicator.startAnimating() + imageView.isHidden = true + case let .image(image): + loadingIndicator.stopAnimating() + imageView.isHidden = false + imageView.image = image + case .error: + loadingIndicator.stopAnimating() + imageView.isHidden = true + } + } + + @objc private func onImageTapped() { + self.dismiss(animated: true) + } +} + +// MARK: - FullImageDisplaying + +extension FullImageViewController: FullImageDisplaying { + func display(image: FullImageModels.Image.ViewModel) { + if let image = UIImage(data: image.image) { + set(state: .image(image: image)) + } else { + set(state: .error) + } + } + + func displayError() { + set(state: .error) + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Style.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Style.swift index 3eb473b..ccf3868 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Style.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Scenes/Style.swift @@ -6,7 +6,17 @@ import UIKit struct Style { - struct ScreenBackground { + enum ScreenBackground { static let color = UIColor(red: 0.93, green: 0.94, blue: 0.95, alpha: 1.0) } + + enum FullImage { + enum LoadingIndicator { + static let color = UIColor(red: 0.74, green: 0.76, blue: 0.78, alpha: 1.0) + } + + enum Background { + static let color = UIColor(red: 0.93, green: 0.94, blue: 0.95, alpha: 1.0) + } + } } diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Utils/Routing.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Utils/Routing.swift new file mode 100644 index 0000000..6ffce38 --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Utils/Routing.swift @@ -0,0 +1,10 @@ +// +// Created by Maxim Berezhnoy on 27/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +import UIKit + +protocol Routing { + var sourceVC: UIViewController? { get set } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/FlickrApiServices/FlickrApiURLBuilder.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/FlickrApiServices/FlickrApiURLBuilder.swift index 65ed2ea..42d0aaf 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/FlickrApiServices/FlickrApiURLBuilder.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/FlickrApiServices/FlickrApiURLBuilder.swift @@ -9,7 +9,8 @@ final class FlickrApiURLResolver { static func build(method: FlickrApiValues.Method, apiKey: String, queryParameters: [FlickrApiValues.QueryParameter: String], - format: String = "json") -> URL { + format: String = "json") -> URL + { let defaultQueryParameters: [FlickrApiValues.QueryParameter: String] = [ .method: method.rawValue, .apiKey: apiKey, diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/FlickrApiServices/FlickrApiValues.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/FlickrApiServices/FlickrApiValues.swift index b263a14..ea0ec46 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/FlickrApiServices/FlickrApiValues.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/FlickrApiServices/FlickrApiValues.swift @@ -20,5 +20,19 @@ enum FlickrApiValues { case format case text case noJsonCallback = "nojsoncallback" + case extras + } + + enum PhotoSizeSuffix: String { + case thumbSquare = "q" + case medium = "c" + case large = "b" + } + + enum PhotoMetadata: String { + case views + case tags + case ownerName = "owner_name" + case dateTaken = "date_taken" } } diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/FlickrApiServices/Photos/Entities/FlickrPhotos.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/FlickrApiServices/Photos/Entities/FlickrPhotos.swift index cb77c18..36d27c1 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/FlickrApiServices/Photos/Entities/FlickrPhotos.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/FlickrApiServices/Photos/Entities/FlickrPhotos.swift @@ -18,4 +18,8 @@ struct FlickrPhoto: Decodable { let secret: String let server: String let farm: Int + let datetaken: String + let ownername: String + let views: String + let tags: String } diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/FlickrApiServices/Photos/FlickrPhotoURLResolver.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/FlickrApiServices/Photos/FlickrPhotoURLResolver.swift index 779e0a1..b60745c 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/FlickrApiServices/Photos/FlickrPhotoURLResolver.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/FlickrApiServices/Photos/FlickrPhotoURLResolver.swift @@ -5,13 +5,26 @@ import struct Foundation.URL -final class FlickrPhotoURLResolver { - static func resolveUrl(for photo: FlickrPhoto) -> URL { - guard let url = URL(string: "https://farm\(photo.farm).staticflickr.com/\(photo.server)/\(photo.id)_\(photo.secret)_q.jpg") +enum FlickrPhotoURLResolver { + static func resolveUrl(for photo: FlickrPhoto, withSize size: PhotoParameters.Size) -> URL { + let sizeSuffix = getSizeSuffix(for: size) + + guard let url = URL(string: "https://farm\(photo.farm).staticflickr.com/\(photo.server)/\(photo.id)_\(photo.secret)_\(sizeSuffix.rawValue).jpg") else { fatalError("Failed to resolve flickr photo url (input photo: \(photo))") } return url } + + private static func getSizeSuffix(for size: PhotoParameters.Size) -> FlickrApiValues.PhotoSizeSuffix { + switch size { + case .thumbSquare: + return .thumbSquare + case .medium: + return .medium + case .large: + return .large + } + } } diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/FlickrApiServices/Photos/FlickrPhotosService.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/FlickrApiServices/Photos/FlickrPhotosService.swift index 478e376..bc337b7 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/FlickrApiServices/Photos/FlickrPhotosService.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/FlickrApiServices/Photos/FlickrPhotosService.swift @@ -8,9 +8,7 @@ import Foundation protocol FlickrPhotosFetching { typealias Completion = (Result<[FlickrPhoto], Error>) -> Void - func getRecent(page: Int, photosPerPage: Int, completion: @escaping Completion) - - func search(matching text: String, page: Int, photosPerPage: Int, completion: @escaping Completion) + func getRecent(page: Int, photosPerPage: Int, includeMetadata metadata: [PhotoParameters.Metadata], completion: @escaping Completion) } final class FlickrPhotosService: FlickrPhotosFetching { @@ -22,27 +20,14 @@ final class FlickrPhotosService: FlickrPhotosFetching { self.httpClient = httpClient } - func getRecent(page: Int, photosPerPage: Int, completion: @escaping Completion) { + func getRecent(page: Int, photosPerPage: Int, includeMetadata metadata: [PhotoParameters.Metadata], completion: @escaping Completion) { let url = FlickrApiURLResolver.build( method: .photosGetRecent, apiKey: apiKey, queryParameters: [ .page: String(page), .perPage: String(photosPerPage), - ] - ) - - fetchFrom(url: url, completion: completion) - } - - func search(matching text: String, page: Int, photosPerPage: Int, completion: @escaping Completion) { - let url = FlickrApiURLResolver.build( - method: .photosSearch, - apiKey: apiKey, - queryParameters: [ - .text: text, - .page: String(page), - .perPage: String(photosPerPage), + .extras: getQueryParameters(for: metadata).map { $0.rawValue }.joined(separator: ","), ] ) @@ -50,7 +35,7 @@ final class FlickrPhotosService: FlickrPhotosFetching { } private func fetchFrom(url: URL, completion: @escaping Completion) { - httpClient.get(url: url) { [weak self] result in + httpClient.get(url: url, background: false) { [weak self] result in guard let self = self else { return } let photosResponse = self.parse(response: result) @@ -79,4 +64,19 @@ final class FlickrPhotosService: FlickrPhotosFetching { return .failure(error) } } + + private func getQueryParameters(for metadata: [PhotoParameters.Metadata]) -> [FlickrApiValues.PhotoMetadata] { + metadata.map { + switch $0 { + case .views: + return .views + case .tags: + return .tags + case .ownerName: + return .ownerName + case .dateTaken: + return .dateTaken + } + } + } } diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoCollectionFetcher/Flickr/FlickrCollectionDataFetcher.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoCollectionFetcher/Flickr/FlickrCollectionDataFetcher.swift new file mode 100644 index 0000000..d109c11 --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoCollectionFetcher/Flickr/FlickrCollectionDataFetcher.swift @@ -0,0 +1,47 @@ +// +// Created by Maxim Berezhnoy on 27/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +import Foundation.NSData + +final class FlickrCollectionDataFetcher: FlickrCollectionDataFetching { + let dataProvider: PhotoDataProviding + + private let operatingQueue = DispatchQueue(label: "\(FlickrCollectionDataFetcher.self)OperatingQueue") + + init(dataProvider: PhotoDataProviding) { + self.dataProvider = dataProvider + } + + // Completes with dictionary containing only successfully retrieved data for photos + func fetchData( + photos: [FlickrPhoto], + withSize size: PhotoParameters.Size, + _ completion: @escaping ([Photo.ID: Foundation.Data]) -> Void + ) { + var photosToFetch = Set(photos.map { $0.id }) + var photosImageData = [Photo.ID: Data]() + + photos.forEach { photo in + let imageURL = FlickrPhotoURLResolver.resolveUrl(for: photo, withSize: size) + + dataProvider.getPhotoData(from: imageURL, backgroundDownload: false) { [operatingQueue] result in + operatingQueue.async { + switch result { + case let .success(data): + photosImageData[photo.id] = data + case let .failure(error): + print("Failed to retrieve data for photo \(imageURL): \(error)") + } + + photosToFetch.remove(photo.id) + + if photosToFetch.isEmpty { + completion(photosImageData) + } + } + } + } + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoCollectionFetcher/Flickr/FlickrCollectionDataFetching.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoCollectionFetcher/Flickr/FlickrCollectionDataFetching.swift new file mode 100644 index 0000000..4d51a91 --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoCollectionFetcher/Flickr/FlickrCollectionDataFetching.swift @@ -0,0 +1,10 @@ +// +// Created by Maxim Berezhnoy on 27/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +import Foundation.NSData + +protocol FlickrCollectionDataFetching { + func fetchData(photos: [FlickrPhoto], withSize size: PhotoParameters.Size, _ completion: @escaping ([Photo.ID: Data]) -> Void) +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoCollectionFetcher/Flickr/FlickrCollectionFetcher.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoCollectionFetcher/Flickr/FlickrCollectionFetcher.swift index 1c84205..26202da 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoCollectionFetcher/Flickr/FlickrCollectionFetcher.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoCollectionFetcher/Flickr/FlickrCollectionFetcher.swift @@ -3,49 +3,95 @@ // // Copyright (c) 2020 rencevio. All rights reserved. +import Foundation.NSDateFormatter + final class FlickrCollectionFetcher: PhotoCollectionFetching { private let flickrPhotosService: FlickrPhotosFetching + private let collectionDataFetcher: FlickrCollectionDataFetching - init(flickrPhotosService: FlickrPhotosFetching) { + init(flickrPhotosService: FlickrPhotosFetching, collectionDataFetcher: FlickrCollectionDataFetching) { self.flickrPhotosService = flickrPhotosService + self.collectionDataFetcher = collectionDataFetcher } func fetchPhotos(startingFrom position: Int, fetchAtMost maxFetchCount: Int, - completion: @escaping Completion) { - fetchPhotos(startingFrom: position, fetchAtMost: maxFetchCount, matching: nil, completion: completion) - } - - func fetchPhotos(startingFrom position: Int, - fetchAtMost maxFetchCount: Int, - matching searchCriteria: String?, - completion: @escaping Completion) { + withSize size: PhotoParameters.Size, + completion: @escaping Completion) + { let page = position / maxFetchCount + 1 + let metadata = PhotoParameters.Metadata.allCases - if let searchCriteria = searchCriteria, !searchCriteria.isEmpty { - flickrPhotosService.search( - matching: searchCriteria, - page: page, - photosPerPage: maxFetchCount, - completion: transformFetchResult(completion) - ) - } else { - flickrPhotosService.getRecent( - page: page, - photosPerPage: maxFetchCount, - completion: transformFetchResult(completion) - ) - } + flickrPhotosService.getRecent( + page: page, + photosPerPage: maxFetchCount, + includeMetadata: metadata, + completion: transformFetchResult(withSize: size, completion) + ) } - private func transformFetchResult(_ completion: @escaping Completion) -> FlickrPhotosService.Completion { - { result in + private func transformFetchResult(withSize size: PhotoParameters.Size, _ completion: @escaping Completion) -> FlickrPhotosService.Completion { + { [weak self] result in + guard let self = self else { + return + } + switch result { case let .success(photos): - completion(.success(photos.map { Photo(id: $0.id, imageURL: FlickrPhotoURLResolver.resolveUrl(for: $0)) })) + self.transformValidPhotos(photos, withSize: size) { validPhotos in + completion(.success(validPhotos)) + } case let .failure(error): completion(.failure(error)) } } } + + private func transformValidPhotos(_ photos: [FlickrPhoto], + withSize size: PhotoParameters.Size, + _ completion: @escaping ([Photo]) -> Void) + { + collectionDataFetcher.fetchData(photos: photos, withSize: size) { photosImageData in + let transformedPhotos = photos.map { photo -> Photo? in + guard let metadata = extractMetadata(from: photo) else { + return nil + } + + guard let imageData = photosImageData[photo.id] else { + return nil + } + + let fullSizeImageURL = FlickrPhotoURLResolver.resolveUrl(for: photo, withSize: .large) + + return Photo( + id: photo.id, + imageData: imageData, + fullSizeImageURL: fullSizeImageURL, + metadata: metadata + ) + } + + completion(transformedPhotos.compactMap { $0 }) + } + } +} + +private func extractMetadata(from photo: FlickrPhoto) -> Photo.Metadata? { + let dateTakenFormatter = DateFormatter() + dateTakenFormatter.dateFormat = "YYYY-MM-dd HH:mm:ss" + + guard let views = photo.views.toInt() else { + return nil + } + + guard let dateTaken = photo.datetaken.toDate(formatter: dateTakenFormatter) else { + return nil + } + + return Photo.Metadata( + views: views, + tags: photo.tags.components(separatedBy: " "), + ownerName: photo.ownername, + dateTaken: dateTaken + ) } diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoCollectionFetcher/Flickr/FlickrCollectionFetcherFactory.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoCollectionFetcher/Flickr/FlickrCollectionFetcherFactory.swift index 86ed6cb..d72daaa 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoCollectionFetcher/Flickr/FlickrCollectionFetcherFactory.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoCollectionFetcher/Flickr/FlickrCollectionFetcherFactory.swift @@ -4,11 +4,15 @@ // Copyright (c) 2020 rencevio. All rights reserved. final class FlickrCollectionFetcherFactory: PhotoCollectionFetcherCreating { - func createCollectionFetcher() -> PhotoCollectionFetching { + func createCollectionFetcher(photoDataProvider: PhotoDataProviding) -> PhotoCollectionFetching { let httpClient = Http.Client() let flickrPhotosService = FlickrPhotosService(apiKey: flickrApiKey, httpClient: httpClient) + let collectionDataFetcher = FlickrCollectionDataFetcher(dataProvider: photoDataProvider) - let fetcher = FlickrCollectionFetcher(flickrPhotosService: flickrPhotosService) + let fetcher = FlickrCollectionFetcher( + flickrPhotosService: flickrPhotosService, + collectionDataFetcher: collectionDataFetcher + ) return fetcher } diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoCollectionFetcher/PhotoCollectionFetcherFactory.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoCollectionFetcher/PhotoCollectionFetcherFactory.swift index ad7bba2..ddfb8e4 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoCollectionFetcher/PhotoCollectionFetcherFactory.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoCollectionFetcher/PhotoCollectionFetcherFactory.swift @@ -4,5 +4,5 @@ // Copyright (c) 2020 rencevio. All rights reserved. protocol PhotoCollectionFetcherCreating { - func createCollectionFetcher() -> PhotoCollectionFetching + func createCollectionFetcher(photoDataProvider: PhotoDataProviding) -> PhotoCollectionFetching } diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoCollectionFetcher/PhotoCollectionFetching.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoCollectionFetcher/PhotoCollectionFetching.swift index 0d9ad18..c56d0d4 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoCollectionFetcher/PhotoCollectionFetching.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoCollectionFetcher/PhotoCollectionFetching.swift @@ -8,6 +8,6 @@ protocol PhotoCollectionFetching { func fetchPhotos(startingFrom position: Int, fetchAtMost maxFetchCount: Int, - matching searchCriteria: String?, + withSize size: PhotoParameters.Size, completion: @escaping Completion) } diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoCollectionFetcher/PhotoParameters.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoCollectionFetcher/PhotoParameters.swift new file mode 100644 index 0000000..7a7d9f3 --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoCollectionFetcher/PhotoParameters.swift @@ -0,0 +1,19 @@ +// +// Created by Maxim Berezhnoy on 22/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +enum PhotoParameters { + enum Size { + case thumbSquare + case medium + case large + } + + enum Metadata: CaseIterable { + case views + case tags + case ownerName + case dateTaken + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoDataProvider/PhotoDataProvider.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoDataProvider/PhotoDataProvider.swift index 5e25712..c0d6995 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoDataProvider/PhotoDataProvider.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoDataProvider/PhotoDataProvider.swift @@ -8,7 +8,7 @@ import Foundation protocol PhotoDataProviding { typealias Completion = (Result) -> Void - func getPhotoData(from url: URL, completion: @escaping Completion) + func getPhotoData(from url: URL, backgroundDownload: Bool, _ completion: @escaping Completion) } final class PhotoDataProvider: PhotoDataProviding { @@ -22,7 +22,7 @@ final class PhotoDataProvider: PhotoDataProviding { self.retriever = retriever } - func getPhotoData(from url: URL, completion: @escaping Completion) { + func getPhotoData(from url: URL, backgroundDownload: Bool, _ completion: @escaping Completion) { operatingQueue.async { [weak self] in guard let self = self else { return } @@ -32,13 +32,13 @@ final class PhotoDataProvider: PhotoDataProviding { case let .success(data): completion(.success(data)) case .failure: - self.retrievePhotoData(from: url, completion: completion) + self.retrievePhotoData(from: url, backgroundDownload: backgroundDownload, completion: completion) } } } - private func retrievePhotoData(from url: URL, completion: @escaping Completion) { - retriever.retrieve(from: url) { [weak cache] result in + private func retrievePhotoData(from url: URL, backgroundDownload: Bool, completion: @escaping Completion) { + retriever.retrieve(from: url, backgroundDownload: backgroundDownload) { [weak cache] result in switch result { case let .success(data): cache?.store(data: data, for: url) diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoDataProvider/PhotoDataRetriever/NetworkPhotoDataRetriever.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoDataProvider/PhotoDataRetriever/NetworkPhotoDataRetriever.swift index 97d8ac2..c243d09 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoDataProvider/PhotoDataRetriever/NetworkPhotoDataRetriever.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoDataProvider/PhotoDataRetriever/NetworkPhotoDataRetriever.swift @@ -12,8 +12,8 @@ final class NetworkPhotoDataRetriever: PhotoDataRetrieving { self.httpClient = httpClient } - func retrieve(from url: URL, completion: @escaping Completion) { - httpClient.get(url: url) { result in + func retrieve(from url: URL, backgroundDownload: Bool, completion: @escaping Completion) { + httpClient.get(url: url, background: backgroundDownload) { result in switch result { case let .success(data): completion(.success(data)) diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoDataProvider/PhotoDataRetriever/PhotoDataRetrieving.swift b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoDataProvider/PhotoDataRetriever/PhotoDataRetrieving.swift index 9650248..1265ba4 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoDataProvider/PhotoDataRetriever/PhotoDataRetrieving.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowser/Workers/PhotoDataProvider/PhotoDataRetriever/PhotoDataRetrieving.swift @@ -8,5 +8,5 @@ import Foundation protocol PhotoDataRetrieving { typealias Completion = (Result) -> Void - func retrieve(from url: URL, completion: @escaping Completion) + func retrieve(from url: URL, backgroundDownload: Bool, completion: @escaping Completion) } diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/FlickrApiServices/Photos/FlickrPhotoServiceTests.swift b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/FlickrApiServices/Photos/FlickrPhotoServiceTests.swift index d6ed07a..56510a3 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/FlickrApiServices/Photos/FlickrPhotoServiceTests.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/FlickrApiServices/Photos/FlickrPhotoServiceTests.swift @@ -20,7 +20,13 @@ let validPhotosData = """ "title": "Ford 6.4 Powerstroke EGR Delete Kit", "ispublic": 1, "isfriend": 0, - "isfamily": 0 + "isfamily": 0, + "datetaken":"2020-11-27 17:30:40", + "datetakengranularity":"0", + "datetakenunknown":"0", + "ownername":"owner1", + "views":"1023", + "tags":"tag1 tag2 tag3" }, { "id": "49400436126", @@ -31,7 +37,13 @@ let validPhotosData = """ "title": "BWO20V", "ispublic": 1, "isfriend": 0, - "isfamily": 0 + "isfamily": 0, + "datetaken":"2020-12-24 12:25:40", + "datetakengranularity":"0", + "datetakenunknown":"0", + "ownername":"owner2", + "views":"20", + "tags":"tag1 tag2 tag3" } ] }} @@ -59,7 +71,7 @@ class FlickrPhotoServiceTests: XCTestCase { var fetchedPhotos: [FlickrPhoto]! - sut.getRecent(page: 1, photosPerPage: 1) { result in + sut.getRecent(page: 1, photosPerPage: 1, includeMetadata: []) { result in switch result { case let .success(photos): fetchedPhotos = photos @@ -84,7 +96,7 @@ class FlickrPhotoServiceTests: XCTestCase { var fetchError: Error? - sut.getRecent(page: 1, photosPerPage: 1) { result in + sut.getRecent(page: 1, photosPerPage: 1, includeMetadata: []) { result in switch result { case let .success(photos): XCTFail("Unexpected success while fetching photos: \(photos)") diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/FlickrApiServices/Photos/MockFlickrPhotoService.swift b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/FlickrApiServices/Photos/MockFlickrPhotoService.swift index a3cb8f3..942f449 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/FlickrApiServices/Photos/MockFlickrPhotoService.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/FlickrApiServices/Photos/MockFlickrPhotoService.swift @@ -9,10 +9,7 @@ class MockFlickrPhotosService: FlickrPhotosFetching { var getRecentCalls = [(Int, Int)]() var getRecentResult: Result<[FlickrPhoto], Error>? - var searchCalls = [(String, Int, Int)]() - var searchResult: Result<[FlickrPhoto], Error>? - - func getRecent(page: Int, photosPerPage: Int, completion: @escaping Completion) { + func getRecent(page: Int, photosPerPage: Int, includeMetadata _: [PhotoParameters.Metadata], completion: @escaping Completion) { guard let result = getRecentResult else { fatalError("\(#function) expectation was not set") } @@ -21,14 +18,4 @@ class MockFlickrPhotosService: FlickrPhotosFetching { completion(result) } - - func search(matching text: String, page: Int, photosPerPage: Int, completion: @escaping Completion) { - guard let result = searchResult else { - fatalError("\(#function) expectation was not set") - } - - searchCalls.append((text, page, photosPerPage)) - - completion(result) - } } diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Network/MockHttpClient.swift b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Network/MockHttpClient.swift index 6ad5d45..817a504 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Network/MockHttpClient.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Network/MockHttpClient.swift @@ -11,7 +11,7 @@ class MockHttpClient: HttpCommunicator { var getCalls = [URL]() var getResult: Result! - func get(url: URL, completion: @escaping Completion) { + func get(url: URL, background _: Bool, completion: @escaping Completion) { guard let result = getResult else { fatalError("\(#function) expectation was not set") } diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Browser/BrowserPresenterTests.swift b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Browser/BrowserPresenterTests.swift deleted file mode 100644 index 71fa81f..0000000 --- a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Browser/BrowserPresenterTests.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// Created by Maxim Berezhnoy on 18/01/2020. -// -// Copyright (c) 2020 rencevio. All rights reserved. - -@testable import SimpleFlickrBrowser - -import XCTest - -class BrowserPresenterTests: XCTestCase { - var displayingMock: MockBrowserDisplaying! - - var sut: BrowserPresenter! - - override func setUp() { - super.setUp() - - displayingMock = MockBrowserDisplaying() - - sut = BrowserPresenter() - sut.view = displayingMock - } - - func test_presentPhotos_startingFromZero_dataIsReset() { - let photo = Photo(id: "id", imageURL: URL(string: "www.not-a-website.com")!) - - sut.present(photos: BrowserModels.Photos.Response(startingPosition: 0, photos: [photo])) - - XCTAssertEqual(displayingMock.displayMorePhotosCalls.count, 0) - XCTAssertEqual(displayingMock.displayNewPhotosCalls.count, 1) - } - - func test_presentPhotosTwice_startingFromNonZero_dataIsUpdated() { - let photo = Photo(id: "id", imageURL: URL(string: "www.not-a-website.com")!) - - sut.present(photos: BrowserModels.Photos.Response(startingPosition: 1, photos: [photo])) - - XCTAssertEqual(displayingMock.displayMorePhotosCalls.count, 1) - XCTAssertEqual(displayingMock.displayNewPhotosCalls.count, 0) - } -} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Browser/Cell/BrowserCellInteractorTests.swift b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Browser/Cell/BrowserCellInteractorTests.swift deleted file mode 100644 index 04f2bca..0000000 --- a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Browser/Cell/BrowserCellInteractorTests.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// Created by Maxim Berezhnoy on 16/01/2020. -// -// Copyright (c) 2020 rencevio. All rights reserved. - -@testable import SimpleFlickrBrowser - -import Foundation -import XCTest - -class BrowserCellInteractorTests: XCTestCase { - var presenter: MockBrowserCellPresenter! - var photoDataProvider: MockPhotoDataProvider! - var sut: BrowserCellInteractor! - - override func setUp() { - super.setUp() - - presenter = MockBrowserCellPresenter() - photoDataProvider = MockPhotoDataProvider() - - sut = BrowserCellInteractor(presenter: presenter, photoDataProvider: photoDataProvider) - } - - func test_fetchImage_retrievesFromProvider_success_sendsDataToPresenter() { - let url = URL(string: "www.not-a-website.com")! - let photoData = Data() - - photoDataProvider.getPhotoDataResult = .success(photoData) - - sut.fetch(image: BrowserCellModels.PhotoImage.Request(photoID: "id", url: url)) - - awaitMainQueueResolution() - - XCTAssertEqual(photoDataProvider.getPhotoDataCalls, [url]) - - XCTAssertEqual(presenter.presentLoadingCalls, 1) - - XCTAssertEqual(presenter.presentImageCalls.map { $0.data }, [photoData]) - XCTAssertEqual(presenter.presentErrorCalls, 0) - } - - func test_fetchImage_retrievesFromProvider_failure_sendsErrorToPresenter() { - let url = URL(string: "www.not-a-website.com")! - - enum RetrievalError: Error { - case undefined - } - - photoDataProvider.getPhotoDataResult = .failure(RetrievalError.undefined) - - sut.fetch(image: BrowserCellModels.PhotoImage.Request(photoID: "id", url: url)) - - awaitMainQueueResolution() - - XCTAssertEqual(photoDataProvider.getPhotoDataCalls, [url]) - - XCTAssertEqual(presenter.presentLoadingCalls, 1) - - XCTAssertEqual(presenter.presentImageCalls.count, 0) - XCTAssertEqual(presenter.presentErrorCalls, 1) - } - - func test_fetchImage_fetchAgainWhileFirstIsLoading_onlyPresentsSecondImage() { - let url = URL(string: "www.not-a-website.com")! - - let firstImageData = "1".data(using: .utf8)! - photoDataProvider.getPhotoDataResult = .success(firstImageData) - sut.fetch(image: BrowserCellModels.PhotoImage.Request(photoID: "id1", url: url)) - - let secondImageData = "2".data(using: .utf8)! - photoDataProvider.getPhotoDataResult = .success(secondImageData) - sut.fetch(image: BrowserCellModels.PhotoImage.Request(photoID: "id2", url: url)) - - awaitMainQueueResolution() - - XCTAssertEqual(photoDataProvider.getPhotoDataCalls.count, 2) - - XCTAssertEqual(presenter.presentImageCalls.map { $0.data }, [secondImageData]) - } -} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Browser/Cell/MockBrowserCellPresenter.swift b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Browser/Cell/MockBrowserCellPresenter.swift deleted file mode 100644 index ebba37d..0000000 --- a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Browser/Cell/MockBrowserCellPresenter.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// Created by Maxim Berezhnoy on 16/01/2020. -// -// Copyright (c) 2020 rencevio. All rights reserved. - -@testable import SimpleFlickrBrowser - -import Foundation - -final class MockBrowserCellPresenter: BrowserCellPresenting { - var presentImageCalls = [BrowserCellModels.PhotoImage.Response]() - var presentErrorCalls = 0 - var presentLoadingCalls = 0 - - func present(image: BrowserCellModels.PhotoImage.Response) { - presentImageCalls.append(image) - } - - func presentLoading() { - presentLoadingCalls += 1 - } - - func presentError() { - presentErrorCalls += 1 - } -} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Browser/MockBrowserDisplaying.swift b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Browser/MockBrowserDisplaying.swift deleted file mode 100644 index 831d91e..0000000 --- a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Browser/MockBrowserDisplaying.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// Created by Maxim Berezhnoy on 18/01/2020. -// -// Copyright (c) 2020 rencevio. All rights reserved. - -@testable import SimpleFlickrBrowser - -class MockBrowserDisplaying: BrowserDisplaying { - var displayNewPhotosCalls = [BrowserModels.Photos.ViewModel]() - var displayMorePhotosCalls = [BrowserModels.Photos.ViewModel]() - - func displayNew(photos: BrowserModels.Photos.ViewModel) { - displayNewPhotosCalls.append(photos) - } - - func displayMore(photos: BrowserModels.Photos.ViewModel) { - displayMorePhotosCalls.append(photos) - } -} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Browser/BrowserInteractorTests.swift b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Feed/FeedInteractorTests.swift similarity index 70% rename from SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Browser/BrowserInteractorTests.swift rename to SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Feed/FeedInteractorTests.swift index 9793725..64c9ba4 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Browser/BrowserInteractorTests.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Feed/FeedInteractorTests.swift @@ -7,23 +7,23 @@ import XCTest -class BrowserInteractorTests: XCTestCase { - var presenterMock: MockBrowserPresenter! +class FeedInteractorTests: XCTestCase { + var presenterMock: MockFeedPresenter! var collectionFetcherMock: MockPhotoCollectionFetcher! - var sut: BrowserInteractor! + var sut: FeedInteractor! override func setUp() { super.setUp() - presenterMock = MockBrowserPresenter() + presenterMock = MockFeedPresenter() collectionFetcherMock = MockPhotoCollectionFetcher() - sut = BrowserInteractor(presenter: presenterMock, photoCollectionFetcher: collectionFetcherMock) + sut = FeedInteractor(presenter: presenterMock, photoCollectionFetcher: collectionFetcherMock) } func test_fetch_fetchAgainImmediatelyWithSameParams_fetchesAndPresentsOnlyOnce() { - let fetchRequest = BrowserModels.Photos.Request(startFromPosition: 0, fetchAtMost: 10, searchCriteria: "42") + let fetchRequest = FeedModels.Photos.Request(startFromPosition: 0, fetchAtMost: 10, size: .large, metadata: []) collectionFetcherMock.fetchPhotoResult = .success([]) @@ -37,7 +37,7 @@ class BrowserInteractorTests: XCTestCase { } func test_fetch_fetchAgainAfterFirstRequestFinished_fetchesAndPresentsTwice() { - let fetchRequest = BrowserModels.Photos.Request(startFromPosition: 0, fetchAtMost: 10, searchCriteria: "42") + let fetchRequest = FeedModels.Photos.Request(startFromPosition: 0, fetchAtMost: 10, size: .large, metadata: []) collectionFetcherMock.fetchPhotoResult = .success([]) diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Feed/FeedPresenterTests.swift b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Feed/FeedPresenterTests.swift new file mode 100644 index 0000000..8661518 --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Feed/FeedPresenterTests.swift @@ -0,0 +1,51 @@ +// +// Created by Maxim Berezhnoy on 18/01/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +@testable import SimpleFlickrBrowser + +import XCTest + +class FeedPresenterTests: XCTestCase { + var displayingMock: MockFeedDisplaying! + + var sut: FeedPresenter! + + override func setUp() { + super.setUp() + + displayingMock = MockFeedDisplaying() + + sut = FeedPresenter() + sut.view = displayingMock + } + + func test_presentPhotos_startingFromZero_dataIsReset() { + let photo = Photo( + id: "id", + imageData: Data(), + fullSizeImageURL: URL(string: "www.not-a-website.com")!, + metadata: Photo.Metadata(views: 0, tags: [], ownerName: "", dateTaken: Date()) + ) + + sut.present(photos: FeedModels.Photos.Response(startingPosition: 0, photos: [photo])) + + XCTAssertEqual(displayingMock.displayMorePhotosCalls.count, 0) + XCTAssertEqual(displayingMock.displayNewPhotosCalls.count, 1) + } + + func test_presentPhotosTwice_startingFromNonZero_dataIsUpdated() { + let photo = Photo( + id: "id", + imageData: Data(), + fullSizeImageURL: URL(string: "www.not-a-website.com")!, + metadata: Photo.Metadata(views: 0, tags: [], ownerName: "", dateTaken: Date()) + ) + + sut.present(photos: FeedModels.Photos.Response(startingPosition: 1, photos: [photo])) + + XCTAssertEqual(displayingMock.displayMorePhotosCalls.count, 1) + XCTAssertEqual(displayingMock.displayNewPhotosCalls.count, 0) + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Feed/MockFeedDisplaying.swift b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Feed/MockFeedDisplaying.swift new file mode 100644 index 0000000..1497c43 --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Feed/MockFeedDisplaying.swift @@ -0,0 +1,19 @@ +// +// Created by Maxim Berezhnoy on 18/01/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +@testable import SimpleFlickrBrowser + +class MockFeedDisplaying: FeedDisplaying { + var displayNewPhotosCalls = [FeedModels.Photos.ViewModel]() + var displayMorePhotosCalls = [FeedModels.Photos.ViewModel]() + + func displayNew(photos: FeedModels.Photos.ViewModel) { + displayNewPhotosCalls.append(photos) + } + + func displayMore(photos: FeedModels.Photos.ViewModel) { + displayMorePhotosCalls.append(photos) + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Browser/MockBrowserPresenter.swift b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Feed/MockFeedPresenter.swift similarity index 54% rename from SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Browser/MockBrowserPresenter.swift rename to SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Feed/MockFeedPresenter.swift index 44b2e42..9ea4716 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Browser/MockBrowserPresenter.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/Feed/MockFeedPresenter.swift @@ -7,10 +7,10 @@ import Foundation -final class MockBrowserPresenter: BrowserPresenting { - var presentPhotosCalls = [BrowserModels.Photos.Response]() +final class MockFeedPresenter: FeedPresenting { + var presentPhotosCalls = [FeedModels.Photos.Response]() - func present(photos: BrowserModels.Photos.Response) { + func present(photos: FeedModels.Photos.Response) { presentPhotosCalls.append(photos) } } diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/FullImage/FullImageInteractorTests.swift b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/FullImage/FullImageInteractorTests.swift new file mode 100644 index 0000000..ccf01c6 --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/FullImage/FullImageInteractorTests.swift @@ -0,0 +1,59 @@ +// +// Created by Maxim Berezhnoy on 29/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +@testable import SimpleFlickrBrowser + +import Foundation +import XCTest + +class BrowserCellInteractorTests: XCTestCase { + var presenter: MockFullImagePresenter! + var photoDataProvider: MockPhotoDataProvider! + var sut: FullImageInteractor! + + override func setUp() { + super.setUp() + + presenter = MockFullImagePresenter() + photoDataProvider = MockPhotoDataProvider() + + sut = FullImageInteractor(presenter: presenter, photoDataProvider: photoDataProvider) + } + + func test_fetchImage_retrievesFromProvider_success_sendsDataToPresenter() { + let url = URL(string: "www.not-a-website.com")! + let photoData = Data() + + photoDataProvider.getPhotoDataResults = [.success(photoData)] + + sut.fetch(image: FullImageModels.Image.Request(url: url)) + + awaitMainQueueResolution() + + XCTAssertEqual(photoDataProvider.getPhotoDataCalls, [url]) + + XCTAssertEqual(presenter.presentImageCalls.map { $0.image }, [photoData]) + XCTAssertEqual(presenter.presentErrorCalls, 0) + } + + func test_fetchImage_retrievesFromProvider_failure_sendsErrorToPresenter() { + let url = URL(string: "www.not-a-website.com")! + + enum RetrievalError: Error { + case undefined + } + + photoDataProvider.getPhotoDataResults = [.failure(RetrievalError.undefined)] + + sut.fetch(image: FullImageModels.Image.Request(url: url)) + + awaitMainQueueResolution() + + XCTAssertEqual(photoDataProvider.getPhotoDataCalls, [url]) + + XCTAssertEqual(presenter.presentImageCalls.count, 0) + XCTAssertEqual(presenter.presentErrorCalls, 1) + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/FullImage/MockFullImagePresenter.swift b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/FullImage/MockFullImagePresenter.swift new file mode 100644 index 0000000..423d74e --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Scenes/FullImage/MockFullImagePresenter.swift @@ -0,0 +1,21 @@ +// +// Created by Maxim Berezhnoy on 29/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +@testable import SimpleFlickrBrowser + +import Foundation + +final class MockFullImagePresenter: FullImagePresenting { + var presentImageCalls = [FullImageModels.Image.Response]() + var presentErrorCalls = 0 + + func present(image: FullImageModels.Image.Response) { + presentImageCalls.append(image) + } + + func presentError() { + presentErrorCalls += 1 + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoCollectionFetcher/Flickr/FlickrCollectionDataFetcherTests.swift b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoCollectionFetcher/Flickr/FlickrCollectionDataFetcherTests.swift new file mode 100644 index 0000000..7351b17 --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoCollectionFetcher/Flickr/FlickrCollectionDataFetcherTests.swift @@ -0,0 +1,54 @@ +// +// Created by Maxim Berezhnoy on 29/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +@testable import SimpleFlickrBrowser + +import XCTest + +class FlickrCollectionDataFetcherTests: XCTestCase { + let expectationTimeout = 1.0 + + var dataProviderMock: MockPhotoDataProvider! + var sut: FlickrCollectionDataFetcher! + + override func setUp() { + super.setUp() + + dataProviderMock = MockPhotoDataProvider() + sut = FlickrCollectionDataFetcher(dataProvider: dataProviderMock) + } + + func test_fetchData_somePhotosHaveNoImageData_returnsOnlyPhotosWithData() { + let photosWithData = [ + FlickrPhoto(id: "1", secret: "1", server: "1", farm: 1, datetaken: "1", ownername: "1", views: "1", tags: "1"), + FlickrPhoto(id: "2", secret: "2", server: "2", farm: 2, datetaken: "2", ownername: "2", views: "2", tags: "2"), + ] + let photosWithoutData = [ + FlickrPhoto(id: "3", secret: "3", server: "3", farm: 3, datetaken: "3", ownername: "3", views: "3", tags: "3"), + ] + + let photoSize = PhotoParameters.Size.large + + photosWithoutData.forEach { _ in + dataProviderMock.getPhotoDataResults.append(.failure(Http.RequestError.noData)) + } + photosWithData.forEach { _ in + dataProviderMock.getPhotoDataResults.append(.success(Data())) + } + + let fetchExpectation = XCTestExpectation(description: "Fetching photos data") + var fetchResult: [Photo.ID: Data]! + + sut.fetchData(photos: photosWithData + photosWithoutData, withSize: photoSize) { result in + fetchResult = result + fetchExpectation.fulfill() + } + + wait(for: [fetchExpectation], timeout: expectationTimeout) + + XCTAssertEqual(fetchResult.count, photosWithData.count) + XCTAssertEqual(fetchResult, Dictionary(uniqueKeysWithValues: photosWithData.map { ($0.id, Data()) })) + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoCollectionFetcher/Flickr/FlickrCollectionFetcherTests.swift b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoCollectionFetcher/Flickr/FlickrCollectionFetcherTests.swift index ff7ed50..6408b78 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoCollectionFetcher/Flickr/FlickrCollectionFetcherTests.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoCollectionFetcher/Flickr/FlickrCollectionFetcherTests.swift @@ -11,30 +11,36 @@ class FlickrCollectionFetcherTests: XCTestCase { let expectationTimeout = 1.0 var photosServiceMock: MockFlickrPhotosService! + var collectionDataFetcherMock: MockFlickrCollectionDataFetcher! var sut: FlickrCollectionFetcher! override func setUp() { super.setUp() photosServiceMock = MockFlickrPhotosService() - sut = FlickrCollectionFetcher(flickrPhotosService: photosServiceMock) + collectionDataFetcherMock = MockFlickrCollectionDataFetcher() + sut = FlickrCollectionFetcher(flickrPhotosService: photosServiceMock, collectionDataFetcher: collectionDataFetcherMock) } - func test_fetchPhotos_withoutSearchCriteria_fetchesRecentPhotos() { + func test_fetchPhotos_fetchesRecentPhotos() { let serviceFetchResult = [ - FlickrPhoto(id: "1", secret: "1", server: "1", farm: 1), - FlickrPhoto(id: "2", secret: "2", server: "2", farm: 2), + FlickrPhoto(id: "1", secret: "1", server: "1", farm: 1, datetaken: "2020-11-22 21:21:29", ownername: "1", views: "1", tags: "1"), + FlickrPhoto(id: "2", secret: "2", server: "2", farm: 2, datetaken: "2012-10-20 04:02:01", ownername: "2", views: "2", tags: "2"), ] + let collectionDataFetchResult = Dictionary(uniqueKeysWithValues: serviceFetchResult.map { ($0.id, Data()) }) + let startingPosition = 0 let maxFetchCount = 50 + let photoSize = PhotoParameters.Size.large let fetchExpectation = XCTestExpectation(description: "Fetching photos") var fetchResult: [Photo]! photosServiceMock.getRecentResult = .success(serviceFetchResult) + collectionDataFetcherMock.fetchDataResult = collectionDataFetchResult - sut.fetchPhotos(startingFrom: startingPosition, fetchAtMost: maxFetchCount) { result in + sut.fetchPhotos(startingFrom: startingPosition, fetchAtMost: maxFetchCount, withSize: photoSize) { result in switch result { case let .success(photos): fetchResult = photos @@ -51,29 +57,24 @@ class FlickrCollectionFetcherTests: XCTestCase { XCTAssertEqual(photosServiceMock.getRecentCalls.count, 1) XCTAssertTrue(photosServiceMock.getRecentCalls[0] == (startingPosition + 1, maxFetchCount)) - XCTAssertEqual(photosServiceMock.searchCalls.count, 0) + XCTAssertEqual(collectionDataFetcherMock.fetchDataCalls.count, 1) } - func test_fetchPhotos_withSearchCriteria_searchesForPhotos() { - let serviceFetchResult = [ - FlickrPhoto(id: "1", secret: "1", server: "1", farm: 1), - FlickrPhoto(id: "2", secret: "2", server: "2", farm: 2), - ] + func test_fetchPhotos_correctlyConvertsMetadata() { + let flickrPhoto = FlickrPhoto(id: "1", secret: "1", server: "1", farm: 1, datetaken: "2020-11-22 21:21:29", ownername: "1", views: "1", tags: "1") + + let collectionDataFetchResult = [flickrPhoto.id: Data()] - let searchCriteria = "cars" let startingPosition = 0 let maxFetchCount = 50 let fetchExpectation = XCTestExpectation(description: "Fetching photos") var fetchResult: [Photo]! - photosServiceMock.searchResult = .success(serviceFetchResult) + photosServiceMock.getRecentResult = .success([flickrPhoto]) + collectionDataFetcherMock.fetchDataResult = collectionDataFetchResult - sut.fetchPhotos( - startingFrom: startingPosition, - fetchAtMost: maxFetchCount, - matching: searchCriteria - ) { result in + sut.fetchPhotos(startingFrom: startingPosition, fetchAtMost: maxFetchCount, withSize: .large) { result in switch result { case let .success(photos): fetchResult = photos @@ -85,11 +86,10 @@ class FlickrCollectionFetcherTests: XCTestCase { wait(for: [fetchExpectation], timeout: expectationTimeout) - XCTAssertEqual(serviceFetchResult.map { $0.id }, fetchResult.map { $0.id }) - - XCTAssertEqual(photosServiceMock.searchCalls.count, 1) - XCTAssertTrue(photosServiceMock.searchCalls[0] == (searchCriteria, startingPosition + 1, maxFetchCount)) + XCTAssertEqual(fetchResult.count, 1) - XCTAssertEqual(photosServiceMock.getRecentCalls.count, 0) + let metadata = fetchResult[0].metadata + XCTAssertNotNil(metadata.dateTaken) + XCTAssertEqual(metadata.views, flickrPhoto.views.toInt()!) } } diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoCollectionFetcher/Flickr/MockFlickrCollectionDataFetcher.swift b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoCollectionFetcher/Flickr/MockFlickrCollectionDataFetcher.swift new file mode 100644 index 0000000..0d7e90a --- /dev/null +++ b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoCollectionFetcher/Flickr/MockFlickrCollectionDataFetcher.swift @@ -0,0 +1,23 @@ +// +// Created by Maxim Berezhnoy on 29/11/2020. +// +// Copyright (c) 2020 rencevio. All rights reserved. + +@testable import SimpleFlickrBrowser + +import Foundation.NSData + +class MockFlickrCollectionDataFetcher: FlickrCollectionDataFetching { + var fetchDataCalls = [([FlickrPhoto], PhotoParameters.Size)]() + var fetchDataResult: [Photo.ID: Data]! + + func fetchData(photos: [FlickrPhoto], withSize size: PhotoParameters.Size, _ completion: @escaping ([Photo.ID: Data]) -> Void) { + guard let result = fetchDataResult else { + fatalError("\(#function) expectation was not set") + } + + fetchDataCalls.append((photos, size)) + + completion(result) + } +} diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoCollectionFetcher/MockPhotoCollectionFetcher.swift b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoCollectionFetcher/MockPhotoCollectionFetcher.swift index 2a614ca..f3e3020 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoCollectionFetcher/MockPhotoCollectionFetcher.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoCollectionFetcher/MockPhotoCollectionFetcher.swift @@ -8,18 +8,19 @@ import XCTest class MockPhotoCollectionFetcher: PhotoCollectionFetching { - var fetchPhotosCalls = [(Int, Int, String?)]() + var fetchPhotosCalls = [(Int, Int, PhotoParameters.Size)]() var fetchPhotoResult: Result<[Photo], Error>! func fetchPhotos(startingFrom position: Int, fetchAtMost maxFetchCount: Int, - matching searchCriteria: String?, - completion: @escaping Completion) { + withSize size: PhotoParameters.Size, + completion: @escaping Completion) + { guard let result = fetchPhotoResult else { fatalError("\(#function) expectation was not set") } - fetchPhotosCalls.append((position, maxFetchCount, searchCriteria)) + fetchPhotosCalls.append((position, maxFetchCount, size)) completion(result) } diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoDataProvider/MockPhotoDataProvider.swift b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoDataProvider/MockPhotoDataProvider.swift index 2c4340d..2ddb8d5 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoDataProvider/MockPhotoDataProvider.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoDataProvider/MockPhotoDataProvider.swift @@ -9,15 +9,15 @@ import Foundation final class MockPhotoDataProvider: PhotoDataProviding { var getPhotoDataCalls = [URL]() - var getPhotoDataResult: Result? + var getPhotoDataResults = [Result]() - func getPhotoData(from url: URL, completion: @escaping Completion) { - guard let result = getPhotoDataResult else { + func getPhotoData(from url: URL, backgroundDownload _: Bool, _ completion: @escaping Completion) { + guard !getPhotoDataResults.isEmpty else { fatalError("\(#function) expectation was not set") } getPhotoDataCalls.append(url) - completion(result) + completion(getPhotoDataResults.popLast()!) } } diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoDataProvider/PhotoDataProviderTests.swift b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoDataProvider/PhotoDataProviderTests.swift index c7322f2..544ca45 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoDataProvider/PhotoDataProviderTests.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoDataProvider/PhotoDataProviderTests.swift @@ -30,7 +30,7 @@ class PhotoDataProviderTests: XCTestCase { let fetchExpectation = XCTestExpectation(description: "Fetching photo") - sut.getPhotoData(from: url) { result in + sut.getPhotoData(from: url, backgroundDownload: false) { result in switch result { case let .success(data): XCTAssertEqual(cachedData, data) @@ -59,7 +59,7 @@ class PhotoDataProviderTests: XCTestCase { let fetchExpectation = XCTestExpectation(description: "Fetching photo") - sut.getPhotoData(from: url) { result in + sut.getPhotoData(from: url, backgroundDownload: false) { result in switch result { case let .success(data): XCTAssertEqual(retrievedData, data) diff --git a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoDataProvider/PhotoDataRetriever/MockPhotoDataRetriever.swift b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoDataProvider/PhotoDataRetriever/MockPhotoDataRetriever.swift index 30bf37a..a9c90ef 100644 --- a/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoDataProvider/PhotoDataRetriever/MockPhotoDataRetriever.swift +++ b/SimpleFlickrBrowser/SimpleFlickrBrowserTests/Workers/PhotoDataProvider/PhotoDataRetriever/MockPhotoDataRetriever.swift @@ -11,7 +11,7 @@ class MockDataPhotoRetriever: PhotoDataRetrieving { var retrieveCalls = [URL]() var retrieveResult: Result? - func retrieve(from url: URL, completion: @escaping Completion) { + func retrieve(from url: URL, backgroundDownload _: Bool, completion: @escaping Completion) { guard let result = retrieveResult else { fatalError("\(#function) expectation was not set") }