├── .swiftlint.yml ├── DeezerProject.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ ├── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcuserdata │ │ └── stevencurtis.xcuserdatad │ │ └── UserInterfaceState.xcuserstate └── xcuserdata │ └── stevencurtis.xcuserdatad │ ├── xcdebugger │ └── Breakpoints_v2.xcbkptlist │ └── xcschemes │ └── xcschememanagement.plist ├── DeezerProject ├── AppDelegate.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── Contents.json │ ├── ohdear.imageset │ │ ├── Contents.json │ │ ├── ohdear-1.png │ │ └── ohdear.png │ └── placeholder.imageset │ │ ├── Contents.json │ │ ├── placeholder-1x.png │ │ ├── placeholder-2x.png │ │ └── placeholder-3x.png ├── Base.lproj │ └── LaunchScreen.storyboard ├── Constants.swift ├── CoreData │ ├── DataManager.swift │ ├── ErrorModel.swift │ └── Extensions │ │ └── CodingUserInfoKey.swift ├── Data │ ├── API │ │ ├── APIResponse.swift │ │ ├── BrowseApiService.swift │ │ ├── SearchApiService.swift │ │ └── WrappedData.swift │ ├── Interactors │ │ ├── BrowseInteractor.swift │ │ └── SearchInteractor.swift │ ├── Repository │ │ ├── BrowseRepository.swift │ │ └── SearchRepository.swift │ └── Storage │ │ └── FavouritesStorageService.swift ├── FlowRouting │ └── FlowRoutingService.swift ├── Info.plist ├── Interface │ ├── Factories │ │ └── Screens │ │ │ ├── ArtistFlowScreenFactoryProtocol.swift │ │ │ ├── BrowseFlowScreenFactoryProtocol.swift │ │ │ ├── ScreenFactory.swift │ │ │ ├── SearchFlowScreenFactoryProtocol.swift │ │ │ └── TrackFlowScreenFactoryProtocol.swift │ ├── Flows │ │ ├── ArtistFlow.swift │ │ ├── BrowseFlow.swift │ │ ├── Runner │ │ │ └── FlowRunner.swift │ │ ├── SearchFlow.swift │ │ └── TrackFlow.swift │ └── Screens │ │ ├── Album │ │ ├── AlbumViewController.swift │ │ └── AlbumViewModel.swift │ │ ├── Artist │ │ ├── ArtistViewController.swift │ │ └── ArtistViewModel.swift │ │ ├── Genre │ │ ├── GenreViewController.swift │ │ └── GenreViewModel.swift │ │ ├── GenreList │ │ ├── GenreListViewController.swift │ │ └── GenreListViewModel.swift │ │ ├── Menu │ │ ├── BrowseViewController.swift │ │ └── BrowseViewModel.swift │ │ ├── Search │ │ ├── SearchViewController.swift │ │ └── SearchViewModel.swift │ │ └── Track │ │ ├── TrackViewController.swift │ │ └── TrackViewModel.swift ├── LayoutSection.swift ├── Models │ ├── AlbumSearch │ │ └── AlbumSearch.swift │ ├── ArtistSearch │ │ └── ArtistSearch.swift │ ├── Bridging │ │ ├── DependenciesContainerProtocol.swift │ │ └── ErrorHandlerProtocol.swift │ ├── Chart │ │ ├── Chart.swift │ │ └── DBTrackStorage.swift │ ├── Genre │ │ └── Genre.swift │ └── TrackStoreDto.swift ├── MusicPlayer │ ├── MiniPlayer.swift │ ├── MusicTabBar │ │ └── MusicTabBar.swift │ └── TrackPlayer.swift ├── Resources │ └── DeezerProject.xcdatamodeld │ │ ├── .xccurrentversion │ │ └── DeezerProject.xcdatamodel │ │ └── contents ├── SceneDelegate.swift ├── Sections │ ├── BrowseSection.swift │ ├── GridSection.swift │ ├── LayoutSectionProtocol.swift │ └── SearchSection.swift └── Views │ ├── ButtonModel.swift │ ├── HeaderContent.swift │ ├── SongCollectionViewCell.swift │ └── TitleSupplementaryView.swift ├── DeezerProjectTests ├── DeezerProjectTests.swift └── Info.plist ├── DeezerProjectUITests ├── DeezerProjectUITests.swift └── Info.plist ├── Images ├── architecture.png └── vid.gif └── README.md /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | excluded: 2 | - /Users/stevencurtis/Documents/Interviewprep/MediumPosts/Deezer/DeezerProject/DeezerProject/AppDelegate.swift 3 | -------------------------------------------------------------------------------- /DeezerProject.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /DeezerProject.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /DeezerProject.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "NetworkLibrary", 6 | "repositoryURL": "https://github.com/stevencurtis/NetworkManager", 7 | "state": { 8 | "branch": null, 9 | "revision": "acc2a3eee4392c73cd31be6293e5532e5256c53a", 10 | "version": "0.2.1" 11 | } 12 | }, 13 | { 14 | "package": "SDWebImage", 15 | "repositoryURL": "https://github.com/SDWebImage/SDWebImage.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "a6b6e44eadf0d39250c10a7cc0e3b91d0bdb0e94", 19 | "version": "5.10.4" 20 | } 21 | } 22 | ] 23 | }, 24 | "version": 1 25 | } 26 | -------------------------------------------------------------------------------- /DeezerProject.xcodeproj/project.xcworkspace/xcuserdata/stevencurtis.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevencurtis/DeezerMVVMArchitectureExample/6385b94301fac677a59abf8674125b61ec88f305/DeezerProject.xcodeproj/project.xcworkspace/xcuserdata/stevencurtis.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /DeezerProject.xcodeproj/xcuserdata/stevencurtis.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /DeezerProject.xcodeproj/xcuserdata/stevencurtis.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | DeezerProject.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /DeezerProject/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 26/02/2021. 6 | // 7 | 8 | import UIKit 9 | import CoreData 10 | 11 | @main 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 15 | // Override point for customization after application launch. 16 | return true 17 | } 18 | 19 | // MARK: UISceneSession Lifecycle 20 | 21 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 22 | // Called when a new scene session is being created. 23 | // Use this method to select a configuration to create the new scene with. 24 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 25 | } 26 | 27 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 28 | // Called when the user discards a scene session. 29 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 30 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 31 | } 32 | 33 | // MARK: - Core Data stack 34 | 35 | lazy var persistentContainer: NSPersistentContainer = { 36 | /* 37 | The persistent container for the application. This implementation 38 | creates and returns a container, having loaded the store for the 39 | application to it. This property is optional since there are legitimate 40 | error conditions that could cause the creation of the store to fail. 41 | */ 42 | let container = NSPersistentContainer(name: "DeezerProject") 43 | container.loadPersistentStores(completionHandler: { (_, error) in 44 | if let error = error as NSError? { 45 | // Replace this implementation with code to handle the error appropriately. 46 | // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. 47 | 48 | /* 49 | Typical reasons for an error here include: 50 | * The parent directory does not exist, cannot be created, or disallows writing. 51 | * The persistent store is not accessible, due to permissions or data protection when the device is locked. 52 | * The device is out of space. 53 | * The store could not be migrated to the current model version. 54 | Check the error message to determine what the actual problem was. 55 | */ 56 | fatalError("Unresolved error \(error), \(error.userInfo)") 57 | } 58 | }) 59 | return container 60 | }() 61 | 62 | // MARK: - Core Data Saving support 63 | 64 | func saveContext () { 65 | let context = persistentContainer.viewContext 66 | if context.hasChanges { 67 | do { 68 | try context.save() 69 | } catch { 70 | // Replace this implementation with code to handle the error appropriately. 71 | // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. 72 | let nserror = error as NSError 73 | fatalError("Unresolved error \(nserror), \(nserror.userInfo)") 74 | } 75 | } 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /DeezerProject/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /DeezerProject/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /DeezerProject/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /DeezerProject/Assets.xcassets/ohdear.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "ohdear-1.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "ohdear.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /DeezerProject/Assets.xcassets/ohdear.imageset/ohdear-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevencurtis/DeezerMVVMArchitectureExample/6385b94301fac677a59abf8674125b61ec88f305/DeezerProject/Assets.xcassets/ohdear.imageset/ohdear-1.png -------------------------------------------------------------------------------- /DeezerProject/Assets.xcassets/ohdear.imageset/ohdear.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevencurtis/DeezerMVVMArchitectureExample/6385b94301fac677a59abf8674125b61ec88f305/DeezerProject/Assets.xcassets/ohdear.imageset/ohdear.png -------------------------------------------------------------------------------- /DeezerProject/Assets.xcassets/placeholder.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "placeholder-1x.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "placeholder-2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "placeholder-3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /DeezerProject/Assets.xcassets/placeholder.imageset/placeholder-1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevencurtis/DeezerMVVMArchitectureExample/6385b94301fac677a59abf8674125b61ec88f305/DeezerProject/Assets.xcassets/placeholder.imageset/placeholder-1x.png -------------------------------------------------------------------------------- /DeezerProject/Assets.xcassets/placeholder.imageset/placeholder-2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevencurtis/DeezerMVVMArchitectureExample/6385b94301fac677a59abf8674125b61ec88f305/DeezerProject/Assets.xcassets/placeholder.imageset/placeholder-2x.png -------------------------------------------------------------------------------- /DeezerProject/Assets.xcassets/placeholder.imageset/placeholder-3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevencurtis/DeezerMVVMArchitectureExample/6385b94301fac677a59abf8674125b61ec88f305/DeezerProject/Assets.xcassets/placeholder.imageset/placeholder-3x.png -------------------------------------------------------------------------------- /DeezerProject/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /DeezerProject/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 05/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Constants { 11 | static let entityName = "Track" 12 | } 13 | -------------------------------------------------------------------------------- /DeezerProject/CoreData/DataManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataManager.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 05/03/2021. 6 | // 7 | 8 | import UIKit 9 | import CoreData 10 | 11 | public protocol DataManagerProtocol { 12 | func save(favourite: TrackApiDto, completion: (() -> Void)?) 13 | init() 14 | func getFavourites() throws -> [TrackApiDto] 15 | func delete(favourite: TrackApiDto, completion: (() -> Void)?) throws 16 | } 17 | 18 | final class DataManager: DataManagerProtocol { 19 | private var managedObjectContext: NSManagedObjectContext! = nil 20 | private var entity: NSEntityDescription! = nil 21 | 22 | init (objectContext: NSManagedObjectContext, entity: NSEntityDescription) { 23 | self.managedObjectContext = objectContext 24 | self.entity = entity 25 | } 26 | 27 | required init() { 28 | guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return } 29 | managedObjectContext = appDelegate.persistentContainer.viewContext 30 | managedObjectContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy 31 | 32 | if let entityDescription = NSEntityDescription.entity( 33 | forEntityName: Constants.entityName, 34 | in: managedObjectContext 35 | ) { 36 | entity = entityDescription 37 | } 38 | } 39 | 40 | func getFavourites() throws -> [TrackApiDto] { 41 | let fetchRequest = NSFetchRequest(entityName: DBTrackStorage.entityName) 42 | let track: [DBTrackStorage] 43 | do { 44 | track = try managedObjectContext.fetch(fetchRequest) 45 | } catch let error as NSError { 46 | throw ErrorModel(errorDescription: "Core Data Error. Could not fetch. \(error), \(error.userInfo)") 47 | } 48 | if track.isEmpty {throw ErrorModel(errorDescription: "No Pairs")} 49 | return track.map {$0.toDto()} 50 | } 51 | 52 | func save(favourite: TrackApiDto, completion: (() -> Void)? = nil) { 53 | managedObjectContext.perform { 54 | let object = DBTrackStorage(entity: self.entity, insertInto: self.managedObjectContext) 55 | object.update(from: favourite) 56 | self.saveContext(completion: completion ?? {}) 57 | } 58 | } 59 | 60 | func delete(favourite: TrackApiDto, completion: (() -> Void)?) throws { 61 | let fetchRequest = NSFetchRequest(entityName: DBTrackStorage.entityName) 62 | let track: [DBTrackStorage] 63 | do { 64 | track = try managedObjectContext.fetch(fetchRequest) 65 | } catch let error as NSError { 66 | throw ErrorModel(errorDescription: "Core Data Error. Could not fetch. \(error), \(error.userInfo)") 67 | } 68 | if let object = track.first(where: { $0.id.intValue == favourite.id }) { 69 | self.managedObjectContext.delete(object) 70 | 71 | if let completion = completion { 72 | self.saveContext(completion: completion) 73 | } else { 74 | self.saveContext {} 75 | } 76 | } 77 | } 78 | 79 | func saveContext(completion: @escaping () -> Void) { 80 | managedObjectContext.perform { 81 | do { 82 | try self.managedObjectContext.save() 83 | completion() 84 | } catch let error as NSError { 85 | print("Could not save. \(error), \(error.userInfo)") 86 | } 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /DeezerProject/CoreData/ErrorModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorModel.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 05/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | struct ErrorModel: Error { 11 | var errorDescription: String 12 | } 13 | -------------------------------------------------------------------------------- /DeezerProject/CoreData/Extensions/CodingUserInfoKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodingUserInfoKey.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 05/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | extension CodingUserInfoKey { 11 | static let context = CodingUserInfoKey(rawValue: "context") 12 | } 13 | -------------------------------------------------------------------------------- /DeezerProject/Data/API/APIResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIResponse.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 01/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum ApiResponse { 11 | case success(HTTPURLResponse, T) 12 | case failure(HTTPURLResponse?, ApiError) 13 | 14 | public var result: Result { 15 | switch self { 16 | case .success(_, let value): 17 | return .success(value) 18 | case .failure(_, let error): 19 | return .failure(error) 20 | } 21 | } 22 | } 23 | 24 | public enum ApiError: Error { 25 | var localizedDescription: String { 26 | switch self { 27 | case .network(errorMessage: let error): 28 | return error 29 | case .generic: 30 | return "error" 31 | } 32 | } 33 | case network(errorMessage: String) 34 | case generic 35 | } 36 | -------------------------------------------------------------------------------- /DeezerProject/Data/API/BrowseApiService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BrowseApiService.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 01/03/2021. 6 | // 7 | 8 | import Foundation 9 | import NetworkLibrary 10 | 11 | protocol BrowseApiServiceProtocol { 12 | func getGenre(completion: @escaping (ApiResponse<[GenreApiDto]>) -> Void) 13 | func getChart(completion: @escaping (ApiResponse) -> Void) 14 | func getGenreArtists(identifier: String, completion: @escaping (ApiResponse<[ArtistSearchApiDto]>) -> Void) 15 | func getTracklist(identifier: String, completion: @escaping (ApiResponse<[TrackApiDto]>) -> Void) 16 | func getAlbumList(urlString: String, completion: @escaping (ApiResponse<[TrackApiDto]>) -> Void) 17 | } 18 | 19 | final class BrowseApiService { 20 | private var anyNetworkManager: AnyNetworkManager? 21 | 22 | init() { 23 | self.anyNetworkManager = AnyNetworkManager() 24 | } 25 | 26 | init( 27 | networkManager: T 28 | ) { 29 | self.anyNetworkManager = AnyNetworkManager(manager: networkManager) 30 | } 31 | } 32 | 33 | extension BrowseApiService: BrowseApiServiceProtocol { 34 | func getGenre(completion: @escaping (ApiResponse<[GenreApiDto]>) -> Void) { 35 | guard let url = URL(string: "https://api.deezer.com/genre") else {return} 36 | anyNetworkManager?.fetch( 37 | url: url, 38 | method: .get(), 39 | completionBlock: {result in 40 | if let res = try? result.get() { 41 | let decoder = JSONDecoder() 42 | decoder.keyDecodingStrategy = .convertFromSnakeCase 43 | if let decoded = try? decoder.decode(WrappedData<[GenreApiDto]>.self, from: res) { 44 | completion(.success(.init(), decoded.data)) 45 | return 46 | } 47 | } 48 | completion(.failure(nil, ApiError.network(errorMessage: "Decode error"))) 49 | } 50 | ) 51 | } 52 | 53 | func getChart(completion: @escaping (ApiResponse) -> Void) { 54 | guard let url = URL(string: "https://api.deezer.com/chart") else {return} 55 | anyNetworkManager?.fetch( 56 | url: url, 57 | method: .get(), 58 | completionBlock: {result in 59 | if let res = try? result.get() { 60 | let decoder = JSONDecoder() 61 | decoder.keyDecodingStrategy = .convertFromSnakeCase 62 | if let decoded = try? decoder.decode(ChartApiDto.self, from: res) { 63 | completion(.success(.init(), decoded)) 64 | return 65 | } 66 | } 67 | completion(.failure(nil, ApiError.network(errorMessage: "Decode error"))) 68 | } 69 | ) 70 | } 71 | 72 | func getGenreArtists(identifier: String, completion: @escaping (ApiResponse<[ArtistSearchApiDto]>) -> Void) { 73 | guard let url = URL(string: "https://api.deezer.com/genre/\(identifier)/artists") else {return} 74 | anyNetworkManager?.fetch( 75 | url: url, 76 | method: .get(), 77 | completionBlock: {result in 78 | if let res = try? result.get() { 79 | let decoder = JSONDecoder() 80 | decoder.keyDecodingStrategy = .convertFromSnakeCase 81 | if let decoded = try? decoder.decode(WrappedData<[ArtistSearchApiDto]>.self, from: res) { 82 | completion(.success(.init(), decoded.data)) 83 | return 84 | } 85 | } 86 | completion(.failure(nil, ApiError.network(errorMessage: "Decode error"))) 87 | } 88 | ) 89 | } 90 | 91 | func getTracklist(identifier: String, completion: @escaping (ApiResponse<[TrackApiDto]>) -> Void) { 92 | guard let url = URL(string: "https://api.deezer.com/artist/\(identifier)/top?limit=50") else {return} 93 | anyNetworkManager?.fetch( 94 | url: url, 95 | method: .get(), 96 | completionBlock: {result in 97 | if let res = try? result.get() { 98 | let decoder = JSONDecoder() 99 | decoder.keyDecodingStrategy = .convertFromSnakeCase 100 | if let decoded = try? decoder.decode(WrappedData<[TrackApiDto]>.self, from: res) { 101 | completion(.success(.init(), decoded.data)) 102 | return 103 | } 104 | } 105 | completion(.failure(nil, ApiError.network(errorMessage: "Decode error"))) 106 | } 107 | ) 108 | } 109 | 110 | func getAlbumList(urlString: String, completion: @escaping (ApiResponse<[TrackApiDto]>) -> Void) { 111 | guard let url = URL(string: urlString) else {return} 112 | anyNetworkManager?.fetch( 113 | url: url, 114 | method: .get(), 115 | completionBlock: {result in 116 | if let res = try? result.get() { 117 | let decoder = JSONDecoder() 118 | decoder.keyDecodingStrategy = .convertFromSnakeCase 119 | if let decoded = try? decoder.decode(WrappedData<[TrackApiDto]>.self, from: res) { 120 | completion(.success(.init(), decoded.data)) 121 | return 122 | } 123 | } 124 | completion(.failure(nil, ApiError.network(errorMessage: "Decode error"))) 125 | } 126 | ) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /DeezerProject/Data/API/SearchApiService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchApiService.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 04/03/2021. 6 | // 7 | 8 | import Foundation 9 | import NetworkLibrary 10 | 11 | protocol SearchApiServiceProtocol { 12 | func searchAlbum(query: String, completion: @escaping (ApiResponse<[AlbumSearchApiDto]>) -> Void) 13 | func searchArtist(query: String, completion: @escaping (ApiResponse<[ArtistSearchApiDto]>) -> Void) 14 | func searchTrack(query: String, completion: @escaping (ApiResponse<[TrackApiDto]>) -> Void) 15 | } 16 | 17 | final class SearchApiService { 18 | private var anyNetworkManager: AnyNetworkManager? 19 | 20 | init() { 21 | self.anyNetworkManager = AnyNetworkManager() 22 | } 23 | 24 | init( 25 | networkManager: T 26 | ) { 27 | self.anyNetworkManager = AnyNetworkManager(manager: networkManager) 28 | } 29 | } 30 | 31 | extension SearchApiService: SearchApiServiceProtocol { 32 | func searchAlbum(query: String, completion: @escaping (ApiResponse<[AlbumSearchApiDto]>) -> Void) { 33 | anyNetworkManager?.fetch( 34 | url: URL(string: "https://api.deezer.com/search/album?q=\(query)")!, 35 | method: .get(), 36 | completionBlock: {result in 37 | if let res = try? result.get() { 38 | let decoder = JSONDecoder() 39 | decoder.keyDecodingStrategy = .convertFromSnakeCase 40 | if let decoded = try? decoder.decode(WrappedData<[AlbumSearchApiDto]>.self, from: res) { 41 | completion(.success(.init(), decoded.data)) 42 | return 43 | } 44 | } 45 | completion(.failure(nil, ApiError.network(errorMessage: "Decode error"))) 46 | } 47 | ) 48 | } 49 | 50 | func searchArtist(query: String, completion: @escaping (ApiResponse<[ArtistSearchApiDto]>) -> Void) { 51 | anyNetworkManager?.fetch( 52 | url: URL(string: "https://api.deezer.com/search/artist?q=\(query)")!, 53 | method: .get(), 54 | completionBlock: {result in 55 | if let res = try? result.get() { 56 | let decoder = JSONDecoder() 57 | decoder.keyDecodingStrategy = .convertFromSnakeCase 58 | if let decoded = try? decoder.decode(WrappedData<[ArtistSearchApiDto]>.self, from: res) { 59 | completion(.success(.init(), decoded.data)) 60 | return 61 | } 62 | } 63 | completion(.failure(nil, ApiError.network(errorMessage: "Decode error"))) 64 | } 65 | ) 66 | } 67 | 68 | func searchTrack(query: String, completion: @escaping (ApiResponse<[TrackApiDto]>) -> Void) { 69 | anyNetworkManager?.fetch( 70 | url: URL(string: "https://api.deezer.com/search/track?q=\(query)")!, 71 | method: .get(), 72 | completionBlock: {result in 73 | if let res = try? result.get() { 74 | let decoder = JSONDecoder() 75 | decoder.keyDecodingStrategy = .convertFromSnakeCase 76 | if let decoded = try? decoder.decode(WrappedData<[TrackApiDto]>.self, from: res) { 77 | completion(.success(.init(), decoded.data)) 78 | return 79 | } 80 | } 81 | completion(.failure(nil, ApiError.network(errorMessage: "Decode error"))) 82 | } 83 | ) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /DeezerProject/Data/API/WrappedData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ApiResponseWrappedData.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 01/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | struct WrappedData: Decodable { 11 | let data: T 12 | } 13 | -------------------------------------------------------------------------------- /DeezerProject/Data/Interactors/BrowseInteractor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BrowseInteractor.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 01/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol BrowseInteractorProtocol { 11 | func getGenre(completion: @escaping (Result<[Genre], Error>) -> Void) 12 | func getChart(completion: @escaping (Result) -> Void) 13 | func getGenreAndGetChart(completion: @escaping (Result<([Genre], Chart?), Error>) -> Void) 14 | func getGenreArtists(identifier: String, completion: @escaping (Result<[ArtistSearch], Error>) -> Void) 15 | func getTracklist(identifier: String, completion: @escaping (Result<[Track], Error>) -> Void) 16 | func saveTrack(track: Track, completion: (() -> Void)?) 17 | func deleteTrack(track: Track, completion: (() -> Void)?) 18 | func getTracks() -> [Track] 19 | func getAlbumTracks(urlString: String, completion: @escaping (Result<[Track], Error>) -> Void) 20 | } 21 | 22 | final class BrowseInteractor { 23 | private let browseRepository: BrowseRepositoryProtocol 24 | init ( 25 | browseRepository: BrowseRepositoryProtocol = BrowseRepository() 26 | ) { 27 | self.browseRepository = browseRepository 28 | } 29 | } 30 | 31 | extension BrowseInteractor: BrowseInteractorProtocol { 32 | func getAlbumTracks(urlString: String, completion: @escaping (Result<[Track], Error>) -> Void) { 33 | browseRepository.getAlbumTracklist(urlString: urlString, completion: completion) 34 | } 35 | 36 | func getTracks() -> [Track] { 37 | return browseRepository.getTracks() 38 | } 39 | 40 | func saveTrack(track: Track, completion: (() -> Void)?) { 41 | browseRepository.createOrUpdate(favourite: track, completion: completion) 42 | } 43 | 44 | func deleteTrack(track: Track, completion: (() -> Void)?) { 45 | browseRepository.deleteTrack(track: track, completion: completion) 46 | } 47 | 48 | func getTracklist(identifier: String, completion: @escaping (Result<[Track], Error>) -> Void) { 49 | browseRepository.getTracklist(identifier: identifier, completion: completion) 50 | } 51 | 52 | func getGenreArtists(identifier: String, completion: @escaping (Result<[ArtistSearch], Error>) -> Void) { 53 | browseRepository.getGenreArtists(identifier: identifier, completion: completion) 54 | } 55 | 56 | func getGenreAndGetChart(completion: @escaping (Result<([Genre], Chart?), Error>) -> Void) { 57 | let dispatchGroup = DispatchGroup() 58 | var genre: Result<[Genre], Error>? 59 | var chart: Result? 60 | dispatchGroup.enter() 61 | getGenre(completion: {genre = $0; dispatchGroup.leave() }) 62 | dispatchGroup.enter() 63 | getChart(completion: {chart = $0; dispatchGroup.leave() }) 64 | 65 | dispatchGroup.notify(queue: .main) { 66 | switch(genre, chart) { 67 | case let (.success(genre), .success(chart)): 68 | completion(.success((genre, chart))) 69 | case let (.failure, .success(chart)): 70 | completion(.success(([], chart))) 71 | case let (.success(genre), .failure): 72 | completion(.success((genre, nil))) 73 | default: 74 | completion(.failure(ApiError.generic)) 75 | } 76 | } 77 | } 78 | 79 | func getGenre(completion: @escaping (Result<[Genre], Error>) -> Void) { 80 | browseRepository.getGenre(completion: completion) 81 | } 82 | 83 | func getChart(completion: @escaping (Result) -> Void) { 84 | browseRepository.getChart(completion: completion) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /DeezerProject/Data/Interactors/SearchInteractor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchInteractor.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 04/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol SearchInteractorProtocol { 11 | func getSearchAlbum(query: String, completion: @escaping (Result<[AlbumSearch], Error>) -> Void) 12 | func getSearchTrack(query: String, completion: @escaping (Result<[Track], Error>) -> Void) 13 | func getSearchArtist(query: String, completion: @escaping (Result<[ArtistSearch], Error>) -> Void) 14 | func getSearchResults(query: String, completion: @escaping (Result<([AlbumSearch], [Track], [ArtistSearch]), Error>) -> Void) 15 | func getTracklist(urlString: String, completion: @escaping (Result<[Track], Error>) -> Void) 16 | } 17 | 18 | class SearchInteractor { 19 | private let searchRepository: SearchRespositoryProtocol 20 | private let browseRepository: BrowseRepositoryProtocol 21 | 22 | init( 23 | searchRepository: SearchRespositoryProtocol = SearchRespository(), 24 | browseRepository: BrowseRepositoryProtocol = BrowseRepository() 25 | ) { 26 | self.searchRepository = searchRepository 27 | self.browseRepository = browseRepository 28 | } 29 | } 30 | 31 | extension SearchInteractor: SearchInteractorProtocol { 32 | func getTracklist(urlString: String, completion: @escaping (Result<[Track], Error>) -> Void) { 33 | browseRepository.getAlbumTracklist(urlString: urlString, completion: completion) 34 | } 35 | 36 | func getSearchResults( 37 | query: String, 38 | // swiftlint:disable large_tuple 39 | completion: @escaping (Result<([AlbumSearch], [Track], [ArtistSearch]), Error>) -> Void 40 | ) { 41 | let dispatchGroup = DispatchGroup() 42 | 43 | var album: Result<[AlbumSearch], Error>? 44 | var track: Result<[Track], Error>? 45 | var artist: Result<[ArtistSearch], Error>? 46 | 47 | dispatchGroup.enter() 48 | getSearchAlbum(query: query, completion: { album = $0; dispatchGroup.leave() }) 49 | dispatchGroup.enter() 50 | getSearchTrack(query: query, completion: { track = $0; dispatchGroup.leave() }) 51 | dispatchGroup.enter() 52 | getSearchArtist(query: query, completion: { artist = $0; dispatchGroup.leave() }) 53 | 54 | dispatchGroup.notify(queue: .main) { 55 | switch(album, track, artist) { 56 | case let (.success(album), .success(track), .success(artist)): 57 | completion(.success((album, track, artist))) 58 | case let (.failure, .success(track), .success(artist)): 59 | completion(.success(([], track, artist))) 60 | case let (.success(album), .failure, .success(artist)): 61 | completion(.success((album, [], artist))) 62 | case let (.success(album), .success(track), .failure): 63 | completion(.success((album, track, []))) 64 | case let (.success(album), .failure, .failure): 65 | completion(.success((album, [], []))) 66 | case let (.failure, .success(track), .failure): 67 | completion(.success(([], track, []))) 68 | case let (.failure, .failure, .success(artist)): 69 | completion(.success(([], [], artist))) 70 | default: 71 | completion(.failure(ApiError.generic)) 72 | } 73 | } 74 | } 75 | 76 | func getSearchAlbum(query: String, completion: @escaping (Result<[AlbumSearch], Error>) -> Void) { 77 | searchRepository.getSearchAlbum(query: query, completion: completion) 78 | } 79 | 80 | func getSearchTrack(query: String, completion: @escaping (Result<[Track], Error>) -> Void) { 81 | searchRepository.getSearchTrack(query: query, completion: completion) 82 | } 83 | 84 | func getSearchArtist(query: String, completion: @escaping (Result<[ArtistSearch], Error>) -> Void) { 85 | searchRepository.getSearchArtist(query: query, completion: completion) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /DeezerProject/Data/Repository/BrowseRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BrowseRepository.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 01/03/2021. 6 | // 7 | 8 | import Foundation 9 | import NetworkLibrary 10 | 11 | protocol BrowseRepositoryProtocol { 12 | func getGenre(completion: @escaping (Result<[Genre], Error>) -> Void) 13 | func getChart(completion: @escaping (Result) -> Void) 14 | func getGenreArtists(identifier: String, completion: @escaping (Result<[ArtistSearch], Error>) -> Void) 15 | func getTracklist(identifier: String, completion: @escaping (Result<[Track], Error>) -> Void) 16 | func createOrUpdate(favourite: Track, completion: (() -> Void)?) 17 | func getTracks() -> [Track] 18 | func deleteTrack(track: Track, completion: (() -> Void)?) 19 | func getAlbumTracklist(urlString: String, completion: @escaping (Result<[Track], Error>) -> Void) 20 | } 21 | 22 | final class BrowseRepository { 23 | private let apiService: BrowseApiServiceProtocol 24 | private let storageService: FavouritesStorageServiceProtocol 25 | 26 | init( 27 | apiService: BrowseApiServiceProtocol = BrowseApiService(), 28 | storageService: FavouritesStorageServiceProtocol = FavouritesStorageService() 29 | ) { 30 | self.apiService = apiService 31 | self.storageService = storageService 32 | } 33 | } 34 | 35 | extension BrowseRepository: BrowseRepositoryProtocol { 36 | func deleteTrack(track: Track, completion: (() -> Void)?) { 37 | storageService.delete(track: updateApiDtoFavouriteFrom(favourite: track), completion: completion) 38 | } 39 | 40 | func getTracks() -> [Track] { 41 | return storageService.getFavourites().map { $0.toDomain() } 42 | } 43 | 44 | private func updateApiDtoFavouriteFrom(favourite: Track) -> TrackApiDto { 45 | guard let artistID = favourite.artist?.id, let artistName = favourite.artist?.name else { 46 | return .init( 47 | id: favourite.id, 48 | title: favourite.title, 49 | titleShort: favourite.titleShort, 50 | titleVersion: favourite.titleVersion, 51 | link: favourite.link, 52 | duration: favourite.duration, 53 | rank: favourite.rank, 54 | explicitLyrics: favourite.explicitLyrics, 55 | explicitContentLyrics: favourite.explicitContentLyrics, 56 | explicitContentCover: favourite.explicitContentCover, 57 | preview: favourite.preview, 58 | md5Image: favourite.md5Image, 59 | position: favourite.position, 60 | artist: nil 61 | ) 62 | } 63 | return .init( 64 | id: favourite.id, 65 | title: favourite.title, 66 | titleShort: favourite.titleShort, 67 | titleVersion: favourite.titleVersion, 68 | link: favourite.link, 69 | duration: favourite.duration, 70 | rank: favourite.rank, 71 | explicitLyrics: favourite.explicitLyrics, 72 | explicitContentLyrics: favourite.explicitContentLyrics, 73 | explicitContentCover: favourite.explicitContentCover, 74 | preview: favourite.preview, 75 | md5Image: favourite.md5Image, 76 | position: favourite.position, 77 | artist: .init( 78 | id: artistID, 79 | name: artistName, 80 | link: favourite.artist?.link, 81 | picture: favourite.artist?.picture, 82 | pictureSmall: favourite.artist?.pictureSmall, 83 | pictureMedium: favourite.artist?.pictureMedium, 84 | pictureBig: favourite.artist?.pictureBig, 85 | pictureXl: favourite.artist?.pictureXl, 86 | radio: favourite.artist?.radio 87 | ) 88 | ) 89 | } 90 | 91 | // swiftlint:disable:next function_body_length 92 | func createOrUpdate(favourite: Track, completion: (() -> Void)?) { 93 | storageService.save(favourite: updateApiDtoFavouriteFrom(favourite: favourite), completion: completion) 94 | } 95 | 96 | func getGenreArtists(identifier: String, completion: @escaping (Result<[ArtistSearch], Error>) -> Void) { 97 | apiService.getGenreArtists(identifier: identifier, completion: { apiResponse in 98 | switch apiResponse.result { 99 | case .success(let dto): 100 | completion(.success(dto.compactMap {$0.toDomain()}) ) 101 | case .failure(let error): 102 | completion(.failure(error)) 103 | } 104 | } 105 | ) 106 | } 107 | 108 | func getGenre(completion: @escaping (Result<[Genre], Error>) -> Void) { 109 | apiService.getGenre(completion: { apiReponse in 110 | switch apiReponse.result { 111 | case .success(let dto): 112 | completion(.success(dto.compactMap {$0.toDomain()})) 113 | case .failure(let error): 114 | completion(.failure(error)) 115 | } 116 | } 117 | ) 118 | } 119 | 120 | func getChart(completion: @escaping (Result) -> Void) { 121 | apiService.getChart(completion: {apiResponse in 122 | switch apiResponse.result { 123 | case .success(let dto): 124 | completion(.success(dto.toDomain())) 125 | case .failure(let error): 126 | completion(.failure(error)) 127 | } 128 | } 129 | ) 130 | } 131 | 132 | func getTracklist(identifier: String, completion: @escaping (Result<[Track], Error>) -> Void) { 133 | apiService.getTracklist(identifier: identifier, completion: {apiResponse in 134 | switch apiResponse.result { 135 | case .success(let dto): 136 | completion(.success(dto.compactMap {$0.toDomain()})) 137 | case .failure(let error): 138 | completion(.failure(error)) 139 | } 140 | } 141 | ) 142 | } 143 | 144 | func getAlbumTracklist(urlString: String, completion: @escaping (Result<[Track], Error>) -> Void) { 145 | apiService.getAlbumList(urlString: urlString, completion: {apiResponse in 146 | switch apiResponse.result { 147 | case .success(let dto): 148 | completion(.success(dto.compactMap {$0.toDomain()})) 149 | case .failure(let error): 150 | completion(.failure(error)) 151 | } 152 | } 153 | ) 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /DeezerProject/Data/Repository/SearchRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchRespository.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 04/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol SearchRespositoryProtocol { 11 | func getSearchAlbum(query: String, completion: @escaping (Result<[AlbumSearch], Error>) -> Void) 12 | func getSearchArtist(query: String, completion: @escaping (Result<[ArtistSearch], Error>) -> Void) 13 | func getSearchTrack(query: String, completion: @escaping (Result<[Track], Error>) -> Void) 14 | } 15 | 16 | final class SearchRespository { 17 | private let apiService: SearchApiServiceProtocol 18 | 19 | init( 20 | apiService: SearchApiServiceProtocol = SearchApiService() 21 | ) { 22 | self.apiService = apiService 23 | } 24 | } 25 | 26 | extension SearchRespository: SearchRespositoryProtocol { 27 | func getSearchAlbum(query: String, completion: @escaping (Result<[AlbumSearch], Error>) -> Void) { 28 | apiService.searchAlbum(query: query, completion: { apiResponse in 29 | switch apiResponse.result { 30 | case .success(let dto): 31 | completion(.success(dto.compactMap {$0.toDomain()})) 32 | case .failure(let error): 33 | completion(.failure(error)) 34 | } 35 | } 36 | ) 37 | } 38 | 39 | func getSearchArtist(query: String, completion: @escaping (Result<[ArtistSearch], Error>) -> Void) { 40 | apiService.searchArtist(query: query, completion: { apiResponse in 41 | switch apiResponse.result { 42 | case .success(let dto): 43 | completion(.success(dto.compactMap {$0.toDomain()})) 44 | case .failure(let error): 45 | completion(.failure(error)) 46 | } 47 | } 48 | ) 49 | } 50 | 51 | func getSearchTrack(query: String, completion: @escaping (Result<[Track], Error>) -> Void) { 52 | apiService.searchTrack(query: query, completion: { apiResponse in 53 | switch apiResponse.result { 54 | case .success(let dto): 55 | completion(.success(dto.compactMap {$0.toDomain()})) 56 | case .failure(let error): 57 | completion(.failure(error)) 58 | } 59 | } 60 | ) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /DeezerProject/Data/Storage/FavouritesStorageService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FavouritesStorage.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 05/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol FavouritesStorageServiceProtocol { 11 | func save(favourite: TrackApiDto, completion: (() -> Void)?) 12 | func getFavourites() -> [TrackApiDto] 13 | func delete(track: TrackApiDto, completion: (() -> Void)?) 14 | } 15 | 16 | final class FavouritesStorageService { 17 | private let dataManager: DataManager 18 | 19 | init(dataManager: DataManager = DataManager()) { 20 | self.dataManager = dataManager 21 | } 22 | } 23 | extension FavouritesStorageService: FavouritesStorageServiceProtocol { 24 | func getFavourites() -> [TrackApiDto] { 25 | let favourites = try? dataManager.getFavourites() 26 | return favourites ?? [] 27 | } 28 | 29 | func save(favourite: TrackApiDto, completion: (() -> Void)?) { 30 | dataManager.save(favourite: favourite, completion: completion) 31 | } 32 | 33 | func delete(track: TrackApiDto, completion: (() -> Void)?) { 34 | try? dataManager.delete(favourite: track, completion: completion) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /DeezerProject/FlowRouting/FlowRoutingService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FlowRoutingService.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 26/02/2021. 6 | // 7 | 8 | import UIKit 9 | 10 | public protocol FlowRoutingServiceProtocol { 11 | var rootViewController: UIViewController { get } 12 | var visibleViewController: UIViewController { get } 13 | func showPushed(_ controller: UIViewController) 14 | func clearStackInBetween(left: UIViewController, right: UIViewController) 15 | func clearStackAndShowPushed(_ controllers: [UIViewController]) 16 | func clearLastAndShowPushed(_ controller: UIViewController) 17 | func jumpBack(to controller: UIViewController?) 18 | func jumpBack(to controller: UIViewController, andPush next: UIViewController) 19 | func jumpBackToRoot() 20 | func replace(viewController: UIViewController, with controller: UIViewController) 21 | func showModel( 22 | _ controller: UIViewController, 23 | modelPresentationStyle: UIModalPresentationStyle?, 24 | closeHandler: (() -> Void)? 25 | ) 26 | func showMusicBar() 27 | func hideMusicBar() 28 | } 29 | 30 | public final class FlowRoutingService { 31 | public let navigationController: UINavigationController 32 | public let tabController: MusicTabBar 33 | 34 | public init(navigationController: UINavigationController, tabController: MusicTabBar) { 35 | self.navigationController = navigationController 36 | self.tabController = tabController 37 | } 38 | } 39 | 40 | extension FlowRoutingService: FlowRoutingServiceProtocol { 41 | public func hideMusicBar() { 42 | tabController.hideMusicPlayer() 43 | } 44 | 45 | public func showMusicBar() { 46 | tabController.showMusicPlayer() 47 | } 48 | 49 | public var rootViewController: UIViewController { 50 | return navigationController.viewControllers.first! 51 | } 52 | 53 | public var visibleViewController: UIViewController { 54 | return navigationController.visibleViewController ?? navigationController.viewControllers.last! 55 | } 56 | 57 | public func clearStackInBetween(left: UIViewController, right: UIViewController) { 58 | let navigationController = 59 | self.navigationController.visibleViewController?.navigationController ?? self.navigationController 60 | let currentStack = navigationController.viewControllers 61 | guard let leftIndex = currentStack.firstIndex(of: left), 62 | let rightIndex = currentStack.firstIndex(of: right), 63 | leftIndex < rightIndex 64 | else { return } 65 | let newStack = Array(currentStack.prefix(leftIndex + 1)) + [right] 66 | clearStackAndShowPushed(newStack) 67 | } 68 | 69 | public func clearStackAndShowPushed(_ controllers: [UIViewController]) { 70 | if let visibleViewController = navigationController.visibleViewController { 71 | visibleViewController.navigationController?.setViewControllers(controllers, animated: true) 72 | } else { 73 | navigationController.setViewControllers(controllers, animated: false) 74 | } 75 | } 76 | 77 | public func showPushed(_ controller: UIViewController) { 78 | if let visibleViewController = navigationController.visibleViewController { 79 | visibleViewController.navigationController?.pushViewController( 80 | controller, 81 | animated: true 82 | ) 83 | } else { 84 | navigationController.pushViewController(controller, animated: false) 85 | } 86 | } 87 | 88 | public func clearLastAndShowPushed(_ controller: UIViewController) { 89 | let navigationController: UINavigationController? = { 90 | if let visibleViewController = self.navigationController.visibleViewController { 91 | return visibleViewController.navigationController 92 | } else { 93 | return self.navigationController 94 | } 95 | }() 96 | guard let nvc = navigationController else { return } 97 | 98 | nvc.setViewControllers(Array(nvc.viewControllers.dropLast()) + [controller], animated: true) 99 | } 100 | 101 | public func jumpBack(to controller: UIViewController?) { 102 | if let controller = controller { 103 | if let topNav = visibleViewController.navigationController, 104 | topNav.viewControllers.contains(controller) { 105 | topNav.popToViewController(controller, animated: true) 106 | } else { 107 | navigationController.popToViewController(controller, animated: true) 108 | } 109 | } else { 110 | visibleViewController.navigationController?.popViewController(animated: true) 111 | } 112 | } 113 | 114 | public func jumpBack(to controller: UIViewController, andPush next: UIViewController) { 115 | guard let nav = visibleViewController.navigationController, 116 | let index = nav.viewControllers.firstIndex(of: controller) 117 | else { 118 | return 119 | } 120 | let stack = Array(nav.viewControllers.dropLast(nav.viewControllers.count - index - 1)) + [next] 121 | nav.setViewControllers(stack, animated: true) 122 | } 123 | 124 | public func jumpBackToRoot() { 125 | let navigationController = visibleViewController.navigationController ?? self.navigationController 126 | navigationController.popToRootViewController(animated: true) 127 | } 128 | 129 | public func replace(viewController: UIViewController, with controller: UIViewController) { 130 | guard 131 | let navigationController = visibleViewController.navigationController, 132 | let index = navigationController.viewControllers.firstIndex(of: viewController) 133 | else { 134 | return 135 | } 136 | navigationController.viewControllers[index] = controller 137 | } 138 | 139 | public func showModel( 140 | _ controller: UIViewController, 141 | modelPresentationStyle: UIModalPresentationStyle?, 142 | closeHandler: (() -> Void)? 143 | ) { 144 | if controller is UINavigationController 145 | || controller is UIAlertController { 146 | visibleViewController.present(controller, animated: true) 147 | } else { 148 | let navigationController = UINavigationController(rootViewController: controller) 149 | if let modalPresentationStyle = modelPresentationStyle { 150 | navigationController.modalPresentationStyle = modalPresentationStyle 151 | } 152 | controller.navigationItem.leftBarButtonItem = UIBarButtonItem( 153 | title: "close", 154 | style: .done, 155 | target: nil, 156 | action: nil 157 | ) 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /DeezerProject/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UISceneConfigurationName 33 | Default Configuration 34 | UISceneDelegateClassName 35 | $(PRODUCT_MODULE_NAME).SceneDelegate 36 | 37 | 38 | 39 | 40 | UIApplicationSupportsIndirectInputEvents 41 | 42 | UIBackgroundModes 43 | 44 | audio 45 | 46 | UILaunchStoryboardName 47 | LaunchScreen 48 | UIRequiredDeviceCapabilities 49 | 50 | armv7 51 | 52 | UISupportedInterfaceOrientations 53 | 54 | UIInterfaceOrientationPortrait 55 | 56 | UISupportedInterfaceOrientations~ipad 57 | 58 | UIInterfaceOrientationPortrait 59 | UIInterfaceOrientationPortraitUpsideDown 60 | UIInterfaceOrientationLandscapeLeft 61 | UIInterfaceOrientationLandscapeRight 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /DeezerProject/Interface/Factories/Screens/ArtistFlowScreenFactoryProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArtistFlowScreenFactoryProtocol.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 10/03/2021. 6 | // 7 | 8 | import UIKit 9 | 10 | public protocol ArtistFlowScreenFactoryProtocol { 11 | func makeArtistScreen(flow: ArtistFlowProtocol, artist: ArtistSearch) -> UIViewController 12 | } 13 | -------------------------------------------------------------------------------- /DeezerProject/Interface/Factories/Screens/BrowseFlowScreenFactoryProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BrowseFlowScreenFactory.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 01/03/2021. 6 | // 7 | 8 | import UIKit 9 | 10 | public protocol BrowseFlowScreenFactoryProtocol { 11 | func makeBrowseScreen(flow: BrowseFlowProtocol) -> UIViewController 12 | func makeGenreListScreen(flow: BrowseFlowProtocol, data: [BrowseSectionData]) -> UIViewController 13 | func makeGenreScreen(flow: BrowseFlowProtocol, genre: Genre) -> UIViewController 14 | } 15 | -------------------------------------------------------------------------------- /DeezerProject/Interface/Factories/Screens/ScreenFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScreenFactory.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 26/02/2021. 6 | // 7 | 8 | import UIKit 9 | 10 | struct ScreenFactory {} 11 | 12 | extension ScreenFactory: BrowseFlowScreenFactoryProtocol { 13 | func makeGenreScreen(flow: BrowseFlowProtocol, genre: Genre) -> UIViewController { 14 | let viewModel = GenreViewModel(flow: flow, genre: genre) 15 | let viewController = GenreViewController(viewModel: viewModel) 16 | return viewController 17 | } 18 | 19 | func makeGenreListScreen(flow: BrowseFlowProtocol, data: [BrowseSectionData]) -> UIViewController { 20 | let viewModel = GenreListViewModel(flow: flow, data: data) 21 | let viewController = GenreListViewController(viewModel: viewModel) 22 | return viewController 23 | } 24 | 25 | func makeBrowseScreen(flow: BrowseFlowProtocol) -> UIViewController { 26 | let viewModel = BrowseViewModel(flow: flow) 27 | let viewController = BrowseViewController(viewModel: viewModel) 28 | return viewController 29 | } 30 | } 31 | 32 | extension ScreenFactory: SearchFlowScreenFactoryProtocol { 33 | func makeSearchScreen(flow: SearchFlowProtocol) -> UIViewController { 34 | let viewModel = SearchViewModel(flow: flow) 35 | let viewController = SearchViewController(viewModel: viewModel) 36 | return viewController 37 | } 38 | 39 | func makeAlbumScreen(flow: SearchFlowProtocol, album: AlbumSearch) -> UIViewController { 40 | let viewModel = AlbumViewModel(flow: flow, album: album) 41 | let viewController = AlbumViewController(viewModel: viewModel) 42 | return viewController 43 | } 44 | } 45 | 46 | extension ScreenFactory: ArtistFlowScreenFactoryProtocol { 47 | func makeArtistScreen(flow: ArtistFlowProtocol, artist: ArtistSearch) -> UIViewController { 48 | let viewModel = ArtistViewModel(flow: flow, artist: artist) 49 | let viewController = ArtistViewController(viewModel: viewModel) 50 | return viewController 51 | } 52 | } 53 | 54 | extension ScreenFactory: TrackFlowScreenFactoryProtocol { 55 | func makeTrackScreen(flow: TrackFlowProtocol, track: Track, image: String?) -> UIViewController { 56 | let viewModel = TrackViewModel(flow: flow, track: track, image: image) 57 | let viewController = TrackViewController(viewModel: viewModel) 58 | return viewController 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /DeezerProject/Interface/Factories/Screens/SearchFlowScreenFactoryProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchFlowScreenFactoryProtocol.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 04/03/2021. 6 | // 7 | 8 | import UIKit 9 | 10 | public protocol SearchFlowScreenFactoryProtocol { 11 | func makeSearchScreen(flow: SearchFlowProtocol) -> UIViewController 12 | func makeAlbumScreen(flow: SearchFlowProtocol, album: AlbumSearch) -> UIViewController 13 | } 14 | -------------------------------------------------------------------------------- /DeezerProject/Interface/Factories/Screens/TrackFlowScreenFactoryProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TrackFlowScreenFactoryProtocol.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 10/03/2021. 6 | // 7 | 8 | import UIKit 9 | 10 | public protocol TrackFlowScreenFactoryProtocol { 11 | func makeTrackScreen(flow: TrackFlowProtocol, track: Track, image: String?) -> UIViewController 12 | } 13 | -------------------------------------------------------------------------------- /DeezerProject/Interface/Flows/ArtistFlow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArtistFlow.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 10/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol ArtistFlowProtocol { 11 | func showArtist(artist: ArtistSearch) 12 | func runFlow(with artist: ArtistSearch) 13 | func showTrack(track: Track, image: String) 14 | } 15 | 16 | final class ArtistFlow { 17 | private let router: FlowRoutingServiceProtocol 18 | private let screenFactory: ArtistFlowScreenFactoryProtocol 19 | private let flowRunner: FlowRunnerProtocol 20 | 21 | init( 22 | router: FlowRoutingServiceProtocol, 23 | screenFactory: ArtistFlowScreenFactoryProtocol = ScreenFactory(), 24 | flowRunner: FlowRunnerProtocol = FlowRunner() 25 | ) { 26 | self.router = router 27 | self.screenFactory = screenFactory 28 | self.flowRunner = flowRunner 29 | } 30 | } 31 | 32 | extension ArtistFlow: ArtistFlowProtocol { 33 | func showTrack(track: Track, image: String) { 34 | flowRunner.runTrackFlow(router: router, track: track, image: image) 35 | } 36 | 37 | func showArtist(artist: ArtistSearch) { 38 | router.showPushed(screenFactory.makeArtistScreen(flow: self, artist: artist)) 39 | } 40 | 41 | func runFlow(with artist: ArtistSearch) { 42 | showArtistScreen(artist: artist) 43 | } 44 | 45 | private func showArtistScreen(artist: ArtistSearch) { 46 | router.showPushed( 47 | screenFactory.makeArtistScreen(flow: self, artist: artist) 48 | ) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /DeezerProject/Interface/Flows/BrowseFlow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BrowseFlow.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 01/03/2021. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | public protocol BrowseFlowProtocol { 11 | func runFlow() 12 | func showGenreListScreen(data: [BrowseSectionData]) 13 | func showTrackScreen(track: Track, image: String?) 14 | func showGenre(genre: Genre) 15 | func showArtistScreen(artist: ArtistSearch) 16 | 17 | } 18 | 19 | extension BrowseFlowProtocol { 20 | func showTrackScreen(track: Track, image: String? = nil) { 21 | showTrackScreen(track: track, image: nil) 22 | } 23 | } 24 | 25 | final class BrowseFlow { 26 | private let router: FlowRoutingServiceProtocol 27 | private let screenFactory: BrowseFlowScreenFactoryProtocol 28 | private let flowRunner: FlowRunnerProtocol 29 | 30 | init( 31 | router: FlowRoutingServiceProtocol, 32 | screenFactory: BrowseFlowScreenFactoryProtocol = ScreenFactory(), 33 | flowRunner: FlowRunnerProtocol = FlowRunner() 34 | ) { 35 | self.router = router 36 | self.screenFactory = screenFactory 37 | self.flowRunner = flowRunner 38 | } 39 | } 40 | 41 | extension BrowseFlow: BrowseFlowProtocol { 42 | func showArtistScreen(artist: ArtistSearch) { 43 | flowRunner.runArtistFlow(router: router, artist: artist) 44 | } 45 | 46 | func showTrackScreen(track: Track, image: String? = nil) { 47 | flowRunner.runTrackFlow(router: router, track: track, image: image) 48 | } 49 | 50 | func runFlow() { 51 | showBrowseScreen() 52 | } 53 | 54 | func showBrowseScreen() { 55 | router.showPushed( 56 | screenFactory.makeBrowseScreen(flow: self) 57 | ) 58 | } 59 | 60 | func showGenreListScreen(data: [BrowseSectionData]) { 61 | router.showPushed( 62 | screenFactory.makeGenreListScreen(flow: self, data: data) 63 | ) 64 | } 65 | 66 | func showGenre(genre: Genre) { 67 | router.showPushed( 68 | screenFactory.makeGenreScreen(flow: self, genre: genre) 69 | ) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /DeezerProject/Interface/Flows/Runner/FlowRunner.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FlowRunner.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 10/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol FlowRunnerProtocol { 11 | func runBrowseFlow( 12 | router: FlowRoutingServiceProtocol 13 | ) 14 | 15 | func runSearchFlow( 16 | router: FlowRoutingServiceProtocol 17 | ) 18 | 19 | func runArtistFlow( 20 | router: FlowRoutingServiceProtocol, 21 | artist: ArtistSearch 22 | ) 23 | 24 | func runTrackFlow( 25 | router: FlowRoutingServiceProtocol, 26 | track: Track, 27 | image: String? 28 | ) 29 | } 30 | 31 | public struct FlowRunner { 32 | public init() {} 33 | } 34 | 35 | extension FlowRunner: FlowRunnerProtocol { 36 | public func runTrackFlow( 37 | router: FlowRoutingServiceProtocol, 38 | track: Track, 39 | image: String? 40 | ) { 41 | TrackFlow(router: router).runFlow(with: track, image: image) 42 | } 43 | 44 | public func runBrowseFlow( 45 | router: FlowRoutingServiceProtocol 46 | ) { 47 | BrowseFlow( 48 | router: router 49 | ).runFlow() 50 | } 51 | 52 | public func runSearchFlow( 53 | router: FlowRoutingServiceProtocol 54 | ) { 55 | SearchFlow( 56 | router: router 57 | ).runFlow() 58 | } 59 | 60 | public func runArtistFlow( 61 | router: FlowRoutingServiceProtocol, 62 | artist: ArtistSearch 63 | ) { 64 | ArtistFlow( 65 | router: router 66 | ).runFlow(with: artist) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /DeezerProject/Interface/Flows/SearchFlow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchFlow.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 04/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol SearchFlowProtocol { 11 | func runFlow() 12 | func showAlbum(album: AlbumSearch) 13 | func showArtist(artist: ArtistSearch) 14 | func showTrackScreen(track: Track, image: String?) 15 | } 16 | 17 | extension SearchFlowProtocol { 18 | func showTrackScreen(track: Track) { 19 | showTrackScreen(track: track, image: nil) 20 | } 21 | } 22 | 23 | final class SearchFlow { 24 | private let router: FlowRoutingServiceProtocol 25 | private let screenFactory: SearchFlowScreenFactoryProtocol 26 | private let flowRunner: FlowRunnerProtocol 27 | 28 | init( 29 | router: FlowRoutingServiceProtocol, 30 | screenFactory: SearchFlowScreenFactoryProtocol = ScreenFactory(), 31 | flowRunner: FlowRunnerProtocol = FlowRunner() 32 | ) { 33 | self.router = router 34 | self.screenFactory = screenFactory 35 | self.flowRunner = flowRunner 36 | } 37 | } 38 | 39 | extension SearchFlow: SearchFlowProtocol { 40 | func showTrackScreen(track: Track, image: String? = nil) { 41 | flowRunner.runTrackFlow(router: router, track: track, image: image) 42 | } 43 | 44 | func showArtist(artist: ArtistSearch) { 45 | flowRunner.runArtistFlow(router: router, artist: artist) 46 | } 47 | 48 | func runFlow() { 49 | showSearchScreen() 50 | } 51 | 52 | private func showSearchScreen() { 53 | router.showPushed( 54 | screenFactory.makeSearchScreen(flow: self) 55 | ) 56 | } 57 | 58 | func showAlbum(album: AlbumSearch) { 59 | router.showPushed( 60 | screenFactory.makeAlbumScreen(flow: self, album: album) 61 | ) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /DeezerProject/Interface/Flows/TrackFlow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TrackFlow.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 10/03/2021. 6 | // 7 | 8 | import UIKit 9 | 10 | public protocol TrackFlowProtocol { 11 | func runFlow(with track: Track, image: String?) 12 | func showMusicBar() 13 | func hideMusicBar() 14 | } 15 | 16 | final class TrackFlow { 17 | private let router: FlowRoutingServiceProtocol 18 | private let screenFactory: TrackFlowScreenFactoryProtocol 19 | 20 | init( 21 | router: FlowRoutingServiceProtocol, 22 | screenFactory: TrackFlowScreenFactoryProtocol = ScreenFactory() 23 | ) { 24 | self.router = router 25 | self.screenFactory = screenFactory 26 | } 27 | } 28 | 29 | extension TrackFlow: TrackFlowProtocol { 30 | func runFlow(with track: Track, image: String?) { 31 | showTrackScreen(track: track, image: image) 32 | } 33 | 34 | private func showTrackScreen(track: Track, image: String?) { 35 | router.showPushed( 36 | screenFactory.makeTrackScreen(flow: self, track: track, image: image) 37 | ) 38 | } 39 | 40 | func showMusicBar() { 41 | router.showMusicBar() 42 | } 43 | 44 | func hideMusicBar() { 45 | router.hideMusicBar() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /DeezerProject/Interface/Screens/Album/AlbumViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlbumViewController.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 10/03/2021. 6 | // 7 | 8 | import UIKit 9 | 10 | class AlbumViewController: UIViewController { 11 | var viewModel: AlbumViewModelProtocol 12 | lazy var artistImageView = UIImageView() 13 | lazy var tableView = UITableView() 14 | 15 | override func viewDidLoad() { 16 | super.viewDidLoad() 17 | title = viewModel.album.title 18 | 19 | setupHierarchy() 20 | setupComponents() 21 | setupConstraints() 22 | 23 | viewModel.reloadCollectionView = { 24 | self.tableView.reloadData() 25 | } 26 | } 27 | 28 | init(viewModel: AlbumViewModelProtocol) { 29 | self.viewModel = viewModel 30 | super.init(nibName: nil, bundle: nil) 31 | } 32 | 33 | required init?(coder: NSCoder) { 34 | fatalError("init(coder:) has not been implemented") 35 | } 36 | 37 | override func loadView() { 38 | let view = UIView() 39 | view.backgroundColor = .systemBackground 40 | self.view = view 41 | } 42 | 43 | override func viewDidAppear(_ animated: Bool) { 44 | super.viewDidAppear(animated) 45 | viewModel.getTracklist(urlString: viewModel.album.tracklist) 46 | } 47 | 48 | func setupHierarchy() { 49 | self.view.addSubview(artistImageView) 50 | self.view.addSubview(tableView) 51 | } 52 | 53 | func setupComponents() { 54 | if let url = URL(string: viewModel.album.coverMedium) { 55 | artistImageView.sd_setImage( 56 | with: url, 57 | placeholderImage: UIImage(named: "placeholder"), 58 | options: .highPriority 59 | ) 60 | } 61 | artistImageView.contentMode = .scaleAspectFit 62 | artistImageView.translatesAutoresizingMaskIntoConstraints = false 63 | tableView.backgroundColor = .systemBackground 64 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") 65 | tableView.delegate = self 66 | tableView.dataSource = self 67 | tableView.translatesAutoresizingMaskIntoConstraints = false 68 | } 69 | 70 | func setupConstraints() { 71 | NSLayoutConstraint.activate([ 72 | artistImageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), 73 | artistImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor), 74 | tableView.topAnchor.constraint(equalTo: artistImageView.bottomAnchor), 75 | tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), 76 | tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), 77 | tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) 78 | ]) 79 | } 80 | } 81 | 82 | extension AlbumViewController: UITableViewDelegate { 83 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 84 | viewModel.showTrack(track: viewModel.tracklist[indexPath.row]) 85 | } 86 | } 87 | 88 | extension AlbumViewController: UITableViewDataSource { 89 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 90 | viewModel.tracklist.count 91 | } 92 | 93 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 94 | if let cell = tableView.dequeueReusableCell(withIdentifier: "cell") { 95 | cell.textLabel?.text = viewModel.tracklist[indexPath.row].title 96 | return cell 97 | } 98 | fatalError("Unable to dequeue cell") 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /DeezerProject/Interface/Screens/Album/AlbumViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlbumViewModel.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 10/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol AlbumViewModelProtocol { 11 | var album: AlbumSearch { get } 12 | func getTracklist(urlString: String) 13 | var tracklist: [Track] { get } 14 | var reloadCollectionView: (() -> Void)? { get set } 15 | func showTrack(track: Track) 16 | } 17 | 18 | class AlbumViewModel: AlbumViewModelProtocol { 19 | public enum Section: CaseIterable, Hashable { 20 | case grid 21 | } 22 | var reloadCollectionView: (() -> Void)? 23 | 24 | var tracklist: [Track] = [] 25 | 26 | let searchInteractor: SearchInteractorProtocol 27 | let flow: SearchFlowProtocol 28 | let album: AlbumSearch 29 | init ( 30 | flow: SearchFlowProtocol, 31 | searchInteractor: SearchInteractorProtocol = SearchInteractor(), 32 | album: AlbumSearch 33 | ) { 34 | self.searchInteractor = searchInteractor 35 | self.flow = flow 36 | self.album = album 37 | } 38 | 39 | func getTracklist(urlString: String) { 40 | searchInteractor.getTracklist(urlString: urlString, completion: { result in 41 | switch result { 42 | case .success(let tracks): 43 | self.tracklist = tracks 44 | DispatchQueue.main.async { 45 | self.reloadCollectionView?() 46 | } 47 | case .failure: 48 | break 49 | } 50 | }) 51 | } 52 | func showTrack(track: Track) { 53 | flow.showTrackScreen(track: track, image: album.coverMedium) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /DeezerProject/Interface/Screens/Artist/ArtistViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArtistViewController.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 05/03/2021. 6 | // 7 | 8 | import UIKit 9 | 10 | class ArtistViewController: UIViewController { 11 | var viewModel: ArtistViewModelProtocol 12 | lazy var artistImageView = UIImageView() 13 | lazy var tableView = UITableView() 14 | 15 | override func viewDidLoad() { 16 | super.viewDidLoad() 17 | title = viewModel.artist.name 18 | 19 | setupHierarchy() 20 | setupComponents() 21 | setupConstraints() 22 | 23 | viewModel.reloadCollectionView = { 24 | self.tableView.reloadData() 25 | } 26 | } 27 | 28 | init(viewModel: ArtistViewModelProtocol) { 29 | self.viewModel = viewModel 30 | super.init(nibName: nil, bundle: nil) 31 | } 32 | 33 | required init?(coder: NSCoder) { 34 | fatalError("init(coder:) has not been implemented") 35 | } 36 | 37 | override func loadView() { 38 | let view = UIView() 39 | view.backgroundColor = .systemBackground 40 | self.view = view 41 | } 42 | 43 | override func viewDidAppear(_ animated: Bool) { 44 | super.viewDidAppear(animated) 45 | viewModel.getTracklist(identifier: String(viewModel.artist.id)) 46 | } 47 | 48 | func setupHierarchy() { 49 | self.view.addSubview(artistImageView) 50 | self.view.addSubview(tableView) 51 | } 52 | 53 | func setupComponents() { 54 | if let url = URL(string: viewModel.artist.pictureMedium) { 55 | artistImageView.sd_setImage( 56 | with: url, 57 | placeholderImage: UIImage(named: "placeholder"), 58 | options: .highPriority 59 | ) 60 | } 61 | artistImageView.contentMode = .scaleAspectFit 62 | artistImageView.translatesAutoresizingMaskIntoConstraints = false 63 | tableView.backgroundColor = .systemBackground 64 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") 65 | tableView.delegate = self 66 | tableView.dataSource = self 67 | tableView.translatesAutoresizingMaskIntoConstraints = false 68 | } 69 | 70 | func setupConstraints() { 71 | NSLayoutConstraint.activate([ 72 | artistImageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), 73 | artistImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor), 74 | tableView.topAnchor.constraint(equalTo: artistImageView.bottomAnchor), 75 | tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), 76 | tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), 77 | tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) 78 | ]) 79 | } 80 | } 81 | 82 | extension ArtistViewController: UITableViewDelegate { 83 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 84 | viewModel.showTrack(track: viewModel.tracklist[indexPath.row]) 85 | } 86 | } 87 | 88 | extension ArtistViewController: UITableViewDataSource { 89 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 90 | viewModel.tracklist.count 91 | } 92 | 93 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 94 | if let cell = tableView.dequeueReusableCell(withIdentifier: "cell") { 95 | cell.textLabel?.text = viewModel.tracklist[indexPath.row].title 96 | return cell 97 | } 98 | fatalError("Unable to dequeue cell") 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /DeezerProject/Interface/Screens/Artist/ArtistViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArtistViewModel.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 05/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol ArtistViewModelProtocol { 11 | var artist: ArtistSearch { get } 12 | func getTracklist(identifier: String) 13 | var reloadCollectionView: (() -> Void)? { get set } 14 | var tracklist: [Track] { get } 15 | func showTrack(track: Track) 16 | } 17 | 18 | class ArtistViewModel: ArtistViewModelProtocol { 19 | var reloadCollectionView: (() -> Void)? 20 | 21 | let artist: ArtistSearch 22 | let browseInteractor: BrowseInteractorProtocol 23 | let flow: ArtistFlowProtocol 24 | var tracklist: [Track] = [] 25 | init ( 26 | browseInteractor: BrowseInteractorProtocol = BrowseInteractor(), 27 | flow: ArtistFlowProtocol, 28 | artist: ArtistSearch 29 | ) { 30 | self.browseInteractor = browseInteractor 31 | self.flow = flow 32 | self.artist = artist 33 | } 34 | 35 | func showTrack(track: Track) { 36 | flow.showTrack(track: track, image: artist.pictureMedium) 37 | } 38 | 39 | func getTracklist(identifier: String) { 40 | browseInteractor.getTracklist(identifier: identifier, completion: { result in 41 | switch result { 42 | case .success(let tracks): 43 | self.tracklist = tracks 44 | DispatchQueue.main.async { 45 | self.reloadCollectionView?() 46 | } 47 | case .failure: 48 | break 49 | } 50 | }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /DeezerProject/Interface/Screens/Genre/GenreViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GenreViewController.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 05/03/2021. 6 | // 7 | 8 | import UIKit 9 | 10 | class GenreViewController: UIViewController { 11 | var dataSource: UICollectionViewDiffableDataSource! 12 | typealias Snapshot = NSDiffableDataSourceSnapshot 13 | override func viewDidLoad() { 14 | super.viewDidLoad() 15 | 16 | title = viewModel.genre.name 17 | 18 | setupHierarchy() 19 | setupComponents() 20 | setupConstraints() 21 | 22 | collectionView.register( 23 | SongCollectionViewCell.self, 24 | forCellWithReuseIdentifier: String(describing: SongCollectionViewCell.self) 25 | ) 26 | 27 | viewModel.getArtists() 28 | 29 | configureDataSource() 30 | 31 | var snapshot = Snapshot() 32 | snapshot.appendSections([.grid]) 33 | snapshot.appendItems(viewModel.artists, toSection: .grid) 34 | 35 | dataSource.apply(snapshot, animatingDifferences: true) 36 | 37 | viewModel.reloadCollectionView = { 38 | self.applySnapshot() 39 | } 40 | } 41 | 42 | func applySnapshot() { 43 | if !viewModel.artists.isEmpty { 44 | var currentSnapshot = dataSource.snapshot() 45 | currentSnapshot.appendItems(viewModel.artists, toSection: .grid) 46 | dataSource.apply(currentSnapshot, animatingDifferences: true) 47 | } 48 | } 49 | 50 | var viewModel: GenreViewModelProtocol 51 | lazy var collectionView: UICollectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) 52 | 53 | init(viewModel: GenreViewModelProtocol) { 54 | self.viewModel = viewModel 55 | super.init(nibName: nil, bundle: nil) 56 | } 57 | 58 | required init?(coder: NSCoder) { 59 | fatalError("init(coder:) has not been implemented") 60 | } 61 | 62 | override func loadView() { 63 | let view = UIView() 64 | view.backgroundColor = .red 65 | self.view = view 66 | } 67 | 68 | lazy var layout = { () -> UICollectionViewFlowLayout in 69 | let flowLayout = UICollectionViewFlowLayout() 70 | flowLayout.scrollDirection = .vertical 71 | flowLayout.itemSize = .init(width: 80, height: 80) 72 | return flowLayout 73 | }() 74 | 75 | func setupHierarchy() { 76 | view.addSubview(collectionView) 77 | } 78 | func setupComponents() { 79 | collectionView.translatesAutoresizingMaskIntoConstraints = false 80 | collectionView.backgroundColor = .systemBackground 81 | collectionView.contentInset = .init(top: 0, left: 10, bottom: 0, right: 10) 82 | 83 | } 84 | func setupConstraints() { 85 | NSLayoutConstraint.activate([ 86 | collectionView.topAnchor.constraint(equalTo: view.topAnchor), 87 | collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), 88 | collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), 89 | collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor) 90 | ]) 91 | } 92 | } 93 | 94 | extension GenreViewController { 95 | func configureDataSource() { 96 | dataSource = UICollectionViewDiffableDataSource 97 | ( 98 | collectionView: collectionView 99 | ) { (collectionView: UICollectionView, indexPath: IndexPath, sectionData: ArtistSearch) 100 | -> UICollectionViewCell? in 101 | guard let cell = collectionView.dequeueReusableCell( 102 | withReuseIdentifier: String( 103 | describing: SongCollectionViewCell.self 104 | ), 105 | for: indexPath 106 | ) as? SongCollectionViewCell else { 107 | fatalError("Unable to create new cell") 108 | } 109 | cell.configure( 110 | with: sectionData.name, 111 | pictureURLString: sectionData.pictureMedium, 112 | onTap: { [unowned self] in 113 | self.viewModel.showArtist(artist: self.viewModel.artists[indexPath.row]) 114 | } 115 | ) 116 | return cell 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /DeezerProject/Interface/Screens/Genre/GenreViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GenreViewModel.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 05/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol GenreViewModelProtocol { 11 | var genre: Genre { get } 12 | var artists: [ArtistSearch] { get } 13 | func getArtists() 14 | var reloadCollectionView: (() -> Void)? { get set } 15 | func showArtist(artist: ArtistSearch) 16 | } 17 | 18 | class GenreViewModel: GenreViewModelProtocol { 19 | public enum Section: CaseIterable, Hashable { 20 | case grid 21 | } 22 | let browseInteractor: BrowseInteractorProtocol 23 | let flow: BrowseFlowProtocol 24 | let genre: Genre 25 | var artists: [ArtistSearch] = [] 26 | var reloadCollectionView: (() -> Void)? 27 | 28 | init ( 29 | browseInteractor: BrowseInteractorProtocol = BrowseInteractor(), 30 | flow: BrowseFlowProtocol, 31 | genre: Genre 32 | ) { 33 | self.browseInteractor = browseInteractor 34 | self.flow = flow 35 | self.genre = genre 36 | } 37 | 38 | func getArtists() { 39 | browseInteractor.getGenreArtists(identifier: genre.id.description, completion: { result in 40 | switch result { 41 | case .success(let artists): 42 | self.artists = artists 43 | DispatchQueue.main.async { 44 | self.reloadCollectionView?() 45 | } 46 | case .failure: 47 | break 48 | } 49 | }) 50 | } 51 | 52 | func showArtist(artist: ArtistSearch) { 53 | flow.showArtistScreen(artist: artist) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /DeezerProject/Interface/Screens/GenreList/GenreListViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GenreViewController.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 03/03/2021. 6 | // 7 | 8 | import UIKit 9 | 10 | class GenreListViewController: UIViewController { 11 | var dataSource: UICollectionViewDiffableDataSource! 12 | typealias Snapshot = NSDiffableDataSourceSnapshot 13 | 14 | override func loadView() { 15 | let view = UIView() 16 | view.backgroundColor = .systemBackground 17 | self.view = view 18 | } 19 | 20 | lazy var layout = { () -> UICollectionViewFlowLayout in 21 | let flowLayout = UICollectionViewFlowLayout() 22 | flowLayout.scrollDirection = .vertical 23 | flowLayout.itemSize = .init(width: 80, height: 80) 24 | return flowLayout 25 | }() 26 | 27 | override func viewDidLoad() { 28 | super.viewDidLoad() 29 | setupHierarchy() 30 | setupComponents() 31 | setupConstraints() 32 | 33 | collectionView.register( 34 | SongCollectionViewCell.self, 35 | forCellWithReuseIdentifier: String(describing: SongCollectionViewCell.self) 36 | ) 37 | 38 | configureDataSource() 39 | 40 | var snapshot = Snapshot() 41 | snapshot.appendSections([.grid]) 42 | snapshot.appendItems(viewModel.genreData, toSection: .grid) 43 | 44 | dataSource.apply(snapshot, animatingDifferences: true) 45 | } 46 | 47 | func setupHierarchy() { 48 | view.addSubview(collectionView) 49 | } 50 | func setupComponents() { 51 | collectionView.translatesAutoresizingMaskIntoConstraints = false 52 | collectionView.backgroundColor = .systemBackground 53 | collectionView.contentInset = .init(top: 0, left: 10, bottom: 0, right: 10) 54 | } 55 | func setupConstraints() { 56 | NSLayoutConstraint.activate([ 57 | collectionView.topAnchor.constraint(equalTo: view.topAnchor), 58 | collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), 59 | collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), 60 | collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor) 61 | ]) 62 | } 63 | 64 | var viewModel: GenreListViewModelProtocol 65 | lazy var collectionView: UICollectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) 66 | 67 | lazy var layoutSections: [BrowseLayoutSectionProtocol] = [] 68 | 69 | init(viewModel: GenreListViewModelProtocol) { 70 | self.viewModel = viewModel 71 | super.init(nibName: nil, bundle: nil) 72 | } 73 | 74 | required init?(coder: NSCoder) { 75 | fatalError("init(coder:) has not been implemented") 76 | } 77 | } 78 | 79 | extension GenreListViewController { 80 | func configureDataSource() { 81 | dataSource = UICollectionViewDiffableDataSource 82 | ( 83 | collectionView: collectionView 84 | ) { (collectionView: UICollectionView, indexPath: IndexPath, sectionData: BrowseSectionData) 85 | -> UICollectionViewCell? in 86 | guard let cell = collectionView.dequeueReusableCell( 87 | withReuseIdentifier: String( 88 | describing: SongCollectionViewCell.self 89 | ), 90 | for: indexPath 91 | ) as? SongCollectionViewCell else { 92 | fatalError("Unable to create new cell") 93 | } 94 | 95 | switch sectionData { 96 | case .chart: 97 | break 98 | case .genre(let genre): 99 | cell.configure( 100 | with: genre.name, 101 | pictureURLString: genre.pictureMedium, 102 | onTap: { 103 | let genre = self.viewModel.genreData[indexPath.row] 104 | switch genre { 105 | case .genre(let genre): 106 | self.viewModel.showGenre(genre: genre) 107 | default: break 108 | } 109 | } 110 | ) 111 | case .favourite: 112 | break 113 | } 114 | return cell 115 | 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /DeezerProject/Interface/Screens/GenreList/GenreListViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GenreViewModel.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 03/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol GenreListViewModelProtocol { 11 | var genreData: [BrowseSectionData] { get } 12 | func showGenre(genre: Genre) 13 | } 14 | 15 | class GenreListViewModel: GenreListViewModelProtocol { 16 | public enum Section: CaseIterable, Hashable { 17 | case grid 18 | } 19 | let flow: BrowseFlowProtocol 20 | let genreData: [BrowseSectionData] 21 | init( 22 | flow: BrowseFlowProtocol, 23 | data: [BrowseSectionData] 24 | ) { 25 | self.flow = flow 26 | self.genreData = data 27 | } 28 | 29 | func showGenre(genre: Genre) { 30 | flow.showGenre(genre: genre) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /DeezerProject/Interface/Screens/Menu/BrowseViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 26/02/2021. 6 | // 7 | 8 | import UIKit 9 | import NetworkLibrary 10 | 11 | class BrowseViewController: UIViewController { 12 | var dataSource: UICollectionViewDiffableDataSource! 13 | typealias Snapshot = NSDiffableDataSourceSnapshot 14 | 15 | lazy var layoutSections: [BrowseLayoutSectionProtocol] = [] 16 | 17 | lazy var emptyView = UIView() 18 | lazy var layout: UICollectionViewLayout = myCollectionViewLayout 19 | lazy var collectionView: UICollectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) 20 | lazy var emptyImage = UIImageView() 21 | 22 | var viewModel: BrowseViewModelProtocol 23 | 24 | lazy var myCollectionViewLayout: UICollectionViewLayout = { 25 | let layout = UICollectionViewCompositionalLayout { (sectionIndex, _) -> NSCollectionLayoutSection? in 26 | return self.layoutSections[sectionIndex].layoutSection 27 | } 28 | return layout 29 | }() 30 | 31 | // swiftlint:disable:next cyclomatic_complexity 32 | func getLayoutForSection(for section: BrowseViewModel.Section) -> BrowseLayoutSectionProtocol { 33 | switch section { 34 | case .chart: 35 | return BrowseSection( 36 | title: "Chart", 37 | onTap: { item in 38 | switch self.viewModel.chart?[item] { 39 | case .chart(let chart): 40 | self.viewModel.moveTrack(with: chart) 41 | default: break 42 | } 43 | }, 44 | onFavouriteTap: nil 45 | ) 46 | case .genre: 47 | return BrowseSection( 48 | title: "Genre", 49 | buttonTitle: "See All", 50 | onTap: { [unowned self] item in 51 | switch self.viewModel.genre[item] { 52 | case .genre(let genre): 53 | viewModel.showGenre(with: genre) 54 | default: break 55 | } 56 | }, 57 | onFavouriteTap: nil 58 | ) 59 | case .favourites: 60 | return BrowseSection( 61 | title: "Favourites", 62 | onTap: { item in 63 | switch self.viewModel.chart?[item] { 64 | case .chart(let chart): 65 | self.viewModel.moveTrack(with: chart) 66 | default: break 67 | } 68 | }, 69 | onFavouriteTap: { item in 70 | switch self.viewModel.favourites[item] { 71 | case .favourite(let favourite): 72 | self.viewModel.deleteFavourite(track: favourite, completion: { 73 | self.updateFavourites() 74 | }) 75 | default: break 76 | } 77 | } 78 | ) 79 | } 80 | } 81 | 82 | override func viewDidLoad() { 83 | super.viewDidLoad() 84 | title = "Music" 85 | 86 | setupHierarchy() 87 | setupComponents() 88 | setupConstraints() 89 | 90 | collectionView.register( 91 | TitleSupplementaryView.self, 92 | forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, 93 | withReuseIdentifier: String(describing: TitleSupplementaryView.self) 94 | ) 95 | 96 | collectionView.register( 97 | SongCollectionViewCell.self, 98 | forCellWithReuseIdentifier: String(describing: SongCollectionViewCell.self) 99 | ) 100 | 101 | configureDataSource() 102 | 103 | layoutSections.append(getLayoutForSection(for: .genre)) 104 | layoutSections.append(getLayoutForSection(for: .chart)) 105 | layoutSections.append(getLayoutForSection(for: .favourites)) 106 | 107 | var snapshot = Snapshot() 108 | snapshot.appendSections([.genre]) 109 | snapshot.appendSections([.chart]) 110 | snapshot.appendSections([.favourites]) 111 | 112 | dataSource.apply(snapshot, animatingDifferences: true) 113 | 114 | viewModel.reloadCollectionView = { 115 | self.applySnapshot() 116 | } 117 | 118 | viewModel.getGenreAndChart() 119 | } 120 | 121 | func setupHierarchy() { 122 | view.addSubview(collectionView) 123 | view.addSubview(emptyView) 124 | emptyView.addSubview(emptyImage) 125 | } 126 | 127 | func setupComponents() { 128 | collectionView.translatesAutoresizingMaskIntoConstraints = false 129 | collectionView.backgroundColor = .systemBackground 130 | 131 | emptyView.isHidden = true 132 | emptyView.translatesAutoresizingMaskIntoConstraints = false 133 | emptyView.backgroundColor = .systemBackground 134 | 135 | emptyImage.translatesAutoresizingMaskIntoConstraints = false 136 | emptyImage.image = UIImage(named: "ohdear") 137 | emptyImage.contentMode = .scaleAspectFit 138 | } 139 | 140 | func setupConstraints() { 141 | NSLayoutConstraint.activate([ 142 | collectionView.topAnchor.constraint(equalTo: view.topAnchor), 143 | collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), 144 | collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), 145 | collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), 146 | emptyView.topAnchor.constraint(equalTo: view.topAnchor), 147 | emptyView.bottomAnchor.constraint(equalTo: view.bottomAnchor), 148 | emptyView.trailingAnchor.constraint(equalTo: view.trailingAnchor), 149 | emptyView.leadingAnchor.constraint(equalTo: view.leadingAnchor), 150 | emptyImage.topAnchor.constraint(equalTo: view.topAnchor, constant: +50), 151 | emptyImage.leadingAnchor.constraint(equalTo: view.leadingAnchor), 152 | emptyImage.trailingAnchor.constraint(equalTo: view.trailingAnchor) 153 | ]) 154 | } 155 | 156 | func applySnapshot() { 157 | var currentSnapshot = dataSource.snapshot() 158 | currentSnapshot.appendItems(viewModel.genre, toSection: .genre) 159 | 160 | if viewModel.genre.isEmpty { 161 | currentSnapshot.deleteSections([.genre]) 162 | } 163 | 164 | if let chart = viewModel.chart { 165 | currentSnapshot.appendItems(chart, toSection: .chart) 166 | } else { 167 | currentSnapshot.deleteSections([.chart]) 168 | } 169 | 170 | if viewModel.genre.isEmpty && viewModel.chart == nil { 171 | emptyView.isHidden = false 172 | } else { 173 | emptyView.isHidden = true 174 | } 175 | dataSource.apply(currentSnapshot, animatingDifferences: true) 176 | } 177 | 178 | override func loadView() { 179 | let view = UIView() 180 | self.view = view 181 | } 182 | 183 | init(viewModel: BrowseViewModelProtocol) { 184 | self.viewModel = viewModel 185 | super.init(nibName: nil, bundle: nil) 186 | } 187 | 188 | required init?(coder: NSCoder) { 189 | fatalError("init(coder:) has not been implemented") 190 | } 191 | 192 | override func viewWillAppear(_ animated: Bool) { 193 | super.viewWillAppear(animated) 194 | configureHeader() 195 | updateFavourites() 196 | } 197 | 198 | func updateFavourites() { 199 | viewModel.updateFavourites() 200 | 201 | var currentSnapshot = Snapshot() 202 | currentSnapshot.appendSections([.genre]) 203 | currentSnapshot.appendSections([.chart]) 204 | 205 | if viewModel.favourites.count > 0 { 206 | if currentSnapshot.indexOfSection(.favourites) == nil { 207 | currentSnapshot.appendSections([.favourites]) 208 | currentSnapshot.appendItems(self.viewModel.favourites, toSection: .favourites) 209 | } 210 | } else { 211 | if currentSnapshot.indexOfSection(.favourites) != nil { 212 | currentSnapshot.deleteSections([.favourites]) 213 | } 214 | } 215 | if let chartItems = self.viewModel.chart { 216 | currentSnapshot.appendItems(chartItems, toSection: .chart) 217 | } 218 | currentSnapshot.appendItems(self.viewModel.genre, toSection: .genre) 219 | 220 | self.dataSource.apply(currentSnapshot, animatingDifferences: true) 221 | } 222 | } 223 | 224 | extension BrowseViewController { 225 | func configureHeader() { 226 | dataSource.supplementaryViewProvider = { 227 | ( 228 | collectionView: UICollectionView, 229 | kind: String, 230 | indexPath: IndexPath 231 | ) 232 | -> UICollectionReusableView in 233 | guard let section = self.layoutSections[indexPath.section] as? HeaderSectionProtocol else {fatalError()} 234 | return section.header( 235 | collectionView: collectionView, 236 | indexPath: indexPath, 237 | title: section.title, 238 | buttonTitle: section.buttonTitle, 239 | action: { 240 | switch BrowseViewModel.Section.allCases[indexPath.section] { 241 | case .chart: 242 | break 243 | case .favourites: 244 | break 245 | case .genre: 246 | self.viewModel.moveGenre() 247 | } 248 | } 249 | ) 250 | } 251 | } 252 | func configureDataSource() { 253 | dataSource = UICollectionViewDiffableDataSource 254 | ( 255 | collectionView: collectionView 256 | ) { (collectionView: UICollectionView, indexPath: IndexPath, sectionData: BrowseSectionData) 257 | -> UICollectionViewCell? in 258 | return self.layoutSections[indexPath.section].configureCell( 259 | collectionView: collectionView, 260 | indexPath: indexPath, 261 | item: sectionData, 262 | position: indexPath.row 263 | ) 264 | } 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /DeezerProject/Interface/Screens/Menu/BrowseViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuViewModel.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 26/02/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol BrowseViewModelProtocol { 11 | func getGenreAndChart() 12 | var genre: [BrowseSectionData] { get set } 13 | var chart: [BrowseSectionData]? { get set } 14 | var favourites: [BrowseSectionData] { get set } 15 | var reloadCollectionView: (() -> Void)? { get set } 16 | func moveGenre() 17 | func moveTrack(with track: Track) 18 | func showGenre(with genre: Genre) 19 | func addFavourites(with data: BrowseSectionData) 20 | func updateFavourites() 21 | func deleteFavourite(track: Track, completion: (() -> Void)?) 22 | } 23 | 24 | public enum BrowseSectionData: Hashable { 25 | case chart(Track) 26 | case genre(Genre) 27 | case favourite(Track) 28 | } 29 | 30 | class BrowseViewModel: BrowseViewModelProtocol { 31 | func addFavourites(with data: BrowseSectionData) { 32 | switch data { 33 | case .chart(let track): 34 | browseInteractor.saveTrack(track: track, completion: { 35 | DispatchQueue.main.async { 36 | self.reloadCollectionView?() 37 | } 38 | }) 39 | default: break 40 | } 41 | } 42 | 43 | var chart: [BrowseSectionData]? = [] 44 | var genre: [BrowseSectionData] = [] 45 | var favourites: [BrowseSectionData] = [] 46 | 47 | public enum Section: CaseIterable, Hashable { 48 | case genre 49 | case chart 50 | case favourites 51 | } 52 | 53 | var reloadCollectionView: (() -> Void)? 54 | 55 | let browseInteractor: BrowseInteractorProtocol 56 | let flow: BrowseFlowProtocol 57 | init ( 58 | browseInteractor: BrowseInteractorProtocol = BrowseInteractor(), 59 | flow: BrowseFlowProtocol 60 | ) { 61 | self.browseInteractor = browseInteractor 62 | self.flow = flow 63 | } 64 | 65 | func moveGenre() { 66 | flow.showGenreListScreen(data: genre) 67 | } 68 | 69 | func showGenre(with genre: Genre) { 70 | flow.showGenre(genre: genre) 71 | } 72 | 73 | func moveTrack(with track: Track) { 74 | flow.showTrackScreen(track: track) 75 | } 76 | 77 | func updateFavourites() { 78 | self.favourites = self.browseInteractor.getTracks().map { BrowseSectionData.favourite($0) } 79 | } 80 | 81 | func getSavedTracks() -> [Track] { 82 | return browseInteractor.getTracks() 83 | } 84 | 85 | func deleteFavourite(track: Track, completion: (() -> Void)?) { 86 | browseInteractor.deleteTrack(track: track, completion: completion) 87 | } 88 | 89 | func getGenreAndChart() { 90 | browseInteractor.getGenreAndGetChart(completion: { data in 91 | switch data { 92 | case .success((let genre, let chart)): 93 | self.genre = genre.map { BrowseSectionData.genre($0) } 94 | self.chart = chart?.tracks.data.map { BrowseSectionData.chart($0) } 95 | DispatchQueue.main.async { 96 | self.reloadCollectionView?() 97 | } 98 | case .failure: 99 | self.genre = [] 100 | self.chart = nil 101 | DispatchQueue.main.async { 102 | self.reloadCollectionView?() 103 | } 104 | } 105 | }) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /DeezerProject/Interface/Screens/Search/SearchViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchViewController.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 04/03/2021. 6 | // 7 | 8 | import UIKit 9 | 10 | class SearchViewController: UIViewController { 11 | let searchController = UISearchController(searchResultsController: nil) 12 | var dataSource: UICollectionViewDiffableDataSource! 13 | typealias Snapshot = NSDiffableDataSourceSnapshot 14 | 15 | lazy var layoutSections: [SearchLayoutSectionProtocol] = [] 16 | 17 | lazy var layout: UICollectionViewLayout = myCollectionViewLayout 18 | lazy var collectionView: UICollectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) 19 | lazy var emptyImage = UIImageView() 20 | 21 | var viewModel: SearchViewModelProtocol 22 | 23 | lazy var myCollectionViewLayout: UICollectionViewLayout = { 24 | let layout = UICollectionViewCompositionalLayout { (sectionIndex, _) -> NSCollectionLayoutSection? in 25 | return self.layoutSections[sectionIndex].layoutSection 26 | } 27 | return layout 28 | }() 29 | 30 | func getLayoutForSection(for section: SearchViewModel.Section) -> SearchLayoutSectionProtocol { 31 | switch section { 32 | case .albums: 33 | return SearchSection( 34 | title: "Albums", 35 | onTap: { num in 36 | switch self.viewModel.albums[num] { 37 | case .albums(let album): 38 | self.viewModel.showAlbums(with: album) 39 | default: break 40 | } 41 | }, 42 | onFavouriteTap: nil 43 | ) 44 | case .artist: 45 | return SearchSection( 46 | title: "Artists", 47 | onTap: { num in 48 | switch self.viewModel.artists[num] { 49 | case .artist(let artist): 50 | self.viewModel.showArtist(with: artist) 51 | default: break 52 | } 53 | }, 54 | onFavouriteTap: nil 55 | ) 56 | case .tracks: 57 | return SearchSection( 58 | title: "Tracks", 59 | onTap: { num in 60 | switch self.viewModel.tracks[num] { 61 | case .tracks(let track): 62 | self.viewModel.showTracks(with: track) 63 | default: break 64 | } 65 | }, 66 | onFavouriteTap: nil 67 | ) 68 | } 69 | } 70 | 71 | override func viewDidLoad() { 72 | super.viewDidLoad() 73 | 74 | setupSearchController() 75 | 76 | title = "Search" 77 | setupHierarchy() 78 | setupComponents() 79 | setupConstraints() 80 | 81 | collectionView.register( 82 | TitleSupplementaryView.self, 83 | forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, 84 | withReuseIdentifier: String(describing: TitleSupplementaryView.self) 85 | ) 86 | 87 | collectionView.register( 88 | SongCollectionViewCell.self, 89 | forCellWithReuseIdentifier: String(describing: SongCollectionViewCell.self) 90 | ) 91 | 92 | configureDataSource() 93 | 94 | layoutSections.append(getLayoutForSection(for: .albums)) 95 | layoutSections.append(getLayoutForSection(for: .artist)) 96 | layoutSections.append(getLayoutForSection(for: .tracks)) 97 | 98 | viewModel.reloadCollectionView = { 99 | self.applySnapshot() 100 | } 101 | } 102 | 103 | func applySnapshot() { 104 | var currentSnapshot = dataSource.snapshot() 105 | 106 | if viewModel.albums.isEmpty { 107 | if currentSnapshot.indexOfSection(.albums) != nil { 108 | currentSnapshot.deleteSections([.albums]) 109 | } 110 | } else { 111 | if currentSnapshot.indexOfSection(.albums) == nil { 112 | currentSnapshot.appendSections([.albums]) 113 | } 114 | currentSnapshot.appendItems(viewModel.albums, toSection: .albums) 115 | } 116 | 117 | if viewModel.artists.isEmpty { 118 | if currentSnapshot.indexOfSection(.artist) != nil { 119 | currentSnapshot.deleteSections([.artist]) 120 | } 121 | } else { 122 | if currentSnapshot.indexOfSection(.artist) == nil { 123 | currentSnapshot.appendSections([.artist]) 124 | } 125 | currentSnapshot.appendItems(viewModel.artists, toSection: .artist) 126 | } 127 | 128 | if viewModel.tracks.isEmpty { 129 | if currentSnapshot.indexOfSection(.tracks) != nil { 130 | currentSnapshot.deleteSections([.tracks]) 131 | } 132 | } else { 133 | if currentSnapshot.indexOfSection(.tracks) == nil { 134 | currentSnapshot.appendSections([.tracks]) 135 | } 136 | currentSnapshot.appendItems(viewModel.tracks, toSection: .tracks) 137 | } 138 | dataSource.apply(currentSnapshot, animatingDifferences: true) 139 | } 140 | 141 | init(viewModel: SearchViewModelProtocol) { 142 | self.viewModel = viewModel 143 | super.init(nibName: nil, bundle: nil) 144 | } 145 | 146 | required init?(coder: NSCoder) { 147 | fatalError("init(coder:) has not been implemented") 148 | } 149 | 150 | override func loadView() { 151 | let view = UIView() 152 | view.backgroundColor = .systemBackground 153 | self.view = view 154 | } 155 | 156 | override func viewWillAppear(_ animated: Bool) { 157 | super.viewWillAppear(animated) 158 | configureHeader() 159 | } 160 | 161 | func setupSearchController() { 162 | searchController.searchBar.translatesAutoresizingMaskIntoConstraints = false 163 | searchController.searchBar.placeholder = "Search for an album, song or artist" 164 | 165 | searchController.obscuresBackgroundDuringPresentation = false 166 | 167 | searchController.searchBar.delegate = self 168 | searchController.searchBar.keyboardType = .default 169 | 170 | let placeholderAppearance = UILabel.appearance(whenContainedInInstancesOf: [UISearchBar.self]) 171 | placeholderAppearance.font = .systemFont(ofSize: 16) 172 | 173 | navigationController?.navigationBar.barTintColor = UIColor(named: "PrimaryColor") 174 | navigationItem.searchController = searchController 175 | 176 | navigationItem.hidesSearchBarWhenScrolling = false 177 | } 178 | 179 | func setupHierarchy() { 180 | view.addSubview(collectionView) 181 | } 182 | 183 | func setupComponents() { 184 | collectionView.translatesAutoresizingMaskIntoConstraints = false 185 | collectionView.backgroundColor = .systemBackground 186 | } 187 | func setupConstraints() { 188 | NSLayoutConstraint.activate([ 189 | collectionView.topAnchor.constraint(equalTo: view.topAnchor), 190 | collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), 191 | collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), 192 | collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor) 193 | ]) 194 | } 195 | 196 | func deleteAllItems() { 197 | var currentSnapshot = dataSource.snapshot() 198 | currentSnapshot.deleteItems(viewModel.albums) 199 | currentSnapshot.deleteItems(viewModel.artists) 200 | currentSnapshot.deleteItems(viewModel.tracks) 201 | dataSource.apply(currentSnapshot) 202 | } 203 | 204 | func deleteAllSection() { 205 | var currentSnapshot = dataSource.snapshot() 206 | currentSnapshot.deleteSections([.albums, .artist, .tracks]) 207 | dataSource.apply(currentSnapshot) 208 | } 209 | } 210 | 211 | extension SearchViewController { 212 | func configureHeader() { 213 | dataSource.supplementaryViewProvider = { 214 | ( 215 | collectionView: UICollectionView, 216 | kind: String, 217 | indexPath: IndexPath 218 | ) 219 | -> UICollectionReusableView in 220 | guard let section = self.layoutSections[indexPath.section] as? HeaderSectionProtocol else {fatalError()} 221 | return section.header( 222 | collectionView: collectionView, 223 | indexPath: indexPath, 224 | title: section.title, 225 | buttonTitle: nil, 226 | action: {} 227 | ) 228 | } 229 | } 230 | func configureDataSource() { 231 | dataSource = UICollectionViewDiffableDataSource 232 | ( 233 | collectionView: collectionView 234 | ) { (collectionView: UICollectionView, indexPath: IndexPath, sectionData: SearchSectionData) 235 | -> UICollectionViewCell? in 236 | return self.layoutSections[indexPath.section].configureCell( 237 | collectionView: collectionView, 238 | indexPath: indexPath, 239 | item: sectionData, 240 | position: indexPath.row 241 | ) 242 | } 243 | } 244 | } 245 | 246 | extension SearchViewController: UISearchBarDelegate { 247 | func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { 248 | deleteAllItems() 249 | viewModel.makeSearch(with: searchText) 250 | } 251 | func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { 252 | deleteAllItems() 253 | deleteAllSection() 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /DeezerProject/Interface/Screens/Search/SearchViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchViewModel.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 04/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol SearchViewModelProtocol { 11 | func makeSearch(with term: String) 12 | var reloadCollectionView: (() -> Void)? { get set } 13 | var albums: [SearchSectionData] { get } 14 | var artists: [SearchSectionData] { get } 15 | var tracks: [SearchSectionData] { get } 16 | func showAlbums(with album: AlbumSearch) 17 | func showArtist(with artist: ArtistSearch) 18 | func showTracks(with track: Track) 19 | } 20 | 21 | public enum SearchSectionData: Hashable { 22 | case artist(ArtistSearch) 23 | case albums(AlbumSearch) 24 | case tracks(Track) 25 | } 26 | 27 | class SearchViewModel: NSObject, SearchViewModelProtocol { 28 | public enum Section: CaseIterable, Hashable { 29 | case artist 30 | case albums 31 | case tracks 32 | } 33 | 34 | var reloadCollectionView: (() -> Void)? 35 | 36 | var albums: [SearchSectionData] = [] 37 | var artists: [SearchSectionData] = [] 38 | var tracks: [SearchSectionData] = [] 39 | 40 | let searchInteractor: SearchInteractorProtocol 41 | let flow: SearchFlowProtocol 42 | init( 43 | searchInteractor: SearchInteractorProtocol = SearchInteractor(), 44 | flow: SearchFlowProtocol 45 | ) { 46 | self.searchInteractor = searchInteractor 47 | self.flow = flow 48 | } 49 | 50 | func showAlbums(with album: AlbumSearch) { 51 | flow.showAlbum(album: album) 52 | } 53 | 54 | func showArtist(with artist: ArtistSearch) { 55 | flow.showArtist(artist: artist) 56 | } 57 | 58 | func showTracks(with track: Track) { 59 | flow.showTrackScreen(track: track) 60 | } 61 | 62 | func makeSearch(with term: String) { 63 | NSObject.cancelPreviousPerformRequests(withTarget: self) 64 | perform(#selector(getSearchResults(with:)), with: term, afterDelay: TimeInterval(1.0)) 65 | } 66 | 67 | @objc private func searchAlbums(term: String) { 68 | searchInteractor.getSearchAlbum(query: term, completion: {res in 69 | switch res { 70 | case .success(let albums): 71 | self.albums = albums.map { SearchSectionData.albums($0) } 72 | DispatchQueue.main.async { 73 | self.reloadCollectionView?() 74 | } 75 | case .failure: 76 | break 77 | } 78 | } 79 | ) 80 | } 81 | 82 | @objc func getSearchResults(with query: String) { 83 | let formattedQuery = query.replacingOccurrences(of: " ", with: "+") 84 | searchInteractor.getSearchResults(query: formattedQuery, completion: {data in 85 | switch data { 86 | case .success((let album, let track, let artist)): 87 | self.albums = album.map { SearchSectionData.albums($0) } 88 | self.tracks = track.map { SearchSectionData.tracks($0) } 89 | self.artists = artist.map { SearchSectionData.artist($0) } 90 | DispatchQueue.main.async { 91 | self.reloadCollectionView?() 92 | } 93 | case .failure: 94 | self.albums = [] 95 | self.tracks = [] 96 | self.artists = [] 97 | DispatchQueue.main.async { 98 | self.reloadCollectionView?() 99 | } 100 | } 101 | } 102 | ) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /DeezerProject/Interface/Screens/Track/TrackViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TrackViewController.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 04/03/2021. 6 | // 7 | 8 | import UIKit 9 | import SDWebImage 10 | 11 | class TrackViewController: UIViewController { 12 | override func viewDidLoad() { 13 | super.viewDidLoad() 14 | setupHierarchy() 15 | setupComponents() 16 | setupConstraints() 17 | } 18 | 19 | let viewModel: TrackViewModelProtocol 20 | init(viewModel: TrackViewModelProtocol) { 21 | self.viewModel = viewModel 22 | super.init(nibName: nil, bundle: nil) 23 | } 24 | 25 | required init?(coder: NSCoder) { 26 | fatalError("init(coder:) has not been implemented") 27 | } 28 | 29 | lazy private var stackView = UIStackView() 30 | lazy private var controlsStackView = UIStackView() 31 | lazy private var artistImageView = UIImageView() 32 | lazy private var artistLabel = UILabel() 33 | lazy private var trackLabel = UILabel() 34 | lazy private var playButton = UIButton() 35 | private let largeConfig = UIImage.SymbolConfiguration(pointSize: 40, weight: .medium, scale: .medium) 36 | 37 | override func loadView() { 38 | let view = UIView() 39 | view.backgroundColor = .systemBackground 40 | self.view = view 41 | } 42 | 43 | func setupHierarchy() { 44 | self.view.addSubview(stackView) 45 | stackView.addArrangedSubview(artistImageView) 46 | stackView.addArrangedSubview(trackLabel) 47 | stackView.addArrangedSubview(artistLabel) 48 | stackView.addArrangedSubview(controlsStackView) 49 | controlsStackView.addArrangedSubview(playButton) 50 | 51 | let logoutBarButtonItem = UIBarButtonItem( 52 | image: UIImage( 53 | systemName: viewModel.isFavourite() ? "heart.fill" : "heart", 54 | withConfiguration: largeConfig)?.withTintColor(.black, renderingMode: .alwaysOriginal 55 | ), 56 | style: .plain, 57 | target: self, 58 | action: #selector(updateFavourite) 59 | ) 60 | self.navigationItem.rightBarButtonItem = logoutBarButtonItem 61 | } 62 | 63 | @objc func updateFavourite() { 64 | if viewModel.isFavourite() { 65 | viewModel.deleteFavourite(completion: { 66 | let logoutBarButtonItem = UIBarButtonItem( 67 | image: UIImage( 68 | systemName: self.viewModel.isFavourite() ? "heart.fill" : "heart", 69 | withConfiguration: self.largeConfig)?.withTintColor(.black, renderingMode: .alwaysOriginal 70 | ), 71 | style: .plain, 72 | target: self, 73 | action: #selector(self.updateFavourite) 74 | ) 75 | self.navigationItem.rightBarButtonItem = logoutBarButtonItem 76 | }) 77 | } else { 78 | viewModel.addFavourite(completion: { 79 | let logoutBarButtonItem = UIBarButtonItem( 80 | image: UIImage( 81 | systemName: self.viewModel.isFavourite() ? "heart.fill" : "heart", 82 | withConfiguration: self.largeConfig)?.withTintColor(.black, renderingMode: .alwaysOriginal 83 | ), 84 | style: .plain, 85 | target: self, 86 | action: #selector(self.updateFavourite) 87 | ) 88 | self.navigationItem.rightBarButtonItem = logoutBarButtonItem 89 | }) 90 | } 91 | } 92 | 93 | func setupComponents() { 94 | stackView.axis = .vertical 95 | stackView.distribution = .fill 96 | 97 | controlsStackView.axis = .horizontal 98 | 99 | artistLabel.text = viewModel.track.artist?.name 100 | artistLabel.textAlignment = .left 101 | artistLabel.font = UIFont.systemFont(ofSize: 16) 102 | artistLabel.adjustsFontForContentSizeCategory = true 103 | 104 | trackLabel.font = UIFont.boldSystemFont(ofSize: 16) 105 | trackLabel.adjustsFontForContentSizeCategory = true 106 | trackLabel.text = viewModel.track.title 107 | trackLabel.textAlignment = .left 108 | 109 | if viewModel.isPlaying() { 110 | playButton.setImage( 111 | UIImage( 112 | systemName: "pause.fill", 113 | withConfiguration: largeConfig)?.withTintColor(.black, renderingMode: .alwaysOriginal 114 | ), 115 | for: .normal 116 | ) 117 | } else { 118 | playButton.setImage( 119 | UIImage( 120 | systemName: "play.fill", 121 | withConfiguration: largeConfig)?.withTintColor(.black, renderingMode: .alwaysOriginal 122 | ), 123 | for: .normal 124 | ) 125 | } 126 | 127 | playButton.addTarget(self, action: #selector(tapped(_:)), for: .touchDown) 128 | playButton.addTarget(self, action: #selector(tappedUp(_:)), for: .touchUpInside) 129 | 130 | if let picture = viewModel.image ?? viewModel.track.artist!.pictureMedium, let url = URL(string: picture) { 131 | artistImageView.sd_setImage( 132 | with: url, 133 | placeholderImage: UIImage(named: "placeholder"), 134 | options: .highPriority 135 | ) 136 | } 137 | 138 | artistImageView.contentMode = .scaleAspectFit 139 | playButton.translatesAutoresizingMaskIntoConstraints = false 140 | stackView.translatesAutoresizingMaskIntoConstraints = false 141 | artistImageView.translatesAutoresizingMaskIntoConstraints = false 142 | artistLabel.translatesAutoresizingMaskIntoConstraints = false 143 | } 144 | 145 | override func viewWillDisappear(_ animated: Bool) { 146 | super.viewWillDisappear(animated) 147 | if self.isMovingFromParent { 148 | viewModel.revealMiniPlayer() 149 | } 150 | } 151 | 152 | override func viewWillAppear(_ animated: Bool) { 153 | super.viewWillAppear(animated) 154 | } 155 | 156 | override func viewDidAppear(_ animated: Bool) { 157 | super.viewDidAppear(animated) 158 | viewModel.hideMiniPlayer() 159 | } 160 | 161 | @objc func tappedUp(_ sender: AnyObject) { 162 | if viewModel.isPlaying() { 163 | viewModel.pauseSong() 164 | playButton.setImage( 165 | UIImage( 166 | systemName: "play.fill", 167 | withConfiguration: largeConfig)?.withTintColor(.black, renderingMode: .alwaysOriginal), 168 | for: .normal 169 | ) 170 | } else { 171 | viewModel.playSong(track: viewModel.track.preview) 172 | playButton.setImage( 173 | UIImage( 174 | systemName: "pause.fill", 175 | withConfiguration: largeConfig)?.withTintColor(.black, renderingMode: .alwaysOriginal), 176 | for: .normal 177 | ) 178 | } 179 | } 180 | 181 | @objc func tapped(_ sender: AnyObject) { 182 | if viewModel.isPlaying() { 183 | playButton.setImage( 184 | UIImage( 185 | systemName: "play.circle", 186 | withConfiguration: largeConfig)?.withTintColor(.black, renderingMode: .alwaysOriginal), 187 | for: .normal) 188 | } else { 189 | playButton.setImage( 190 | UIImage( 191 | systemName: "pause.circle", 192 | withConfiguration: largeConfig)?.withTintColor(.black, renderingMode: .alwaysOriginal), 193 | for: .normal) 194 | } 195 | } 196 | 197 | func setupConstraints() { 198 | NSLayoutConstraint.activate([ 199 | stackView.topAnchor.constraint(equalTo: view.topAnchor), 200 | stackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), 201 | stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), 202 | stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), 203 | playButton.heightAnchor.constraint(equalToConstant: 50) 204 | ]) 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /DeezerProject/Interface/Screens/Track/TrackViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TrackViewModel.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 04/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol TrackViewModelProtocol { 11 | var track: Track {get} 12 | func playSong(track: String) 13 | func pauseSong() 14 | func isPlaying() -> Bool 15 | var image: String? {get} 16 | func revealMiniPlayer() 17 | func hideMiniPlayer() 18 | func isFavourite() -> Bool 19 | func deleteFavourite(completion: (() -> Void)?) 20 | func addFavourite(completion: (() -> Void)?) 21 | } 22 | 23 | class TrackViewModel: TrackViewModelProtocol { 24 | let browseInteractor: BrowseInteractorProtocol 25 | let track: Track 26 | let image: String? 27 | let flow: TrackFlowProtocol 28 | 29 | init ( 30 | flow: TrackFlowProtocol, 31 | browseInteractor: BrowseInteractorProtocol = BrowseInteractor(), 32 | track: Track, 33 | image: String? 34 | ) { 35 | self.browseInteractor = browseInteractor 36 | self.track = track 37 | self.image = image 38 | self.flow = flow 39 | } 40 | 41 | func deleteFavourite(completion: (() -> Void)?) { 42 | browseInteractor.deleteTrack(track: track, completion: completion) 43 | } 44 | 45 | func addFavourite(completion: (() -> Void)?) { 46 | if let image = image { 47 | let imageTrack = track.addArtistImage(image: image) 48 | browseInteractor.saveTrack(track: imageTrack, completion: completion) 49 | } else { 50 | browseInteractor.saveTrack(track: track, completion: completion) 51 | } 52 | } 53 | 54 | func isFavourite() -> Bool { 55 | return browseInteractor.getTracks().first { $0.id == track.id } != nil 56 | } 57 | 58 | func revealMiniPlayer() { 59 | if TrackPlayer.shared.playing { 60 | flow.showMusicBar() 61 | } 62 | } 63 | 64 | func hideMiniPlayer() { 65 | flow.hideMusicBar() 66 | } 67 | 68 | func pauseSong() { 69 | TrackPlayer.shared.pause() 70 | } 71 | 72 | func playSong(track: String) { 73 | if let url = URL(string: track) { 74 | TrackPlayer.shared.play(url: url) 75 | } 76 | } 77 | 78 | func isPlaying() -> Bool { 79 | TrackPlayer.shared.playing 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /DeezerProject/LayoutSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LayoutSection.swift 3 | // CompositionalLayouts 4 | // 5 | // Created by Steven Curtis on 26/06/2020. 6 | // Copyright © 2020 Steven Curtis. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | protocol LayoutSection { 12 | var title: String { get set } 13 | var layoutSection: NSCollectionLayoutSection {get} 14 | func configureCell(collectionView: UICollectionView, 15 | indexPath: IndexPath, 16 | item: AnyHashable, 17 | position: Int 18 | ) -> UICollectionViewCell 19 | func header(collectionView: UICollectionView, 20 | indexPath: IndexPath, 21 | title: String, 22 | buttonTitle: String?, 23 | action: @escaping (Int) -> Void 24 | ) -> UICollectionReusableView 25 | } 26 | -------------------------------------------------------------------------------- /DeezerProject/Models/AlbumSearch/AlbumSearch.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlbumSearch.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 04/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | struct AlbumSearchApiDto: Decodable { 11 | let id: Int 12 | let title: String 13 | let cover: String 14 | let coverSmall: String 15 | let coverMedium: String 16 | let coverBig: String 17 | let coverXl: String 18 | let md5Image: String 19 | let genreId: Int 20 | let nbTracks: Int 21 | let recordType: String 22 | let tracklist: String 23 | let explicitLyrics: Bool 24 | let artist: AlbumSearchApiDto.Artist 25 | func toDomain() -> AlbumSearch { 26 | .init( 27 | id: id, 28 | title: title, 29 | cover: cover, 30 | coverSmall: coverSmall, 31 | coverMedium: coverMedium, 32 | coverBig: coverBig, 33 | coverX1: coverXl, 34 | md5Image: md5Image, 35 | genreId: genreId, 36 | nbTracks: nbTracks, 37 | recordType: recordType, 38 | tracklist: tracklist, 39 | explicitLyrics: explicitLyrics, 40 | artist: artist.toDomain() 41 | ) 42 | } 43 | 44 | struct Artist: Decodable { 45 | let id: Int 46 | let name: String 47 | let link: String 48 | let picture: String 49 | let pictureSmall: String 50 | let pictureMedium: String 51 | let pictureBig: String 52 | let pictureXl: String 53 | func toDomain() -> AlbumSearch.Artist { 54 | .init( 55 | id: id, 56 | name: name, 57 | link: link, 58 | picture: picture, 59 | pictureSmall: pictureSmall, 60 | pictureMedium: pictureMedium, 61 | pictureBig: pictureBig, 62 | pictureXl: pictureXl 63 | ) 64 | } 65 | } 66 | } 67 | 68 | public struct AlbumSearch: Hashable { 69 | let id: Int 70 | let title: String 71 | let cover: String 72 | let coverSmall: String 73 | let coverMedium: String 74 | let coverBig: String 75 | let coverX1: String 76 | let md5Image: String 77 | let genreId: Int 78 | let nbTracks: Int 79 | let recordType: String 80 | let tracklist: String 81 | let explicitLyrics: Bool 82 | let artist: AlbumSearch.Artist 83 | 84 | struct Artist: Hashable { 85 | let id: Int 86 | let name: String 87 | let link: String 88 | let picture: String 89 | let pictureSmall: String 90 | let pictureMedium: String 91 | let pictureBig: String 92 | let pictureXl: String 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /DeezerProject/Models/ArtistSearch/ArtistSearch.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArtistSearch.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 04/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | struct ArtistSearchApiDto: Decodable { 11 | let id: Int 12 | let name: String 13 | let link: String? 14 | let picture: String 15 | let pictureSmall: String 16 | let pictureMedium: String 17 | let pictureBig: String 18 | let pictureXl: String 19 | let nbAlbum: Int? 20 | let nbFan: Int? 21 | let radio: Bool 22 | let tracklist: String 23 | let type: String 24 | func toDomain() -> ArtistSearch { 25 | .init( 26 | id: id, 27 | name: name, 28 | link: link, 29 | picture: picture, 30 | pictureSmall: pictureSmall, 31 | pictureMedium: pictureMedium, 32 | pictureBig: pictureBig, 33 | pictureXl: pictureXl, 34 | nbAlbum: nbAlbum, 35 | nbFan: nbFan, 36 | radio: radio, 37 | tracklist: tracklist, 38 | type: type 39 | ) 40 | } 41 | } 42 | 43 | public struct ArtistSearch: Hashable { 44 | let id: Int 45 | let name: String 46 | let link: String? 47 | let picture: String 48 | let pictureSmall: String 49 | let pictureMedium: String 50 | let pictureBig: String 51 | let pictureXl: String 52 | let nbAlbum: Int? 53 | let nbFan: Int? 54 | let radio: Bool? 55 | let tracklist: String 56 | let type: String 57 | } 58 | -------------------------------------------------------------------------------- /DeezerProject/Models/Bridging/DependenciesContainerProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DependenciesContainerProtocol.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 26/02/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol DependenciesContainerProtocol { 11 | var errorHandler: ErrorHandlerProtocol { get } 12 | } 13 | -------------------------------------------------------------------------------- /DeezerProject/Models/Bridging/ErrorHandlerProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorHandlerProtocol.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 26/02/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol ErrorHandlerProtocol { 11 | func handle(error: Error) 12 | } 13 | -------------------------------------------------------------------------------- /DeezerProject/Models/Chart/Chart.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Chart.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 01/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | struct ChartApiDto: Decodable { 11 | let tracks: TracksApiDto 12 | 13 | struct TracksApiDto: Decodable { 14 | let data: [TrackApiDto] 15 | let total: Int 16 | } 17 | } 18 | 19 | public struct TrackApiDto: Decodable { 20 | let id: Int 21 | let title: String 22 | let titleShort: String 23 | let titleVersion: String? 24 | let link: String? 25 | let duration: Int 26 | let rank: Int 27 | let explicitLyrics: Bool 28 | let explicitContentLyrics: Int? 29 | let explicitContentCover: Int 30 | let preview: String 31 | let md5Image: String 32 | let position: Int? 33 | let artist: ArtistApiDto? 34 | func toDomain() -> Track { 35 | return .init( 36 | id: id, 37 | title: title, 38 | titleShort: titleShort, 39 | titleVersion: titleVersion, 40 | link: link, 41 | duration: duration, 42 | rank: rank, 43 | explicitLyrics: explicitLyrics, 44 | explicitContentLyrics: explicitContentLyrics, 45 | explicitContentCover: explicitContentCover, 46 | preview: preview, 47 | md5Image: md5Image, 48 | position: position, 49 | artist: artist?.toDomain() 50 | ) 51 | } 52 | } 53 | 54 | extension ChartApiDto { 55 | func toDomain() -> Chart { 56 | return .init( 57 | tracks: tracks.toDomain() 58 | ) 59 | } 60 | } 61 | 62 | extension ChartApiDto.TracksApiDto { 63 | func toDomain() -> Tracks { 64 | return .init(data: data.compactMap {$0.toDomain()}, total: total) 65 | } 66 | } 67 | 68 | struct ArtistApiDto: Decodable { 69 | let id: Int 70 | let name: String 71 | let link: String? 72 | let picture: String? 73 | let pictureSmall: String? 74 | let pictureMedium: String? 75 | let pictureBig: String? 76 | let pictureXl: String? 77 | let radio: Bool? 78 | func toDomain() -> Artist { 79 | .init( 80 | id: id, 81 | name: name, 82 | link: link, 83 | picture: picture, 84 | pictureSmall: pictureSmall, 85 | pictureMedium: pictureMedium, 86 | pictureBig: pictureBig, 87 | pictureXl: pictureXl, 88 | radio: radio 89 | ) 90 | } 91 | } 92 | 93 | struct AlbumApiDto: Decodable { 94 | let id: Int 95 | let title: String 96 | let upc: String 97 | let link: String 98 | let share: String 99 | let cover: String 100 | let coverSmall: String 101 | let coverMedium: String 102 | let coverBig: String 103 | let coverX1: String 104 | let md5Image: String 105 | func toDomain() -> Album { 106 | .init( 107 | id: id, 108 | title: title, 109 | upc: upc, 110 | link: link, 111 | share: share, 112 | cover: cover, 113 | coverSmall: coverSmall, 114 | coverMedium: coverMedium, 115 | coverBig: coverBig, 116 | coverX1: coverX1, 117 | md5Image: md5Image 118 | ) 119 | } 120 | } 121 | 122 | public struct Tracks: Hashable { 123 | let data: [Track] 124 | let total: Int 125 | } 126 | 127 | public struct Track: Hashable { 128 | public static func == (lhs: Track, rhs: Track) -> Bool { 129 | lhs.id == rhs.id 130 | } 131 | let id: Int 132 | let title: String 133 | let titleShort: String 134 | let titleVersion: String? 135 | let link: String? 136 | let duration: Int 137 | let rank: Int 138 | let explicitLyrics: Bool 139 | let explicitContentLyrics: Int? 140 | let explicitContentCover: Int 141 | let preview: String 142 | let md5Image: String 143 | let position: Int? 144 | let artist: Artist? 145 | 146 | func addArtistImage(image: String) -> Track { 147 | guard let musicArtist = artist else {return self} 148 | let changedArtist = Artist( 149 | id: musicArtist.id, 150 | name: musicArtist.name, 151 | link: musicArtist.link, 152 | picture: musicArtist.picture, 153 | pictureSmall: musicArtist.pictureSmall, 154 | pictureMedium: image, 155 | pictureBig: musicArtist.pictureBig, 156 | pictureXl: musicArtist.pictureXl, 157 | radio: musicArtist.radio 158 | ) 159 | return Track( 160 | id: id, 161 | title: title, 162 | titleShort: titleShort, 163 | titleVersion: titleVersion, 164 | link: link, 165 | duration: duration, 166 | rank: rank, 167 | explicitLyrics: explicitLyrics, 168 | explicitContentLyrics: explicitContentLyrics, 169 | explicitContentCover: explicitContentCover, 170 | preview: preview, 171 | md5Image: md5Image, 172 | position: position, 173 | artist: changedArtist 174 | ) 175 | } 176 | } 177 | 178 | public struct Chart: Hashable { 179 | let tracks: Tracks 180 | } 181 | 182 | struct Artist: Hashable { 183 | let id: Int 184 | let name: String 185 | let link: String? 186 | let picture: String? 187 | let pictureSmall: String? 188 | let pictureMedium: String? 189 | let pictureBig: String? 190 | let pictureXl: String? 191 | let radio: Bool? 192 | } 193 | 194 | struct Album: Hashable { 195 | let id: Int 196 | let title: String 197 | let upc: String 198 | let link: String 199 | let share: String 200 | let cover: String 201 | let coverSmall: String 202 | let coverMedium: String 203 | let coverBig: String 204 | let coverX1: String 205 | let md5Image: String 206 | } 207 | -------------------------------------------------------------------------------- /DeezerProject/Models/Chart/DBTrackStorage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DBTrack.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 08/03/2021. 6 | // 7 | 8 | import Foundation 9 | import CoreData 10 | 11 | @objcMembers 12 | final class DBTrackStorage: NSManagedObject { 13 | static var entityName: String { 14 | return "Track" 15 | } 16 | 17 | @NSManaged var id: NSNumber 18 | @NSManaged var title: String 19 | @NSManaged var titleShort: String 20 | @NSManaged var link: String 21 | @NSManaged var duration: NSNumber 22 | @NSManaged var rank: NSNumber 23 | @NSManaged var explicitLyrics: Bool 24 | @NSManaged var explicitContentCover: NSNumber 25 | @NSManaged var preview: String 26 | @NSManaged var md5Image: String 27 | @NSManaged var pictureMedium: String 28 | } 29 | 30 | extension DBTrackStorage { 31 | func update(from dto: TrackApiDto) { 32 | id = NSNumber(value: dto.id) 33 | title = dto.title 34 | titleShort = dto.titleShort 35 | link = dto.link ?? "" 36 | duration = NSNumber(value: dto.duration) 37 | rank = NSNumber(value: dto.rank) 38 | pictureMedium = dto.artist?.pictureMedium ?? "" 39 | } 40 | 41 | func toDto() -> TrackApiDto { 42 | return TrackApiDto.init( 43 | id: id.intValue, 44 | title: title, 45 | titleShort: titleShort, 46 | titleVersion: nil, 47 | link: link, 48 | duration: duration.intValue, 49 | rank: rank.intValue, 50 | explicitLyrics: explicitLyrics, 51 | explicitContentLyrics: nil, 52 | explicitContentCover: 0, 53 | preview: preview, 54 | md5Image: md5Image, 55 | position: nil, 56 | artist: .init( 57 | id: id.intValue, 58 | name: "", 59 | link: nil, 60 | picture: nil, 61 | pictureSmall: nil, 62 | pictureMedium: pictureMedium, 63 | pictureBig: nil, 64 | pictureXl: nil, 65 | radio: nil 66 | ) 67 | ) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /DeezerProject/Models/Genre/Genre.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Genre.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 01/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GenreApiDto: Decodable { 11 | let id: Int 12 | let name: String 13 | let picture: String 14 | let pictureSmall: String 15 | let pictureMedium: String 16 | let pictureBig: String 17 | let pictureXl: String 18 | let type: String 19 | } 20 | 21 | extension GenreApiDto { 22 | func toDomain() -> Genre? { 23 | return .init( 24 | id: id, 25 | name: name, 26 | picture: picture, 27 | pictureSmall: pictureSmall, 28 | pictureMedium: pictureMedium, 29 | pictureBig: pictureBig, 30 | pictureXl: pictureXl, 31 | type: type 32 | ) 33 | } 34 | } 35 | 36 | public struct Genre: Hashable { 37 | let id: Int 38 | let name: String 39 | let picture: String 40 | let pictureSmall: String 41 | let pictureMedium: String 42 | let pictureBig: String 43 | let pictureXl: String 44 | let type: String 45 | 46 | public init( 47 | id: Int, 48 | name: String, 49 | picture: String, 50 | pictureSmall: String, 51 | pictureMedium: String, 52 | pictureBig: String, 53 | pictureXl: String, 54 | type: String 55 | ) { 56 | self.id = id 57 | self.name = name 58 | self.picture = picture 59 | self.pictureSmall = pictureSmall 60 | self.pictureMedium = pictureMedium 61 | self.pictureBig = pictureBig 62 | self.pictureXl = pictureXl 63 | self.type = type 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /DeezerProject/Models/TrackStoreDto.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TrackStoreDto.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 10/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | struct TrackStoreDto: Equatable { 11 | let id: Int 12 | let title: String 13 | let titleShort: String 14 | let titleVersion: String? 15 | let link: String? 16 | let duration: Int 17 | let rank: Int 18 | let explicitLyrics: Bool 19 | let explicitContentLyrics: Int? 20 | let explicitContentCover: Int 21 | let preview: String 22 | let md5Image: String 23 | let position: Int? 24 | 25 | func toDomain() -> Track { 26 | return .init( 27 | id: id, 28 | title: title, 29 | titleShort: titleShort, 30 | titleVersion: titleVersion, 31 | link: link, 32 | duration: duration, 33 | rank: rank, 34 | explicitLyrics: explicitLyrics, 35 | explicitContentLyrics: explicitContentLyrics, 36 | explicitContentCover: explicitContentCover, 37 | preview: preview, 38 | md5Image: md5Image, 39 | position: position, 40 | artist: nil 41 | ) 42 | } 43 | } 44 | 45 | extension TrackStoreDto { 46 | init?(favourite: TrackApiDto) { 47 | self.id = favourite.id 48 | self.title = favourite.title 49 | self.titleShort = favourite.titleShort 50 | self.titleVersion = favourite.titleVersion 51 | self.link = favourite.link 52 | self.duration = favourite.duration 53 | self.rank = favourite.rank 54 | self.explicitLyrics = favourite.explicitLyrics 55 | self.explicitContentCover = favourite.explicitContentCover 56 | self.explicitContentLyrics = favourite.explicitContentLyrics 57 | self.preview = favourite.preview 58 | self.md5Image = favourite.md5Image 59 | self.position = favourite.position 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /DeezerProject/MusicPlayer/MiniPlayer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MiniPlayer.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 04/03/2021. 6 | // 7 | 8 | import UIKit 9 | 10 | class MiniPlayer: UIView { 11 | override init(frame: CGRect) { 12 | super.init(frame: frame) 13 | setupView() 14 | } 15 | 16 | required init?(coder aDecoder: NSCoder) { 17 | super.init(coder: aDecoder) 18 | setupView() 19 | } 20 | 21 | private func setupView() { 22 | let blurEffect = UIBlurEffect(style: UIBlurEffect.Style.light) 23 | let blurEffectView = UIVisualEffectView(effect: blurEffect) 24 | blurEffectView.frame = bounds 25 | blurEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight] 26 | addSubview(blurEffectView) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /DeezerProject/MusicPlayer/MusicTabBar/MusicTabBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusicTabBar.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 04/03/2021. 6 | // 7 | 8 | import UIKit 9 | 10 | public class MusicTabBar: UITabBarController, UITabBarControllerDelegate { 11 | private let mini = MiniPlayer() 12 | lazy private var playButton = UIButton() 13 | private let largeConfig = UIImage.SymbolConfiguration(pointSize: 40, weight: .medium, scale: .medium) 14 | 15 | public override func viewDidLoad() { 16 | super.viewDidLoad() 17 | self.delegate = self 18 | } 19 | 20 | public func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) { 21 | if TrackPlayer.shared.playing { 22 | mini.isHidden = false 23 | } 24 | } 25 | 26 | init() { 27 | super.init(nibName: nil, bundle: nil) 28 | mini.translatesAutoresizingMaskIntoConstraints = false 29 | view.addSubview(mini) 30 | mini.addSubview(playButton) 31 | 32 | playButton.setImage( 33 | UIImage( 34 | systemName: "pause.fill", 35 | withConfiguration: largeConfig)?.withTintColor(.black, renderingMode: .alwaysOriginal 36 | ), 37 | for: .normal 38 | ) 39 | playButton.addTarget(self, action: #selector(tapped(_:)), for: .touchDown) 40 | 41 | playButton.translatesAutoresizingMaskIntoConstraints = false 42 | NSLayoutConstraint.activate([ 43 | mini.bottomAnchor.constraint(equalTo: tabBar.topAnchor), 44 | mini.leadingAnchor.constraint(equalTo: view.leadingAnchor), 45 | mini.trailingAnchor.constraint(equalTo: view.trailingAnchor), 46 | mini.heightAnchor.constraint(equalToConstant: 50), 47 | playButton.centerXAnchor.constraint(equalTo: mini.centerXAnchor), 48 | playButton.centerYAnchor.constraint(equalTo: mini.centerYAnchor), 49 | playButton.heightAnchor.constraint(equalToConstant: 50), 50 | playButton.widthAnchor.constraint(equalToConstant: 200) 51 | ]) 52 | mini.isHidden = true 53 | } 54 | 55 | @objc func tapped(_ sender: AnyObject) { 56 | if TrackPlayer.shared.playing { 57 | playButton.setImage( 58 | UIImage( 59 | systemName: "play.circle", 60 | withConfiguration: largeConfig)?.withTintColor(.black, renderingMode: .alwaysOriginal), 61 | for: .normal 62 | ) 63 | toggleMusic() 64 | } else { 65 | playButton.setImage( 66 | UIImage( 67 | systemName: "pause.circle", 68 | withConfiguration: largeConfig)?.withTintColor(.black, renderingMode: .alwaysOriginal), 69 | for: .normal 70 | ) 71 | toggleMusic() 72 | } 73 | } 74 | 75 | func toggleMiniPlayer() { 76 | mini.isHidden = !mini.isHidden 77 | } 78 | 79 | func toggleMusic() { 80 | if TrackPlayer.shared.playing { 81 | TrackPlayer.shared.pause() 82 | } else { 83 | TrackPlayer.shared.playCurrent() 84 | } 85 | } 86 | 87 | func hideMusicPlayer() { 88 | mini.isHidden = true 89 | } 90 | 91 | func showMusicPlayer() { 92 | mini.isHidden = false 93 | } 94 | 95 | func isPlaying() -> Bool { 96 | TrackPlayer.shared.playing 97 | } 98 | 99 | required init?(coder: NSCoder) { 100 | fatalError("init(coder:) has not been implemented") 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /DeezerProject/MusicPlayer/TrackPlayer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TrackPlayer.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 04/03/2021. 6 | // 7 | 8 | import Foundation 9 | import AVFoundation 10 | 11 | protocol TrackPlayerProtocol { 12 | static var shared: TrackPlayerProtocol {get} 13 | func play(url: URL) 14 | func pause() 15 | var playing: Bool { get } 16 | func playCurrent() 17 | } 18 | 19 | class TrackPlayer: TrackPlayerProtocol { 20 | static let shared: TrackPlayerProtocol = TrackPlayer() 21 | 22 | private init() { } 23 | 24 | var playing = false 25 | var avPlayer: AVPlayer = AVPlayer() 26 | var avPlayerLayer: AVPlayerLayer! 27 | var paused: Bool = false 28 | 29 | func play(url: URL) { 30 | let playerItem: AVPlayerItem = AVPlayerItem(url: url) 31 | avPlayer = AVPlayer(playerItem: playerItem) 32 | avPlayer.playImmediately(atRate: 1) 33 | playing = true 34 | } 35 | 36 | func playCurrent() { 37 | avPlayer.play() 38 | } 39 | 40 | func pause() { 41 | avPlayer.pause() 42 | playing = false 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /DeezerProject/Resources/DeezerProject.xcdatamodeld/.xccurrentversion: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | _XCCurrentVersionName 6 | DeezerProject.xcdatamodel 7 | 8 | 9 | -------------------------------------------------------------------------------- /DeezerProject/Resources/DeezerProject.xcdatamodeld/DeezerProject.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /DeezerProject/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 26/02/2021. 6 | // 7 | 8 | import UIKit 9 | 10 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 11 | 12 | var window: UIWindow? 13 | var browseFlow: BrowseFlow? 14 | var searchFlow: SearchFlow? 15 | 16 | func scene( 17 | _ scene: UIScene, 18 | willConnectTo session: UISceneSession, 19 | options connectionOptions: UIScene.ConnectionOptions 20 | ) { 21 | guard let windowScene = (scene as? UIWindowScene) else { return } 22 | window = UIWindow(windowScene: windowScene) 23 | 24 | let tabBar = MusicTabBar() 25 | let browseNavigationController = UINavigationController() 26 | browseNavigationController.navigationBar.prefersLargeTitles = true 27 | 28 | let searchNavigationController = UINavigationController() 29 | searchNavigationController.navigationBar.prefersLargeTitles = true 30 | 31 | browseNavigationController.tabBarItem.image = UIImage(systemName: "square.grid.2x2.fill") 32 | browseNavigationController.tabBarItem.title = "Favorites" 33 | 34 | browseFlow = BrowseFlow( 35 | router: FlowRoutingService( 36 | navigationController: browseNavigationController, 37 | tabController: tabBar 38 | ) 39 | ) 40 | tabBar.addChild(browseNavigationController) 41 | 42 | searchNavigationController.navigationController?.navigationBar.prefersLargeTitles = true 43 | searchNavigationController.tabBarItem.image = UIImage(systemName: "magnifyingglass") 44 | searchNavigationController.tabBarItem.title = "Search" 45 | 46 | searchFlow = SearchFlow( 47 | router: FlowRoutingService( 48 | navigationController: searchNavigationController, 49 | tabController: tabBar 50 | ) 51 | ) 52 | 53 | tabBar.addChild(searchNavigationController) 54 | 55 | window?.rootViewController = tabBar 56 | window?.makeKeyAndVisible() 57 | 58 | browseFlow?.runFlow() 59 | searchFlow?.runFlow() 60 | } 61 | // swiftlint:disable:all 62 | func sceneDidDisconnect(_ scene: UIScene) { 63 | // Called as the scene is being released by the system. 64 | // This occurs shortly after the scene enters the background, or when its session is discarded. 65 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 66 | // The scene may re-connect later, 67 | // as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). 68 | } 69 | 70 | func sceneDidBecomeActive(_ scene: UIScene) { 71 | // Called when the scene has moved from an inactive state to an active state. 72 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 73 | } 74 | 75 | func sceneWillResignActive(_ scene: UIScene) { 76 | // Called when the scene will move from an active state to an inactive state. 77 | // This may occur due to temporary interruptions (ex. an incoming phone call). 78 | } 79 | 80 | func sceneWillEnterForeground(_ scene: UIScene) { 81 | // Called as the scene transitions from the background to the foreground. 82 | // Use this method to undo the changes made on entering the background. 83 | } 84 | 85 | func sceneDidEnterBackground(_ scene: UIScene) { 86 | // Called as the scene transitions from the foreground to the background. 87 | // Use this method to save data, release shared resources, and store enough scene-specific state information 88 | // to restore the scene back to its current state. 89 | 90 | // Save changes in the application's managed object context when the application transitions to the background. 91 | (UIApplication.shared.delegate as? AppDelegate)?.saveContext() 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /DeezerProject/Sections/BrowseSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FavouritesSection.swift 3 | // FavouriteProjectsSection 4 | // 5 | // Created by Steven Curtis on 22/02/2021. 6 | // 7 | 8 | import UIKit 9 | 10 | struct BrowseSection: BrowseLayoutSectionProtocol, HeaderSectionProtocol { 11 | var title: String 12 | var buttonTitle: String? 13 | let onTap: ((Int) -> Void)? 14 | let onFavouriteTap: ((Int) -> Void)? 15 | 16 | func configureCell( 17 | collectionView: UICollectionView, 18 | indexPath: IndexPath, 19 | item: BrowseSectionData, 20 | position: Int 21 | ) -> UICollectionViewCell { 22 | guard let cell = collectionView.dequeueReusableCell( 23 | withReuseIdentifier: String( 24 | describing: SongCollectionViewCell.self 25 | ), 26 | for: indexPath 27 | ) as? SongCollectionViewCell else { 28 | fatalError("Unable to create new cell") 29 | } 30 | switch item { 31 | case .chart(let chart): 32 | cell.configure( 33 | with: chart.title, 34 | pictureURLString: chart.artist?.pictureMedium, 35 | onTap: { 36 | onTap?(indexPath.row) 37 | } 38 | ) 39 | case .genre(let genre): 40 | cell.configure( 41 | with: genre.name, 42 | pictureURLString: genre.pictureMedium, 43 | onTap: { 44 | onTap?(indexPath.row) 45 | } 46 | ) 47 | case .favourite(let favourite): 48 | cell.configure( 49 | with: favourite.title, 50 | pictureURLString: favourite.artist?.pictureMedium, 51 | onTap: { 52 | onTap?(indexPath.row) 53 | }, 54 | topRightAction: ButtonModel.init( 55 | action: { 56 | onFavouriteTap?(indexPath.row) 57 | }, 58 | icon: "heart.fill" 59 | ) 60 | ) 61 | } 62 | return cell 63 | } 64 | 65 | var layoutSection: NSCollectionLayoutSection = { 66 | let itemSize = NSCollectionLayoutSize( 67 | widthDimension: .fractionalWidth(0.33), 68 | heightDimension: .fractionalWidth(0.33) 69 | ) 70 | let item = NSCollectionLayoutItem(layoutSize: itemSize) 71 | item.contentInsets = .init(top: 5, leading: 5, bottom: 5, trailing: 5) 72 | let groupSize = NSCollectionLayoutSize( 73 | widthDimension: .fractionalWidth(0.92), 74 | heightDimension: .fractionalWidth(0.33) 75 | ) 76 | let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) 77 | let section = NSCollectionLayoutSection(group: group) 78 | 79 | section.contentInsets = .init(top: 0, leading: 10, bottom: 0, trailing: 10) 80 | section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary 81 | 82 | let headerSize = NSCollectionLayoutSize( 83 | widthDimension: .fractionalWidth(1.0), 84 | heightDimension: .absolute(70) 85 | ) 86 | let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem( 87 | layoutSize: headerSize, 88 | elementKind: UICollectionView.elementKindSectionHeader, 89 | alignment: .top) 90 | 91 | section.boundarySupplementaryItems = [sectionHeader] 92 | 93 | return section 94 | }() 95 | 96 | func header( 97 | collectionView: UICollectionView, 98 | indexPath: IndexPath, 99 | title: String, 100 | buttonTitle: String?, 101 | action: @escaping () -> Void 102 | ) -> UICollectionReusableView { 103 | let header = collectionView.dequeueReusableSupplementaryView( 104 | ofKind: UICollectionView.elementKindSectionHeader, 105 | withReuseIdentifier: String(describing: TitleSupplementaryView.self), 106 | for: indexPath 107 | ) 108 | if let hdr = header as? TitleSupplementaryView { 109 | hdr.configure(with: 110 | .init( 111 | title: title.description 112 | , 113 | button: ButtonModel( 114 | title: buttonTitle, 115 | action: {action()} 116 | ) 117 | ) 118 | ) 119 | } 120 | return header 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /DeezerProject/Sections/GridSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GridSection.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 03/03/2021. 6 | // 7 | 8 | import UIKit 9 | 10 | struct GridSection: BrowseLayoutSectionProtocol { 11 | let onTap: ((Int) -> Void)? 12 | let onFavouriteTap: ((Int) -> Void)? 13 | 14 | func configureCell( 15 | collectionView: UICollectionView, 16 | indexPath: IndexPath, 17 | item: BrowseSectionData, 18 | position: Int 19 | ) -> UICollectionViewCell { 20 | guard let cell = collectionView.dequeueReusableCell( 21 | withReuseIdentifier: String( 22 | describing: SongCollectionViewCell.self 23 | ), 24 | for: indexPath 25 | ) as? SongCollectionViewCell else { 26 | fatalError("Unable to create new cell") 27 | } 28 | switch item { 29 | case .chart(let chart): 30 | cell.configure( 31 | with: chart.title, 32 | pictureURLString: chart.artist?.pictureMedium, 33 | onTap: { 34 | onTap?(indexPath.row) 35 | } 36 | ) 37 | case .genre(let genre): 38 | cell.configure( 39 | with: genre.name, 40 | pictureURLString: genre.pictureMedium, 41 | onTap: { 42 | onTap?(indexPath.row) 43 | } 44 | ) 45 | case .favourite: 46 | break 47 | } 48 | return cell 49 | } 50 | 51 | var layoutSection: NSCollectionLayoutSection = { 52 | let itemSize = NSCollectionLayoutSize( 53 | widthDimension: .absolute(150), 54 | heightDimension: .absolute(150) 55 | ) 56 | let item = NSCollectionLayoutItem(layoutSize: itemSize) 57 | item.contentInsets = .init(top: 5, leading: 5, bottom: 5, trailing: 5) 58 | let groupSize = NSCollectionLayoutSize( 59 | widthDimension: .fractionalWidth(0.92), 60 | heightDimension: .fractionalWidth(0.33) 61 | ) 62 | let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) 63 | let section = NSCollectionLayoutSection(group: group) 64 | section.contentInsets = .init(top: 0, leading: 10, bottom: 0, trailing: 10) 65 | section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary 66 | 67 | let headerSize = NSCollectionLayoutSize( 68 | widthDimension: .fractionalWidth(1.0), 69 | heightDimension: .absolute(70) 70 | ) 71 | let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem( 72 | layoutSize: headerSize, 73 | elementKind: UICollectionView.elementKindSectionHeader, 74 | alignment: .top) 75 | 76 | section.boundarySupplementaryItems = [sectionHeader] 77 | 78 | return section 79 | }() 80 | 81 | } 82 | -------------------------------------------------------------------------------- /DeezerProject/Sections/LayoutSectionProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LayoutSection.swift 3 | // CompositionalLayouts 4 | // 5 | // Created by Steven Curtis on 26/06/2020. 6 | // Copyright © 2020 Steven Curtis. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | protocol HeaderSectionProtocol { 12 | var title: String { get set } 13 | var buttonTitle: String? { get set } 14 | func header( 15 | collectionView: UICollectionView, 16 | indexPath: IndexPath, 17 | title: String, 18 | buttonTitle: String?, 19 | action: @escaping () -> Void 20 | ) -> UICollectionReusableView 21 | } 22 | 23 | protocol BrowseLayoutSectionProtocol { 24 | var layoutSection: NSCollectionLayoutSection {get} 25 | func configureCell( 26 | collectionView: UICollectionView, 27 | indexPath: IndexPath, 28 | item: BrowseSectionData, 29 | position: Int 30 | ) -> UICollectionViewCell 31 | } 32 | 33 | protocol SearchLayoutSectionProtocol { 34 | var layoutSection: NSCollectionLayoutSection {get} 35 | func configureCell( 36 | collectionView: UICollectionView, 37 | indexPath: IndexPath, 38 | item: SearchSectionData, 39 | position: Int 40 | ) -> UICollectionViewCell 41 | } 42 | -------------------------------------------------------------------------------- /DeezerProject/Sections/SearchSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchSection.swift 3 | // DeezerProject 4 | // 5 | // Created by Steven Curtis on 04/03/2021. 6 | // 7 | 8 | import UIKit 9 | 10 | struct SearchSection: SearchLayoutSectionProtocol, HeaderSectionProtocol { 11 | var title: String 12 | var buttonTitle: String? 13 | let onTap: ((Int) -> Void)? 14 | let onFavouriteTap: ((Int) -> Void)? 15 | 16 | func configureCell( 17 | collectionView: UICollectionView, 18 | indexPath: IndexPath, 19 | item: SearchSectionData, 20 | position: Int 21 | ) -> UICollectionViewCell { 22 | guard let cell = collectionView.dequeueReusableCell( 23 | withReuseIdentifier: String( 24 | describing: SongCollectionViewCell.self 25 | ), 26 | for: indexPath 27 | ) as? SongCollectionViewCell else { 28 | fatalError("Unable to create new cell") 29 | } 30 | switch item { 31 | case .albums(let album): 32 | cell.configure( 33 | with: album.title, 34 | pictureURLString: album.coverMedium, 35 | onTap: {onTap?(indexPath.row)} 36 | ) 37 | case .artist(let artist): 38 | cell.configure( 39 | with: artist.name, 40 | pictureURLString: artist.pictureMedium, 41 | onTap: {onTap?(indexPath.row)} 42 | ) 43 | case .tracks(let track): 44 | cell.configure( 45 | with: track.title, 46 | pictureURLString: track.artist?.pictureMedium, 47 | onTap: {onTap?(indexPath.row)} 48 | ) 49 | } 50 | return cell 51 | } 52 | 53 | var layoutSection: NSCollectionLayoutSection = { 54 | let itemSize = NSCollectionLayoutSize( 55 | widthDimension: .fractionalWidth(0.33), 56 | heightDimension: .fractionalWidth(0.33) 57 | ) 58 | let item = NSCollectionLayoutItem(layoutSize: itemSize) 59 | item.contentInsets = .init(top: 5, leading: 5, bottom: 5, trailing: 5) 60 | let groupSize = NSCollectionLayoutSize( 61 | widthDimension: .fractionalWidth(0.92), 62 | heightDimension: .fractionalWidth(0.33) 63 | ) 64 | let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) 65 | let section = NSCollectionLayoutSection(group: group) 66 | 67 | section.contentInsets = .init(top: 0, leading: 10, bottom: 0, trailing: 10) 68 | section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary 69 | 70 | let headerSize = NSCollectionLayoutSize( 71 | widthDimension: .fractionalWidth(1.0), 72 | heightDimension: .absolute(70) 73 | ) 74 | let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem( 75 | layoutSize: headerSize, 76 | elementKind: UICollectionView.elementKindSectionHeader, 77 | alignment: .top) 78 | 79 | section.boundarySupplementaryItems = [sectionHeader] 80 | 81 | return section 82 | }() 83 | 84 | func header( 85 | collectionView: UICollectionView, 86 | indexPath: IndexPath, 87 | title: String, 88 | buttonTitle: String?, 89 | action: @escaping () -> Void 90 | ) -> UICollectionReusableView { 91 | let header = collectionView.dequeueReusableSupplementaryView( 92 | ofKind: UICollectionView.elementKindSectionHeader, 93 | withReuseIdentifier: String(describing: TitleSupplementaryView.self), 94 | for: indexPath 95 | ) 96 | if let hdr = header as? TitleSupplementaryView { 97 | hdr.configure(with: 98 | .init( 99 | title: title.description 100 | , 101 | button: ButtonModel( 102 | title: buttonTitle, 103 | action: {action()} 104 | ) 105 | ) 106 | ) 107 | } 108 | return header 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /DeezerProject/Views/ButtonModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ButtonModel.swift 3 | // FavouriteProjectsSection 4 | // 5 | // Created by Steven Curtis on 22/02/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct ButtonModel { 11 | public let title: String? 12 | public let accessibilityIdentifier: String? 13 | public let action: () -> Void 14 | public let icon: String? 15 | 16 | public init( 17 | title: String? = nil, 18 | accessibilityIdentifier: String? = nil, 19 | action: @escaping () -> Void, 20 | icon: String? = nil 21 | ) { 22 | self.title = title 23 | self.accessibilityIdentifier = accessibilityIdentifier 24 | self.action = action 25 | self.icon = icon 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /DeezerProject/Views/HeaderContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HeaderContent.swift 3 | // CompositionalLayouts 4 | // 5 | // Created by Steven Curtis on 26/06/2020. 6 | // Copyright © 2020 Steven Curtis. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct HeaderContent { 12 | let title: String 13 | let button: ButtonModel? 14 | var subtitle: String? 15 | 16 | init(title: String, button: ButtonModel? = nil, subtitle: String? = nil) { 17 | self.title = title 18 | self.button = button 19 | self.subtitle = subtitle 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /DeezerProject/Views/SongCollectionViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkCollectionViewCell.swift 3 | // FavouriteProjectsSection 4 | // 5 | // Created by Steven Curtis on 19/02/2021. 6 | // 7 | 8 | import UIKit 9 | import SDWebImage 10 | 11 | class SongCollectionViewCell: UICollectionViewCell { 12 | private enum Constants { 13 | static let placeholderImage = "placeholder" 14 | } 15 | lazy var imageView = UIImageView() 16 | lazy var heartImageView = UIImageView() 17 | 18 | lazy var titleLabel = UILabel() 19 | lazy var backgroundTitleView = UIView() 20 | 21 | var topRightTap: (() -> Void)? 22 | var onTap: (() -> Void)? 23 | var topRightAction: ButtonModel? 24 | 25 | public func configure( 26 | with title: String, 27 | pictureURLString: String?, 28 | onTap: (() -> Void)?, 29 | topRightAction: ButtonModel? = nil 30 | ) { 31 | self.onTap = onTap 32 | titleLabel.text = title 33 | if let picture = pictureURLString, let imgURL = URL(string: picture) { 34 | imageView.sd_setImage( 35 | with: imgURL 36 | ) 37 | } 38 | if let action = topRightAction { 39 | topRightTap = action.action 40 | if let icon = action.icon { 41 | heartImageView.image = ( 42 | UIImage( 43 | systemName: icon 44 | )?.withTintColor( 45 | .red, 46 | renderingMode: .alwaysOriginal 47 | ) 48 | ) 49 | } 50 | } 51 | } 52 | 53 | override init(frame: CGRect) { 54 | super.init(frame: frame) 55 | 56 | addSubview(imageView) 57 | imageView.translatesAutoresizingMaskIntoConstraints = false 58 | imageView.image = UIImage(named: Constants.placeholderImage) 59 | imageView.contentMode = .scaleAspectFit 60 | imageView.isUserInteractionEnabled = true 61 | imageView.clipsToBounds = true 62 | 63 | addSubview(heartImageView) 64 | heartImageView.contentMode = .scaleAspectFit 65 | heartImageView.translatesAutoresizingMaskIntoConstraints = false 66 | heartImageView.isUserInteractionEnabled = true 67 | let tapGesture = UITapGestureRecognizer(target: self, action: #selector(getButtonTapped)) 68 | heartImageView.addGestureRecognizer(tapGesture) 69 | let cellTapGesture = UITapGestureRecognizer(target: self, action: #selector(cellTapped)) 70 | imageView.addGestureRecognizer(cellTapGesture) 71 | 72 | addSubview(backgroundTitleView) 73 | backgroundTitleView.translatesAutoresizingMaskIntoConstraints = false 74 | backgroundTitleView.backgroundColor = .systemGray 75 | backgroundTitleView.alpha = 0.8 76 | 77 | addSubview(titleLabel) 78 | titleLabel.textAlignment = .center 79 | titleLabel.translatesAutoresizingMaskIntoConstraints = false 80 | titleLabel.numberOfLines = 0 81 | titleLabel.adjustsFontSizeToFitWidth = true 82 | titleLabel.textColor = .white 83 | 84 | self.clipsToBounds = true 85 | self.layer.cornerRadius = 15 86 | 87 | setupConstraints() 88 | } 89 | 90 | func setupConstraints() { 91 | let titleLeadingConstraint = titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10) 92 | let titleTrailingConstraint = titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10) 93 | 94 | NSLayoutConstraint.activate([ 95 | heartImageView.topAnchor.constraint(equalTo: topAnchor, constant: 5), 96 | heartImageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -5), 97 | heartImageView.heightAnchor.constraint(equalToConstant: 30), 98 | heartImageView.widthAnchor.constraint(equalToConstant: 30), 99 | imageView.topAnchor.constraint(equalTo: topAnchor), 100 | imageView.bottomAnchor.constraint(equalTo: bottomAnchor), 101 | imageView.trailingAnchor.constraint(equalTo: trailingAnchor), 102 | imageView.leadingAnchor.constraint(equalTo: leadingAnchor), 103 | titleLeadingConstraint, 104 | titleTrailingConstraint, 105 | titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor), 106 | titleLabel.heightAnchor.constraint(equalToConstant: 25), 107 | backgroundTitleView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor), 108 | backgroundTitleView.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor), 109 | backgroundTitleView.bottomAnchor.constraint(equalTo: bottomAnchor), 110 | backgroundTitleView.heightAnchor.constraint(equalToConstant: 25) 111 | ]) 112 | 113 | titleLeadingConstraint.priority = .defaultHigh 114 | titleTrailingConstraint.priority = .defaultHigh 115 | } 116 | 117 | @IBAction func getButtonTapped(_ sender: UIButton) { 118 | topRightTap?() 119 | } 120 | 121 | @objc func didTouchDown(gesture: UILongPressGestureRecognizer) { 122 | if gesture.state == .ended { 123 | topRightTap?() 124 | } 125 | } 126 | 127 | @IBAction func cellTapped() { 128 | onTap?() 129 | } 130 | 131 | required init?(coder: NSCoder) { 132 | fatalError("init(coder:) has not been implemented") 133 | } 134 | 135 | override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) 136 | -> UICollectionViewLayoutAttributes { 137 | return layoutAttributes 138 | } 139 | 140 | override func prepareForReuse() { 141 | heartImageView.sd_cancelCurrentImageLoad() 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /DeezerProject/Views/TitleSupplementaryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppsCompositionalLayout.swift 3 | // CompositionalLayouts 4 | // 5 | // Created by Steven Curtis on 25/06/2020. 6 | // Copyright © 2020 Steven Curtis. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class TitleSupplementaryView: UICollectionReusableView { 12 | private enum Constants { 13 | static let inset = CGFloat(20) 14 | } 15 | 16 | private var button = UIButton(type: .system) 17 | private let titleLabel = UILabel(frame: .zero) 18 | private let stackView = UIStackView(frame: .zero) 19 | 20 | var titleButtonAction: (() -> Void)? 21 | 22 | override init(frame: CGRect) { 23 | super.init(frame: frame) 24 | sharedinit() 25 | } 26 | required init?(coder: NSCoder) { 27 | fatalError("Instantiation from Storyboard not supported") 28 | } 29 | } 30 | 31 | extension TitleSupplementaryView { 32 | public func configure(with content: HeaderContent) { 33 | if let buttonModel = content.button { 34 | button.setTitle(buttonModel.title, for: .normal) 35 | titleButtonAction = buttonModel.action 36 | button.addTarget(self, action: #selector(getButtonTapped(_:)), for: .touchUpInside) 37 | button.isHidden = false 38 | } else { 39 | button.isHidden = true 40 | } 41 | titleLabel.text = content.title 42 | } 43 | 44 | func sharedinit() { 45 | button.titleLabel?.font = UIFont.systemFont(ofSize: 15.0, weight: .light) 46 | button.titleLabel?.textColor = .link 47 | button.translatesAutoresizingMaskIntoConstraints = false 48 | button.contentHorizontalAlignment = .right 49 | button.isUserInteractionEnabled = true 50 | 51 | addSubview(stackView) 52 | 53 | stackView.addArrangedSubview(titleLabel) 54 | stackView.addArrangedSubview(button) 55 | 56 | titleLabel.font = UIFont.systemFont(ofSize: 22.0, weight: .bold) 57 | titleLabel.textColor = .label 58 | titleLabel.translatesAutoresizingMaskIntoConstraints = false 59 | 60 | stackView.translatesAutoresizingMaskIntoConstraints = false 61 | stackView.isLayoutMarginsRelativeArrangement = true 62 | stackView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 0, trailing: 10) 63 | 64 | stackView.axis = .vertical 65 | 66 | NSLayoutConstraint.activate([ 67 | stackView.topAnchor.constraint(equalTo: topAnchor), 68 | stackView.bottomAnchor.constraint(equalTo: bottomAnchor), 69 | stackView.leadingAnchor.constraint(equalTo: leadingAnchor), 70 | stackView.trailingAnchor.constraint(equalTo: trailingAnchor) 71 | ]) 72 | } 73 | 74 | @IBAction func getButtonTapped(_ sender: UIButton) { 75 | titleButtonAction?() 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /DeezerProjectTests/DeezerProjectTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeezerProjectTests.swift 3 | // DeezerProjectTests 4 | // 5 | // Created by Steven Curtis on 26/02/2021. 6 | // 7 | 8 | import XCTest 9 | @testable import DeezerProject 10 | 11 | class DeezerProjectTests: XCTestCase { 12 | 13 | override func setUpWithError() throws { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | } 16 | 17 | override func tearDownWithError() throws { 18 | // Put teardown code here. This method is called after the invocation of each test method in the class. 19 | } 20 | 21 | func testExample() throws { 22 | // This is an example of a functional test case. 23 | // Use XCTAssert and related functions to verify your tests produce the correct results. 24 | } 25 | 26 | func testPerformanceExample() throws { 27 | // This is an example of a performance test case. 28 | self.measure { 29 | // Put the code you want to measure the time of here. 30 | } 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /DeezerProjectTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /DeezerProjectUITests/DeezerProjectUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeezerProjectUITests.swift 3 | // DeezerProjectUITests 4 | // 5 | // Created by Steven Curtis on 26/02/2021. 6 | // 7 | 8 | import XCTest 9 | 10 | class DeezerProjectUITests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | 15 | // In UI tests it is usually best to stop immediately when a failure occurs. 16 | continueAfterFailure = false 17 | 18 | // In UI tests it’s important to set the initial state - such as interface orientation - 19 | // required for your tests before they run. The setUp method is a good place to do this. 20 | } 21 | 22 | override func tearDownWithError() throws { 23 | // Put teardown code here. This method is called after the invocation of each test method in the class. 24 | } 25 | 26 | func testExample() throws { 27 | // UI tests must launch the application that they test. 28 | let app = XCUIApplication() 29 | app.launch() 30 | 31 | // Use recording to get started writing UI tests. 32 | // Use XCTAssert and related functions to verify your tests produce the correct results. 33 | } 34 | 35 | func testLaunchPerformance() throws { 36 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) { 37 | // This measures how long it takes to launch your application. 38 | measure(metrics: [XCTApplicationLaunchMetric()]) { 39 | XCUIApplication().launch() 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /DeezerProjectUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Images/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevencurtis/DeezerMVVMArchitectureExample/6385b94301fac677a59abf8674125b61ec88f305/Images/architecture.png -------------------------------------------------------------------------------- /Images/vid.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevencurtis/DeezerMVVMArchitectureExample/6385b94301fac677a59abf8674125b61ec88f305/Images/vid.gif -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A Music App using the DeezerAPI 2 | 3 | This is an example application that uses the DeezerAPI, in order to show an MVVM architecture. 4 | 5 | ![Video](Images/vid.gif)
6 | 7 | The purpose of this application is to demonstrate the use of flow coordinators, interactors and repositories. The favourites section is stored in CoreData 8 | 9 | ## Installation 10 | Download the files either from the command line, or download from GitHub's green button in the GUI interface. Dependencies are managed by Swift Package Manager, and can be reset in Xcode through `File> Swift Packacges> Reset Package Caches`. More specifically, the **dependencies** I've used are my own `NetworkLibrary` and `SDWebImage`. 11 | 12 | ## Architecture 13 | Note this architecture does not use [two-way bindings](https://stevenpcurtis.medium.com/implement-two-way-uikit-binding-in-vanilla-swift-5261d15c918) as the focus here is on Interactors and Repositories.
14 | ![Architecture](Images/architecture.png)
15 | 16 | ## Limitations 17 | This App is intended to be an example of the architecture rather than a production ready music application. 18 | The App can only play a preview of songs, rather than full applications. 19 | The API used is deprecated (and the documentation no longer available), and as such the application could stop working at any time. 20 | --------------------------------------------------------------------------------