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