diff --git a/.DS_Store b/.DS_Store index a6402b6..f49d40b 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/SpotifyClone.xcodeproj/project.xcworkspace/xcuserdata/stephenchacha.xcuserdatad/UserInterfaceState.xcuserstate b/SpotifyClone.xcodeproj/project.xcworkspace/xcuserdata/stephenchacha.xcuserdatad/UserInterfaceState.xcuserstate index 31563e6..b0e6bdc 100644 Binary files a/SpotifyClone.xcodeproj/project.xcworkspace/xcuserdata/stephenchacha.xcuserdatad/UserInterfaceState.xcuserstate and b/SpotifyClone.xcodeproj/project.xcworkspace/xcuserdata/stephenchacha.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/SpotifyClone/Core/Configuration/AppConfiguration.swift b/SpotifyClone/Core/Configuration/AppConfiguration.swift new file mode 100644 index 0000000..66064c7 --- /dev/null +++ b/SpotifyClone/Core/Configuration/AppConfiguration.swift @@ -0,0 +1,69 @@ +// +// AppConfiguration.swift +// SpotifyClone +// +// Created for code improvements +// + +import Foundation + +/// Centralized configuration for the application +enum AppConfiguration { + /// Spotify API configuration + enum Spotify { + static let baseURL = "https://api.spotify.com/v1" + static let authURL = "https://accounts.spotify.com" + static var tokenURL: String { + return "\(authURL)/api/token" + } + + static var clientID: String { + guard let path = Bundle.main.path(forResource: "Config", ofType: "plist"), + let plist = NSDictionary(contentsOfFile: path) as? [String: Any], + let id = plist["SpotifyClientID"] as? String, !id.isEmpty else { + fatalError("SpotifyClientID not found in Config.plist") + } + return id + } + + static var clientSecret: String { + guard let path = Bundle.main.path(forResource: "Config", ofType: "plist"), + let plist = NSDictionary(contentsOfFile: path) as? [String: Any], + let secret = plist["SpotifyClientSecret"] as? String, !secret.isEmpty else { + fatalError("SpotifyClientSecret not found in Config.plist") + } + return secret + } + + static var redirectURI: String { + guard let path = Bundle.main.path(forResource: "Config", ofType: "plist"), + let plist = NSDictionary(contentsOfFile: path) as? [String: Any], + let uri = plist["SpotifyRedirectURI"] as? String else { + return "http://localhost:3000/callback" + } + return uri + } + } + + /// UserDefaults keys + enum UserDefaultsKeys { + static let accessToken = "access_token" + static let refreshToken = "refresh_token" + static let expirationDate = "expiration_date" + } + + /// Cache configuration + enum Cache { + static let maxMemorySize = 50 * 1024 * 1024 // 50MB + static let maxDiskSize = 200 * 1024 * 1024 // 200MB + static let expirationInterval: TimeInterval = 3600 // 1 hour + } + + /// Network configuration + enum Network { + static let timeoutInterval: TimeInterval = 30 + static let maxRetries = 3 + static let retryBaseDelay: TimeInterval = 1.0 + } +} + diff --git a/SpotifyClone/Core/Error/ApiError.swift b/SpotifyClone/Core/Error/ApiError.swift index 4e5a609..e372507 100644 --- a/SpotifyClone/Core/Error/ApiError.swift +++ b/SpotifyClone/Core/Error/ApiError.swift @@ -21,7 +21,7 @@ enum ApiError: LocalizedError { case apiError(String) case decodingError(String) case unknownError(String) - case rateLimitExceeded + case rateLimitExceeded(retryAfter: String? = nil) case jsonSerializationFailed case jsonParsingFailed @@ -59,4 +59,68 @@ enum ApiError: LocalizedError { return "JSON parsing failed" } } + + /// User-friendly error message for display in UI + var userMessage: String { + switch self { + case .code: + return "Authentication failed. Please try logging in again." + case .tokenNotFound: + return "Your session has expired. Please log in again." + case .invalidInput: + return "Invalid input. Please check your search and try again." + case .invalidURL: + return "Something went wrong. Please try again." + case .failedToGetData: + return "Unable to load data. Please check your internet connection." + case .decodeError, .decodingError: + return "Unable to process the response. Please try again." + case .rateLimitExceeded(let retryAfter): + if let retryAfter = retryAfter, let seconds = Int(retryAfter) { + return "Too many requests. Please wait \(seconds) seconds and try again." + } + return "Too many requests. Please wait a moment and try again." + case .invalidResponse(let statusCode): + switch statusCode { + case 401: + return "Your session has expired. Please log in again." + case 403: + return "You don't have permission to access this." + case 404: + return "The requested item was not found." + case 429: + return "Too many requests. Please wait a moment." + case 500...599: + return "Server error. Please try again later." + default: + return "Something went wrong. Please try again." + } + case .noGenresAvailable: + return "No genres are currently available." + case .apiError(let message): + return "Error: \(message)" + case .encodingError(let message): + return "Encoding error: \(message)" + case .unknownError(let message): + return "An unexpected error occurred: \(message)" + case .jsonSerializationFailed, .jsonParsingFailed: + return "Unable to process the data. Please try again." + } + } + + /// Suggested recovery action for the error + var recoveryAction: String? { + switch self { + case .tokenNotFound, .code: + return "Log In" + case .failedToGetData: + return "Retry" + case .rateLimitExceeded: + return "Wait" + case .invalidResponse(let statusCode) where statusCode == 401: + return "Log In" + default: + return nil + } + } } diff --git a/SpotifyClone/Core/Logging/Logger.swift b/SpotifyClone/Core/Logging/Logger.swift new file mode 100644 index 0000000..9c56f0b --- /dev/null +++ b/SpotifyClone/Core/Logging/Logger.swift @@ -0,0 +1,126 @@ +// +// Logger.swift +// SpotifyClone +// +// Created for centralized logging +// + +import Foundation +import os.log + +/// Log levels for different types of messages +enum LogLevel { + case debug + case info + case warning + case error + + var osLogType: OSLogType { + switch self { + case .debug: return .debug + case .info: return .info + case .warning: return .default + case .error: return .error + } + } + + var prefix: String { + switch self { + case .debug: return "🔍 DEBUG" + case .info: return "â„šī¸ INFO" + case .warning: return "âš ī¸ WARNING" + case .error: return "❌ ERROR" + } + } +} + +/// Centralized logging utility +final class Logger { + static let shared = Logger() + + private let subsystem: String + private let category = "SpotifyClone" + private let log: OSLog + + #if DEBUG + private let isDebugMode = true + #else + private let isDebugMode = false + #endif + + private init() { + subsystem = Bundle.main.bundleIdentifier ?? "com.spotifyclone" + log = OSLog(subsystem: subsystem, category: category) + } + + /// Log a message with specified level + func log( + _ message: String, + level: LogLevel = .info, + file: String = #file, + function: String = #function, + line: Int = #line + ) { + let fileName = (file as NSString).lastPathComponent + let logMessage = "[\(fileName):\(line)] \(function) - \(message)" + + // Skip debug logs in release builds + if level == .debug && !isDebugMode { + return + } + + // Log to OSLog (visible in Console.app) + os_log("%{public}@", log: log, type: level.osLogType, logMessage) + + #if DEBUG + // Print to console in debug mode + print("\(level.prefix) - \(logMessage)") + #endif + } + + /// Log a debug message + func debug( + _ message: String, + file: String = #file, + function: String = #function, + line: Int = #line + ) { + log(message, level: .debug, file: file, function: function, line: line) + } + + /// Log an info message + func info( + _ message: String, + file: String = #file, + function: String = #function, + line: Int = #line + ) { + log(message, level: .info, file: file, function: function, line: line) + } + + /// Log a warning message + func warning( + _ message: String, + file: String = #file, + function: String = #function, + line: Int = #line + ) { + log(message, level: .warning, file: file, function: function, line: line) + } + + /// Log an error message + func error( + _ message: String, + error: Error? = nil, + file: String = #file, + function: String = #function, + line: Int = #line + ) { + var fullMessage = message + if let error = error { + fullMessage += " - Error: \(error.localizedDescription)" + } + log(fullMessage, level: .error, file: file, function: function, line: line) + } +} + diff --git a/SpotifyClone/Core/Networking/NetworkMonitor.swift b/SpotifyClone/Core/Networking/NetworkMonitor.swift new file mode 100644 index 0000000..9a436a5 --- /dev/null +++ b/SpotifyClone/Core/Networking/NetworkMonitor.swift @@ -0,0 +1,59 @@ +// +// NetworkMonitor.swift +// SpotifyClone +// +// Created for network reachability monitoring +// + +import Foundation +import Network + +/// Delegate protocol for network status changes +protocol NetworkMonitorDelegate: AnyObject { + func networkStatusChanged(isConnected: Bool) +} + +/// Monitors network connectivity status +final class NetworkMonitor { + static let shared = NetworkMonitor() + + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "com.spotifyclone.networkmonitor") + + /// Current network connection status + private(set) var isConnected: Bool = false + + /// Delegate to notify about network status changes + weak var delegate: NetworkMonitorDelegate? + + private init() { + startMonitoring() + } + + /// Start monitoring network connectivity + func startMonitoring() { + monitor.pathUpdateHandler = { [weak self] path in + let wasConnected = self?.isConnected ?? false + self?.isConnected = path.status == .satisfied + + // Notify delegate if status changed + if wasConnected != self?.isConnected { + DispatchQueue.main.async { + self?.delegate?.networkStatusChanged(isConnected: self?.isConnected ?? false) + } + } + } + monitor.start(queue: queue) + } + + /// Stop monitoring network connectivity + func stopMonitoring() { + monitor.cancel() + } + + /// Check if network is currently available + func checkConnectivity() -> Bool { + return isConnected + } +} + diff --git a/SpotifyClone/Extensions/UIViewController+ErrorDisplay.swift b/SpotifyClone/Extensions/UIViewController+ErrorDisplay.swift new file mode 100644 index 0000000..80691bf --- /dev/null +++ b/SpotifyClone/Extensions/UIViewController+ErrorDisplay.swift @@ -0,0 +1,46 @@ +// +// UIViewController+ErrorDisplay.swift +// SpotifyClone +// +// Created for error display utilities +// + +import UIKit + +extension UIViewController { + /// Display an error alert with user-friendly message + func showError(_ error: Error, title: String = "Error", completion: (() -> Void)? = nil) { + let message: String + let recoveryAction: String? + + if let apiError = error as? ApiError { + message = apiError.userMessage + recoveryAction = apiError.recoveryAction + } else { + message = error.localizedDescription + recoveryAction = nil + } + + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + + if let recoveryAction = recoveryAction { + alert.addAction(UIAlertAction(title: recoveryAction, style: .default) { _ in + completion?() + }) + } else { + alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in + completion?() + }) + } + + present(alert, animated: true) + } + + /// Display a success message + func showSuccess(_ message: String, title: String = "Success") { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + } +} + diff --git a/SpotifyClone/Managers/AlbumApi.swift b/SpotifyClone/Managers/AlbumApi.swift index 20d687a..131f3ff 100644 --- a/SpotifyClone/Managers/AlbumApi.swift +++ b/SpotifyClone/Managers/AlbumApi.swift @@ -5,7 +5,6 @@ // Created by stephen chacha on 25/12/2024. // import Foundation -import UIKit final class AlbumApiCaller { static let shared = AlbumApiCaller() @@ -251,22 +250,6 @@ final class AlbumApiCaller { } - // Helper function to get the top-most view controller - func topMostViewController() -> UIViewController? { - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first(where: { $0.isKeyWindow }) { - var topController = window.rootViewController - - while let presentedVC = topController?.presentedViewController { - topController = presentedVC - } - - return topController - } - return nil - } - - // MARK: - Helper Methods private func createAndExecuteRequest( with url: URL?, @@ -288,24 +271,16 @@ final class AlbumApiCaller { } if let httpResponse = response as? HTTPURLResponse { - print("Status Code: \(httpResponse.statusCode)") + Logger.shared.debug("HTTP Status Code: \(httpResponse.statusCode)") if httpResponse.statusCode == 429 { - if let retryAfterString = httpResponse.value(forHTTPHeaderField: "Retry-After"), - let retryAfter = Double(retryAfterString) { - print("Rate limit exceeded. Retrying after \(retryAfter) seconds.") + let retryAfterString = httpResponse.value(forHTTPHeaderField: "Retry-After") + let error = ApiError.rateLimitExceeded(retryAfter: retryAfterString) + + // If retry count allows and we have retry-after header, retry automatically + if retryCount > 0, let retryAfterString = retryAfterString, let retryAfter = Double(retryAfterString) { + Logger.shared.warning("Rate limit exceeded. Retrying after \(retryAfter) seconds.") - DispatchQueue.main.async { - let alert = UIAlertController( - title: "Rate Limit Exceeded", - message: "Please wait for \(Int(retryAfter)) seconds before trying again.", - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) - if let viewController = self.topMostViewController() { - viewController.present(alert, animated: true, completion: nil) - } - } DispatchQueue.global().asyncAfter(deadline: .now() + retryAfter) { self.createAndExecuteRequest( with: url, @@ -315,6 +290,9 @@ final class AlbumApiCaller { completion: completion ) } + } else { + // No retries left or no retry-after header, return error + completion(.failure(error)) } return } @@ -324,17 +302,14 @@ final class AlbumApiCaller { } } - // Debugging: Log raw data -// if let jsonString = String(data: data, encoding: .utf8) { -// print("Response JSON: \(jsonString)") -// } -// do { let result = try JSONDecoder().decode(decodingType, from: data) + Logger.shared.debug("Successfully decoded response") completion(.success(result)) } catch { if let responseString = String(data: data, encoding: .utf8) { - print("Raw Response: \(responseString)") + Logger.shared.error("Decoding error", error: error) + Logger.shared.debug("Raw Response: \(responseString.prefix(500))") } completion(.failure(.decodingError(error.localizedDescription))) } diff --git a/SpotifyClone/Managers/AuthManager.swift b/SpotifyClone/Managers/AuthManager.swift index c3de944..d8e8787 100644 --- a/SpotifyClone/Managers/AuthManager.swift +++ b/SpotifyClone/Managers/AuthManager.swift @@ -15,32 +15,20 @@ final class AuthManager { // MARK: - Constants struct Constants { - private static var config: [String: Any]? { - guard let path = Bundle.main.path(forResource: "Config", ofType: "plist"), - let plist = NSDictionary(contentsOfFile: path) as? [String: Any] else { - fatalError("Config.plist not found. Please copy Config.example.plist to Config.plist and add your Spotify API credentials.") - } - return plist - } - static var clientID: String { - guard let id = config?["SpotifyClientID"] as? String, !id.isEmpty else { - fatalError("SpotifyClientID not found in Config.plist") - } - return id + return AppConfiguration.Spotify.clientID } static var clientSecret: String { - guard let secret = config?["SpotifyClientSecret"] as? String, !secret.isEmpty else { - fatalError("SpotifyClientSecret not found in Config.plist") - } - return secret + return AppConfiguration.Spotify.clientSecret } - static let tokenAPIURL = "https://accounts.spotify.com/api/token" + static var tokenAPIURL: String { + return AppConfiguration.Spotify.tokenURL + } static var redirectURI: String { - return config?["SpotifyRedirectURI"] as? String ?? "http://localhost:3000/callback" + return AppConfiguration.Spotify.redirectURI } static let rawScopes = [ @@ -83,15 +71,15 @@ final class AuthManager { // MARK: - Variables public var accessToken: String? { - return UserDefaults.standard.string(forKey: "access_token") + return UserDefaults.standard.string(forKey: AppConfiguration.UserDefaultsKeys.accessToken) } private var refreshToken: String? { - return UserDefaults.standard.string(forKey: "refresh_token") + return UserDefaults.standard.string(forKey: AppConfiguration.UserDefaultsKeys.refreshToken) } private var tokenExpirationDate: Date? { - return UserDefaults.standard.object(forKey: "expiration_date") as? Date + return UserDefaults.standard.object(forKey: AppConfiguration.UserDefaultsKeys.expirationDate) as? Date } private var shouldRefreshToken: Bool { @@ -231,19 +219,19 @@ final class AuthManager { // MARK: - Cache Tokens private func cacheToken(result: AuthResponse) { - UserDefaults.standard.setValue(result.access_token, forKey: "access_token") + UserDefaults.standard.setValue(result.access_token, forKey: AppConfiguration.UserDefaultsKeys.accessToken) if let refreshToken = result.refresh_token { - UserDefaults.standard.setValue(refreshToken, forKey: "refresh_token") + UserDefaults.standard.setValue(refreshToken, forKey: AppConfiguration.UserDefaultsKeys.refreshToken) } let expirationDate = Date().addingTimeInterval(TimeInterval(result.expires_in)) - UserDefaults.standard.setValue(expirationDate, forKey: "expiration_date") + UserDefaults.standard.setValue(expirationDate, forKey: AppConfiguration.UserDefaultsKeys.expirationDate) } - // MARK: - Cache Tokens + // MARK: - Sign Out public func signOut(completion: (Bool)->Void ) { - UserDefaults.standard.setValue(nil, forKey: "access_token") - UserDefaults.standard.setValue(nil, forKey: "refresh_token") - UserDefaults.standard.setValue(nil, forKey: "expiration_date") + UserDefaults.standard.setValue(nil, forKey: AppConfiguration.UserDefaultsKeys.accessToken) + UserDefaults.standard.setValue(nil, forKey: AppConfiguration.UserDefaultsKeys.refreshToken) + UserDefaults.standard.setValue(nil, forKey: AppConfiguration.UserDefaultsKeys.expirationDate) completion(true) } diff --git a/SpotifyClone/Managers/ChapterApiCaller.swift b/SpotifyClone/Managers/ChapterApiCaller.swift index 3c8f0e0..2e10ae2 100644 --- a/SpotifyClone/Managers/ChapterApiCaller.swift +++ b/SpotifyClone/Managers/ChapterApiCaller.swift @@ -7,7 +7,6 @@ import Foundation -import UIKit final class ChapterApiCaller { @@ -84,23 +83,12 @@ final class ChapterApiCaller { // Handle Rate Limit Exceeded (429) if httpResponse.statusCode == 429 { - if let retryAfterString = httpResponse.value(forHTTPHeaderField: "Retry-After"), - let retryAfter = Double(retryAfterString) { - print("Rate limit exceeded. Retrying after \(retryAfter) seconds.") - - // Show feedback to the user that a wait is required - DispatchQueue.main.async { - let alert = UIAlertController( - title: "Rate Limit Exceeded", - message: "Please wait for \(Int(retryAfter)) seconds before trying again.", - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) - // Present alert to the user - if let viewController = self.topMostViewController() { - viewController.present(alert, animated: true, completion: nil) - } - } + let retryAfterString = httpResponse.value(forHTTPHeaderField: "Retry-After") + let error = ApiError.rateLimitExceeded(retryAfter: retryAfterString) + + // If retry count allows and we have retry-after header, retry automatically + if retryCount > 0, let retryAfterString = retryAfterString, let retryAfter = Double(retryAfterString) { + Logger.shared.warning("Rate limit exceeded. Retrying after \(retryAfter) seconds.") // Wait before retrying based on Retry-After header DispatchQueue.global().asyncAfter(deadline: .now() + retryAfter) { @@ -112,6 +100,9 @@ final class ChapterApiCaller { completion: completion ) } + } else { + // No retries left or no retry-after header, return error + completion(.failure(error)) } return } @@ -122,18 +113,15 @@ final class ChapterApiCaller { } } - // Debugging: Log raw data - if let jsonString = String(data: data, encoding: .utf8) { - print("Response JSON: \(jsonString)") - } - do { let result = try JSONDecoder().decode(responseType, from: data) + Logger.shared.debug("Successfully decoded response for endpoint: \(endpoint)") completion(.success(result)) } catch { // Log raw response for debugging if let responseString = String(data: data, encoding: .utf8) { - print("Raw Response: \(responseString)") + Logger.shared.error("Decoding error for endpoint: \(endpoint)", error: error) + Logger.shared.debug("Raw Response: \(responseString.prefix(500))") } completion(.failure(ApiError.decodingError(error.localizedDescription))) } @@ -142,23 +130,6 @@ final class ChapterApiCaller { } } - // Helper function to get the top-most view controller - func topMostViewController() -> UIViewController? { - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first(where: { $0.isKeyWindow }) { - var topController = window.rootViewController - - while let presentedVC = topController?.presentedViewController { - topController = presentedVC - } - - return topController - } - return nil - } - - - // MARK: - Fetch Shows public func getSeveralShows( ids: [String], diff --git a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png b/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png deleted file mode 100644 index 42e0187..0000000 Binary files a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png and /dev/null differ diff --git a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png b/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png deleted file mode 100644 index 8b526ed..0000000 Binary files a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png and /dev/null differ diff --git a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png b/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png deleted file mode 100644 index 00f809c..0000000 Binary files a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png and /dev/null differ diff --git a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png b/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png deleted file mode 100644 index b80cafd..0000000 Binary files a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png and /dev/null differ diff --git a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-38@2x.png b/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-38@2x.png deleted file mode 100644 index e180951..0000000 Binary files a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-38@2x.png and /dev/null differ diff --git a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-38@3x.png b/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-38@3x.png deleted file mode 100644 index b4ec33f..0000000 Binary files a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-38@3x.png and /dev/null differ diff --git a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png b/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png deleted file mode 100644 index a09cfc9..0000000 Binary files a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png and /dev/null differ diff --git a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png b/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png deleted file mode 100644 index 3261544..0000000 Binary files a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png and /dev/null differ diff --git a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png b/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png deleted file mode 100644 index 3261544..0000000 Binary files a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png and /dev/null differ diff --git a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png b/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png deleted file mode 100644 index 79b8467..0000000 Binary files a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png and /dev/null differ diff --git a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-64@2x.png b/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-64@2x.png deleted file mode 100644 index e1d41b1..0000000 Binary files a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-64@2x.png and /dev/null differ diff --git a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-64@3x.png b/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-64@3x.png deleted file mode 100644 index 9f7168b..0000000 Binary files a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-64@3x.png and /dev/null differ diff --git a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-68@2x.png b/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-68@2x.png deleted file mode 100644 index 591a321..0000000 Binary files a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-68@2x.png and /dev/null differ diff --git a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png b/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png deleted file mode 100644 index 00f83dc..0000000 Binary files a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png and /dev/null differ diff --git a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-83_5@2x.png b/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-83_5@2x.png deleted file mode 100644 index 472c1f6..0000000 Binary files a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/icon-83_5@2x.png and /dev/null differ diff --git a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/ios-marketing.png b/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/ios-marketing.png deleted file mode 100644 index 7c1cd50..0000000 Binary files a/SpotifyClone/Resources/Assets.xcassets/AppIcon.appiconset/ios-marketing.png and /dev/null differ diff --git a/SpotifyClone/Resources/Assets.xcassets/logo.imageset/spotify.png b/SpotifyClone/Resources/Assets.xcassets/logo.imageset/spotify.png deleted file mode 100644 index 58ed0f1..0000000 Binary files a/SpotifyClone/Resources/Assets.xcassets/logo.imageset/spotify.png and /dev/null differ diff --git a/docs/ACTION_ITEMS.md b/docs/ACTION_ITEMS.md new file mode 100644 index 0000000..6202937 --- /dev/null +++ b/docs/ACTION_ITEMS.md @@ -0,0 +1,46 @@ +# Action Items - Priority Checklist + +## 🔴 Critical (Do First - This Week) + +- [ ] **Fix SpotifyAPIClient** - Verify it compiles and works correctly +- [ ] **Remove UI code from API layer** - Remove `UIAlertController` from `ChapterApiCaller` and `AlbumApi` +- [ ] **Migrate API managers to SpotifyAPIClient** - Start with `TrackApi`, `PlaylistApi`, `SearchApi` +- [ ] **Fix token refresh race condition** - Improve `AuthManager.withValidToken` + +## 🟡 High Priority (Next 2 Weeks) + +- [ ] **Add unit tests** - Start with `AuthManager` and `SpotifyAPIClient` (target: 10 tests) +- [ ] **Implement proper caching** - Enhance `DataCache` with expiration and image caching +- [ ] **Add NetworkMonitor** - Check network availability before requests +- [ ] **Standardize error handling** - Use `Result` everywhere +- [ ] **Create AppConfiguration** - Centralize all constants and URLs + +## đŸŸĸ Medium Priority (Next Month) + +- [ ] **Reorganize features** - Move to `Features/` structure +- [ ] **Add dependency injection** - Create protocols for managers +- [ ] **Add logging framework** - Replace `print()` statements +- [ ] **Add SwiftLint** - Enforce code style +- [ ] **Audit memory leaks** - Check all closures for retain cycles + +## đŸ”ĩ Features (Ongoing) + +- [ ] **Mini Player** - Persistent bottom player +- [ ] **Queue Management** - View and manage playback queue +- [ ] **Offline Downloads** - Download for offline playback +- [ ] **Widget Support** - Home screen widgets + +## 📝 Quick Wins (Can do today) + +- [ ] Add SwiftLint configuration (30 min) +- [ ] Create `AppConfiguration.swift` (1 hour) +- [ ] Add basic `NetworkMonitor` (2 hours) +- [ ] Write 3 unit tests for `AuthManager` (2 hours) +- [ ] Remove UI code from `ChapterApiCaller` (30 min) + +--- + +**Total Estimated Time for Critical Items**: ~16 hours +**Total Estimated Time for High Priority**: ~40 hours +**Total Estimated Time for Medium Priority**: ~60 hours + diff --git a/docs/CODE_IMPROVEMENTS_EXAMPLES.md b/docs/CODE_IMPROVEMENTS_EXAMPLES.md new file mode 100644 index 0000000..b51f1b4 --- /dev/null +++ b/docs/CODE_IMPROVEMENTS_EXAMPLES.md @@ -0,0 +1,543 @@ +# Code Improvement Examples + +This document provides concrete code examples for implementing the recommended improvements. + +## 1. AppConfiguration (Centralized Constants) + +**File**: `SpotifyClone/Core/Configuration/AppConfiguration.swift` + +```swift +import Foundation + +enum AppConfiguration { + enum Spotify { + static let baseURL = "https://api.spotify.com/v1" + static let authURL = "https://accounts.spotify.com" + static let tokenURL = "\(authURL)/api/token" + + static var clientID: String { + guard let path = Bundle.main.path(forResource: "Config", ofType: "plist"), + let plist = NSDictionary(contentsOfFile: path) as? [String: Any], + let id = plist["SpotifyClientID"] as? String, !id.isEmpty else { + fatalError("SpotifyClientID not found in Config.plist") + } + return id + } + + static var clientSecret: String { + guard let path = Bundle.main.path(forResource: "Config", ofType: "plist"), + let plist = NSDictionary(contentsOfFile: path) as? [String: Any], + let secret = plist["SpotifyClientSecret"] as? String, !secret.isEmpty else { + fatalError("SpotifyClientSecret not found in Config.plist") + } + return secret + } + + static var redirectURI: String { + guard let path = Bundle.main.path(forResource: "Config", ofType: "plist"), + let plist = NSDictionary(contentsOfFile: path) as? [String: Any], + let uri = plist["SpotifyRedirectURI"] as? String else { + return "http://localhost:3000/callback" + } + return uri + } + } + + enum UserDefaultsKeys { + static let accessToken = "access_token" + static let refreshToken = "refresh_token" + static let expirationDate = "expiration_date" + } + + enum Cache { + static let maxMemorySize = 50 * 1024 * 1024 // 50MB + static let maxDiskSize = 200 * 1024 * 1024 // 200MB + static let expirationInterval: TimeInterval = 3600 // 1 hour + } +} +``` + +## 2. NetworkMonitor (Network Reachability) + +**File**: `SpotifyClone/Core/Networking/NetworkMonitor.swift` + +```swift +import Foundation +import Network + +protocol NetworkMonitorDelegate: AnyObject { + func networkStatusChanged(isConnected: Bool) +} + +final class NetworkMonitor { + static let shared = NetworkMonitor() + + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "NetworkMonitor") + + private(set) var isConnected: Bool = false + weak var delegate: NetworkMonitorDelegate? + + private init() { + startMonitoring() + } + + func startMonitoring() { + monitor.pathUpdateHandler = { [weak self] path in + let wasConnected = self?.isConnected ?? false + self?.isConnected = path.status == .satisfied + + if wasConnected != self?.isConnected { + DispatchQueue.main.async { + self?.delegate?.networkStatusChanged(isConnected: self?.isConnected ?? false) + } + } + } + monitor.start(queue: queue) + } + + func stopMonitoring() { + monitor.cancel() + } +} +``` + +## 3. Enhanced DataCache with Expiration + +**File**: `SpotifyClone/Core/Cache/DataCache.swift` + +```swift +import Foundation + +struct CachedItem { + let data: T + let expirationDate: Date + + var isExpired: Bool { + Date() > expirationDate + } +} + +final class DataCache { + static let shared = DataCache() + + private let memoryCache = NSCache() + private let diskCacheURL: URL + private let fileManager = FileManager.default + + private init() { + // Configure memory cache + memoryCache.countLimit = 100 + memoryCache.totalCostLimit = 50 * 1024 * 1024 // 50MB + + // Setup disk cache directory + let cacheDirectory = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0] + diskCacheURL = cacheDirectory.appendingPathComponent("SpotifyCache") + + try? fileManager.createDirectory(at: diskCacheURL, withIntermediateDirectories: true) + } + + func cache(_ item: T, forKey key: String, expirationInterval: TimeInterval = AppConfiguration.Cache.expirationInterval) { + let expirationDate = Date().addingTimeInterval(expirationInterval) + let cachedItem = CachedItem(data: item, expirationDate: expirationDate) + + // Memory cache + if let data = try? JSONEncoder().encode(cachedItem) { + memoryCache.setObject(data as NSData, forKey: key as NSString) + } + + // Disk cache + let fileURL = diskCacheURL.appendingPathComponent(key) + if let data = try? JSONEncoder().encode(cachedItem) { + try? data.write(to: fileURL) + } + } + + func retrieve(_ type: T.Type, forKey key: String) -> T? { + // Check memory cache first + if let memoryData = memoryCache.object(forKey: key as NSString) as Data?, + let cachedItem = try? JSONDecoder().decode(CachedItem.self, from: memoryData), + !cachedItem.isExpired { + return cachedItem.data + } + + // Check disk cache + let fileURL = diskCacheURL.appendingPathComponent(key) + if let diskData = try? Data(contentsOf: fileURL), + let cachedItem = try? JSONDecoder().decode(CachedItem.self, from: diskData), + !cachedItem.isExpired { + // Restore to memory cache + memoryCache.setObject(diskData as NSData, forKey: key as NSString) + return cachedItem.data + } + + return nil + } + + func remove(forKey key: String) { + memoryCache.removeObject(forKey: key as NSString) + let fileURL = diskCacheURL.appendingPathComponent(key) + try? fileManager.removeItem(at: fileURL) + } + + func clearAll() { + memoryCache.removeAllObjects() + try? fileManager.removeItem(at: diskCacheURL) + try? fileManager.createDirectory(at: diskCacheURL, withIntermediateDirectories: true) + } +} +``` + +## 4. Protocol-Based Dependency Injection + +**File**: `SpotifyClone/Core/Networking/AuthManagerProtocol.swift` + +```swift +import Foundation + +protocol AuthManagerProtocol { + var isSignedIn: Bool { get } + var accessToken: String? { get } + var signInURL: URL? { get } + + func exchangeCodeForToken(code: String, completion: @escaping (Bool) -> Void) + func refreshAccessToken(completion: @escaping (Bool) -> Void) + func withValidToken(completion: @escaping (String) -> Void) + func signOut(completion: (Bool) -> Void) + func createRequest(with url: URL?, type: HTTPMethod, completion: @escaping (URLRequest) -> Void) +} + +extension AuthManager: AuthManagerProtocol {} + +// In tests, create a mock: +class MockAuthManager: AuthManagerProtocol { + var isSignedIn: Bool = true + var accessToken: String? = "mock_token" + var signInURL: URL? = URL(string: "https://example.com") + + func exchangeCodeForToken(code: String, completion: @escaping (Bool) -> Void) { + completion(true) + } + + func refreshAccessToken(completion: @escaping (Bool) -> Void) { + completion(true) + } + + func withValidToken(completion: @escaping (String) -> Void) { + completion("mock_token") + } + + func signOut(completion: (Bool) -> Void) { + completion(true) + } + + func createRequest(with url: URL?, type: HTTPMethod, completion: @escaping (URLRequest) -> Void) { + var request = URLRequest(url: url!) + request.setValue("Bearer mock_token", forHTTPHeaderField: "Authorization") + completion(request) + } +} +``` + +## 5. Enhanced Error Handling with User Messages + +**File**: `SpotifyClone/Core/Error/UserFriendlyError.swift` + +```swift +import Foundation + +extension ApiError { + var userMessage: String { + switch self { + case .code: + return "Authentication failed. Please try logging in again." + case .tokenNotFound: + return "Your session has expired. Please log in again." + case .invalidInput: + return "Invalid input. Please check your search and try again." + case .invalidURL: + return "Something went wrong. Please try again." + case .failedToGetData: + return "Unable to load data. Please check your internet connection." + case .decodeError: + return "Unable to process the response. Please try again." + case .rateLimitExceeded: + return "Too many requests. Please wait a moment and try again." + case .invalidResponse(let statusCode): + switch statusCode { + case 401: + return "Your session has expired. Please log in again." + case 403: + return "You don't have permission to access this." + case 404: + return "The requested item was not found." + case 429: + return "Too many requests. Please wait a moment." + case 500...599: + return "Server error. Please try again later." + default: + return "Something went wrong. Please try again." + } + default: + return "An unexpected error occurred. Please try again." + } + } + + var recoveryAction: String? { + switch self { + case .tokenNotFound, .code: + return "Log In" + case .failedToGetData: + return "Retry" + case .rateLimitExceeded: + return "Wait" + default: + return nil + } + } +} +``` + +## 6. Logging Framework Wrapper + +**File**: `SpotifyClone/Core/Logging/Logger.swift` + +```swift +import Foundation +import os.log + +enum LogLevel { + case debug + case info + case warning + case error + + var osLogType: OSLogType { + switch self { + case .debug: return .debug + case .info: return .info + case .warning: return .default + case .error: return .error + } + } +} + +final class Logger { + static let shared = Logger() + + private let subsystem = Bundle.main.bundleIdentifier ?? "com.spotifyclone" + private let category = "SpotifyClone" + private let log: OSLog + + #if DEBUG + private let isDebugMode = true + #else + private let isDebugMode = false + #endif + + private init() { + log = OSLog(subsystem: subsystem, category: category) + } + + func log(_ message: String, level: LogLevel = .info, file: String = #file, function: String = #function, line: Int = #line) { + let fileName = (file as NSString).lastPathComponent + let logMessage = "[\(fileName):\(line)] \(function) - \(message)" + + if level == .debug && !isDebugMode { + return // Skip debug logs in release + } + + os_log("%{public}@", log: log, type: level.osLogType, logMessage) + + #if DEBUG + print("\(level) - \(logMessage)") + #endif + } + + func debug(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + log(message, level: .debug, file: file, function: function, line: line) + } + + func info(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + log(message, level: .info, file: file, function: function, line: line) + } + + func warning(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + log(message, level: .warning, file: file, function: function, line: line) + } + + func error(_ message: String, error: Error? = nil, file: String = #file, function: String = #function, line: Int = #line) { + var fullMessage = message + if let error = error { + fullMessage += " - Error: \(error.localizedDescription)" + } + log(fullMessage, level: .error, file: file, function: function, line: line) + } +} +``` + +## 7. Retry Strategy for API Calls + +**File**: `SpotifyClone/Core/Networking/RetryStrategy.swift` + +```swift +import Foundation + +struct RetryConfiguration { + let maxRetries: Int + let baseDelay: TimeInterval + let maxDelay: TimeInterval + let multiplier: Double + + static let `default` = RetryConfiguration( + maxRetries: 3, + baseDelay: 1.0, + maxDelay: 30.0, + multiplier: 2.0 + ) +} + +extension SpotifyAPIClient { + func sendWithRetry( + _ endpoint: Endpoint, + configuration: RetryConfiguration = .default, + decoder customDecoder: JSONDecoder? = nil, + completion: @escaping (Result) -> Void + ) { + send(endpoint, decoder: customDecoder) { [weak self] result in + switch result { + case .success: + completion(result) + case .failure(let error): + if let apiError = error as? ApiError, + case .rateLimitExceeded = apiError, + configuration.maxRetries > 0 { + // Retry with exponential backoff + let delay = min( + configuration.baseDelay * pow(configuration.multiplier, Double(configuration.maxRetries)), + configuration.maxDelay + ) + + DispatchQueue.global().asyncAfter(deadline: .now() + delay) { + var newConfig = configuration + newConfig = RetryConfiguration( + maxRetries: configuration.maxRetries - 1, + baseDelay: configuration.baseDelay, + maxDelay: configuration.maxDelay, + multiplier: configuration.multiplier + ) + self?.sendWithRetry(endpoint, configuration: newConfig, decoder: customDecoder, completion: completion) + } + } else { + completion(result) + } + } + } + } +} +``` + +## 8. Example Unit Test + +**File**: `SpotifyCloneTests/AuthManagerTests.swift` + +```swift +import XCTest +@testable import SpotifyClone + +final class AuthManagerTests: XCTestCase { + var authManager: AuthManager! + var mockUserDefaults: UserDefaults! + + override func setUp() { + super.setUp() + mockUserDefaults = UserDefaults(suiteName: "test")! + // Use dependency injection if possible, or test the singleton + authManager = AuthManager.shared + } + + override func tearDown() { + mockUserDefaults.removePersistentDomain(forName: "test") + super.tearDown() + } + + func testIsSignedIn_WithValidToken_ReturnsTrue() { + // Given + mockUserDefaults.set("test_token", forKey: "access_token") + + // When + let isSignedIn = authManager.isSignedIn + + // Then + XCTAssertTrue(isSignedIn) + } + + func testIsSignedIn_WithoutToken_ReturnsFalse() { + // Given + mockUserDefaults.removeObject(forKey: "access_token") + + // When + let isSignedIn = authManager.isSignedIn + + // Then + XCTAssertFalse(isSignedIn) + } + + func testShouldRefreshToken_WhenExpired_ReturnsTrue() { + // Given + let expiredDate = Date().addingTimeInterval(-600) // 10 minutes ago + mockUserDefaults.set(expiredDate, forKey: "expiration_date") + + // When + // Note: This requires exposing shouldRefreshToken or testing indirectly + // through withValidToken + + // Then + // Assert expected behavior + } +} +``` + +## 9. Removing UI from API Layer + +**Before** (ChapterApiCaller.swift): +```swift +if httpResponse.statusCode == 429 { + DispatchQueue.main.async { + let alert = UIAlertController(...) + viewController.present(alert, animated: true) + } +} +``` + +**After**: +```swift +if httpResponse.statusCode == 429 { + if let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After") { + let error = ApiError.rateLimitExceeded(retryAfter: retryAfter) + completion(.failure(error)) + return + } +} +``` + +Then handle in ViewController: +```swift +apiCaller.fetch(...) { result in + switch result { + case .success(let data): + // Handle success + case .failure(let error): + if case ApiError.rateLimitExceeded(let retryAfter) = error { + self.showRateLimitAlert(retryAfter: retryAfter) + } else { + self.showError(error) + } + } +} +``` + +--- + +These examples provide concrete patterns you can follow to implement the recommended improvements. Start with the critical items and work your way through the priority list. + diff --git a/docs/IMPLEMENTATION_SUMMARY.md b/docs/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..7897590 --- /dev/null +++ b/docs/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,231 @@ +# Implementation Summary - High Priority Improvements + +## ✅ Completed Improvements + +### 1. **AppConfiguration** - Centralized Constants +**File**: `Core/Configuration/AppConfiguration.swift` + +- ✅ Created centralized configuration enum +- ✅ Moved Spotify API credentials to AppConfiguration +- ✅ Added UserDefaults keys constants +- ✅ Added Cache configuration constants +- ✅ Added Network configuration constants + +**Benefits**: +- Single source of truth for all constants +- Easier to maintain and update +- Type-safe configuration access + +### 2. **NetworkMonitor** - Network Reachability +**File**: `Core/Networking/NetworkMonitor.swift` + +- ✅ Implemented network monitoring using Network framework +- ✅ Added delegate pattern for status changes +- ✅ Provides real-time connectivity status + +**Benefits**: +- Can check network before making requests +- Notify users when offline +- Better error handling for network issues + +### 3. **Logger** - Centralized Logging +**File**: `Core/Logging/Logger.swift` + +- ✅ Replaced `print()` statements with proper logging +- ✅ Added log levels (debug, info, warning, error) +- ✅ Integrated with OSLog for Console.app visibility +- ✅ Conditional compilation for debug vs release + +**Benefits**: +- Better debugging experience +- No performance impact in release builds +- Structured logging for production + +### 4. **Enhanced ApiError** - User-Friendly Errors +**File**: `Core/Error/ApiError.swift` + +- ✅ Added `userMessage` property for UI display +- ✅ Added `recoveryAction` property for suggested actions +- ✅ Enhanced `rateLimitExceeded` to include retry-after info +- ✅ Better error messages for all error types + +**Benefits**: +- Users see friendly error messages +- Clear recovery actions +- Better error handling in UI + +### 5. **Removed UI from API Layer** +**Files**: +- `Managers/ChapterApiCaller.swift` +- `Managers/AlbumApi.swift` + +- ✅ Removed `UIAlertController` from API managers +- ✅ Removed `topMostViewController()` helper methods +- ✅ Removed UIKit import from API layer +- ✅ Replaced with proper error propagation +- ✅ Added Logger calls instead of print statements + +**Benefits**: +- Clean separation of concerns +- API layer is now testable +- No UIKit dependencies in networking code + +### 6. **Error Display Extension** +**File**: `Extensions/UIViewController+ErrorDisplay.swift` + +- ✅ Created reusable error display method +- ✅ Automatically uses user-friendly messages +- ✅ Shows recovery actions when available + +**Benefits**: +- Consistent error display across app +- Easy to use: `viewController.showError(error)` +- Better user experience + +### 7. **Updated AuthManager** +**File**: `Managers/AuthManager.swift` + +- ✅ Refactored to use `AppConfiguration` constants +- ✅ Updated UserDefaults keys to use constants +- ✅ Cleaner code structure + +**Benefits**: +- Consistent with new architecture +- Easier to maintain +- Type-safe key access + +--- + +## 📊 Impact + +### Code Quality +- ✅ **Separation of Concerns**: API layer no longer depends on UIKit +- ✅ **Maintainability**: Centralized configuration and logging +- ✅ **Testability**: API managers can now be unit tested +- ✅ **Consistency**: Standardized error handling + +### User Experience +- ✅ **Better Error Messages**: Users see friendly, actionable errors +- ✅ **Network Awareness**: Can detect and handle offline scenarios +- ✅ **Consistent UI**: Standardized error display across app + +### Developer Experience +- ✅ **Better Debugging**: Structured logging with levels +- ✅ **Easier Maintenance**: Centralized constants +- ✅ **Cleaner Code**: Removed UI dependencies from API layer + +--- + +## 🔄 Migration Guide + +### Using the New Components + +#### 1. Display Errors in ViewControllers +```swift +// Old way (removed from API layer) +// API would show alert directly + +// New way +apiCaller.fetchData { result in + switch result { + case .success(let data): + // Handle success + case .failure(let error): + self.showError(error) // Uses new extension + } +} +``` + +#### 2. Using Logger +```swift +// Old way +print("Status Code: \(statusCode)") + +// New way +Logger.shared.info("Status Code: \(statusCode)") +Logger.shared.error("API call failed", error: error) +Logger.shared.debug("Response data: \(data)") +``` + +#### 3. Using AppConfiguration +```swift +// Old way +let url = "https://api.spotify.com/v1/artists" +let token = UserDefaults.standard.string(forKey: "access_token") + +// New way +let url = AppConfiguration.Spotify.baseURL + "/artists" +let token = UserDefaults.standard.string(forKey: AppConfiguration.UserDefaultsKeys.accessToken) +``` + +#### 4. Checking Network Status +```swift +// Check if network is available +if NetworkMonitor.shared.isConnected { + // Make API call +} else { + showError(ApiError.failedToGetData) +} + +// Listen for network changes +NetworkMonitor.shared.delegate = self + +func networkStatusChanged(isConnected: Bool) { + if isConnected { + // Retry failed requests + } +} +``` + +--- + +## 🚀 Next Steps + +### Immediate (Can do now) +1. **Update remaining API managers** to use Logger instead of print +2. **Add NetworkMonitor checks** before API calls +3. **Use showError()** in ViewControllers for error display +4. **Migrate more API managers** to SpotifyAPIClient pattern + +### Short Term (This week) +1. **Migrate TrackApi** to use SpotifyAPIClient (example pattern) +2. **Add unit tests** for new components (Logger, NetworkMonitor) +3. **Update all ViewControllers** to use new error display +4. **Add network status indicator** in UI + +### Medium Term (Next 2 weeks) +1. **Migrate all API managers** to SpotifyAPIClient +2. **Implement comprehensive caching** with expiration +3. **Add retry strategy** to SpotifyAPIClient +4. **Create protocol-based dependency injection** + +--- + +## 📝 Files Changed + +### New Files Created +- `Core/Configuration/AppConfiguration.swift` +- `Core/Networking/NetworkMonitor.swift` +- `Core/Logging/Logger.swift` +- `Extensions/UIViewController+ErrorDisplay.swift` + +### Files Modified +- `Core/Error/ApiError.swift` - Added user messages and recovery actions +- `Managers/AuthManager.swift` - Updated to use AppConfiguration +- `Managers/ChapterApiCaller.swift` - Removed UI code, added Logger +- `Managers/AlbumApi.swift` - Removed UI code, added Logger + +--- + +## ✨ Key Achievements + +1. **Architecture**: Clean separation between API and UI layers +2. **Maintainability**: Centralized configuration and logging +3. **User Experience**: Better error messages and network awareness +4. **Code Quality**: Removed dependencies, improved testability +5. **Developer Experience**: Better debugging tools and consistent patterns + +--- + +*Implementation completed: January 2025* + diff --git a/docs/LibraryEndpoints.kt b/docs/LibraryEndpoints.kt new file mode 100644 index 0000000..5151d96 --- /dev/null +++ b/docs/LibraryEndpoints.kt @@ -0,0 +1,356 @@ +// Library API Endpoints, Models, and DTOs in Kotlin +// Based on SpotifyClone iOS project + +package com.spotifyclone.api.library + +import com.google.gson.annotations.SerializedName +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.header + +// ============================================================================ +// API ENDPOINTS +// ============================================================================ + +object LibraryEndpoints { + const val BASE_URL = "https://api.spotify.com/v1" + + // Get User's Saved Albums + const val GET_USER_SAVED_ALBUMS = "$BASE_URL/me/albums" + + // Get Current User's Playlists + const val GET_CURRENT_USER_PLAYLISTS = "$BASE_URL/me/playlists" + + // Get User's Saved Podcasts/Shows + const val GET_USER_SAVED_SHOWS = "$BASE_URL/me/shows" + + // Get User's Saved Episodes + const val GET_USER_SAVED_EPISODES = "$BASE_URL/me/episodes" +} + +// ============================================================================ +// API SERVICE +// ============================================================================ + +class LibraryApiService( + private val client: HttpClient, + private val baseUrl: String = LibraryEndpoints.BASE_URL +) { + /** + * Get User's Saved Albums + * GET /me/albums + */ + suspend fun getUserSavedAlbums( + accessToken: String + ): SpotifyUsersAlbumSavedResponse { + return client.get("$baseUrl/me/albums") { + header("Authorization", "Bearer $accessToken") + }.body() + } + + /** + * Get Current User's Playlists + * GET /me/playlists + */ + suspend fun getCurrentUserPlaylists( + accessToken: String + ): CurrentUsersPlaylistsResponse { + return client.get("$baseUrl/me/playlists") { + header("Authorization", "Bearer $accessToken") + }.body() + } + + /** + * Get User's Saved Podcasts/Shows + * GET /me/shows + */ + suspend fun getUserSavedShows( + accessToken: String + ): UsersSavedShows { + return client.get("$baseUrl/me/shows") { + header("Authorization", "Bearer $accessToken") + }.body() + } + + /** + * Get User's Saved Episodes + * GET /me/episodes + */ + suspend fun getUserSavedEpisodes( + accessToken: String + ): UserSavedEpisodesResponse { + return client.get("$baseUrl/me/episodes") { + header("Authorization", "Bearer $accessToken") + }.body() + } +} + +// ============================================================================ +// RESPONSE DTOs +// ============================================================================ + +// MARK: - Get User's Saved Albums Response +data class SpotifyUsersAlbumSavedResponse( + val href: String?, + val items: List, + val limit: Int?, + val next: String?, + val offset: Int?, + val previous: String?, + val total: Int? +) + +data class SpotifyUsersAlbumSavedItemResponse( + @SerializedName("added_at") + val addedAt: String, + val album: Album? +) + +// MARK: - Get Current User's Playlists Response +data class CurrentUsersPlaylistsResponse( + val href: String?, + val limit: Int?, + val next: String?, + val offset: Int?, + val previous: String?, + val total: Int?, + val items: List? +) + +// MARK: - Get User's Saved Shows/Podcasts Response +data class UsersSavedShows( + val href: String, + val limit: Int, + val next: String?, + val offset: Int, + val previous: String?, + val total: Int?, + val items: List? +) + +data class UsersSavedShowsItems( + @SerializedName("added_at") + val addedAt: String, + val show: Show +) + +// MARK: - Get User's Saved Episodes Response +data class UserSavedEpisodesResponse( + val href: String?, + val limit: Int?, + val next: String?, + val offset: Int?, + val previous: String?, + val total: Int?, + val items: List? +) + +data class UserSavedEpisode( + @SerializedName("added_at") + val addedAt: String?, + val episode: Episode? +) + +// ============================================================================ +// MODEL CLASSES +// ============================================================================ + +// MARK: - Album Model +data class Album( + @SerializedName("album_type") + val albumType: String?, + @SerializedName("total_tracks") + val totalTracks: Int?, + @SerializedName("available_markets") + val availableMarkets: List?, + @SerializedName("external_urls") + val externalUrls: ExternalUrls?, + val href: String?, + val id: String?, + val images: List?, + val name: String?, + @SerializedName("release_date") + val releaseDate: String?, + @SerializedName("release_date_precision") + val releaseDatePrecision: String?, + val restrictions: Restrictions?, + val type: String?, + val uri: String?, + val artists: List?, + val tracks: Tracks?, + val copyrights: List?, + @SerializedName("external_ids") + val externalIds: ExternalIDs?, + val genres: List?, + val label: String?, + val popularity: Int? +) + +// MARK: - PlaylistItem Model +data class PlaylistItem( + val collaborative: Boolean?, + val description: String?, + @SerializedName("external_urls") + val externalUrls: ExternalURLs?, + val followers: Followers?, + val href: String?, + val id: String?, + val images: List?, + val name: String?, + val owner: Owner?, + @SerializedName("public") + val publicAccess: Boolean?, + @SerializedName("snapshot_id") + val snapshotID: String?, + val tracks: Tracks?, + val type: String?, + val uri: String? +) + +// MARK: - Show Model (Podcast) +data class Show( + @SerializedName("available_markets") + val availableMarkets: List?, + val copyrights: List?, + val description: String?, + @SerializedName("html_description") + val htmlDescription: String?, + val explicit: Boolean?, + @SerializedName("external_urls") + val externalUrls: ExternalUrls?, + val href: String?, + val id: String?, + val images: List?, + @SerializedName("is_externally_hosted") + val isExternallyHosted: Boolean?, + val languages: List?, + @SerializedName("media_type") + val mediaType: String?, + val name: String?, + val publisher: String?, + val type: String?, + val uri: String?, + @SerializedName("total_episodes") + val totalEpisodes: Int? +) + +// MARK: - Episode Model +data class Episode( + @SerializedName("audio_preview_url") + val audioPreviewURL: String?, + val description: String?, + @SerializedName("html_description") + val htmlDescription: String?, + @SerializedName("duration_ms") + val durationMs: Int?, + val explicit: Boolean?, + @SerializedName("external_urls") + val externalURLs: ExternalURLs?, + val href: String?, + val id: String?, + val images: List?, + @SerializedName("is_externally_hosted") + val isExternallyHosted: Boolean?, + @SerializedName("is_playable") + val isPlayable: Boolean?, + val language: String?, + val languages: List?, + val name: String?, + @SerializedName("release_date") + val releaseDate: String?, + @SerializedName("release_date_precision") + val releaseDatePrecision: String?, + @SerializedName("resume_point") + val resumePoint: ResumePoint?, + val type: String?, + val uri: String?, + val restrictions: Restrictions?, + val show: Show? +) + +// ============================================================================ +// SUPPORTING MODELS +// ============================================================================ + +data class ExternalUrls( + val spotify: String? +) + +data class ExternalURLs( + val spotify: String? +) + +data class APIImage( + val url: String?, + val height: Int?, + val width: Int? +) + +data class Restrictions( + val reason: String? +) + +data class Artist( + val externalUrls: ExternalUrls?, + val href: String?, + val id: String?, + val name: String?, + val type: String?, + val uri: String? +) + +data class Tracks( + val href: String?, + val limit: Int?, + val next: String?, + val offset: Int?, + val previous: String?, + val total: Int?, + val items: List? +) + +data class Track( + val id: String?, + val name: String?, + val artists: List?, + val album: Album?, + val durationMs: Int?, + val explicit: Boolean?, + val previewUrl: String?, + val uri: String? +) + +data class Copyright( + val text: String?, + val type: String? +) + +data class ExternalIDs( + val isrc: String?, + val ean: String?, + val upc: String? +) + +data class Followers( + val href: String?, + val total: Int? +) + +data class Owner( + val externalUrls: ExternalUrls?, + val followers: Followers?, + val href: String?, + val id: String?, + val type: String?, + val uri: String?, + val displayName: String? +) + +data class ResumePoint( + @SerializedName("fully_played") + val fullyPlayed: Boolean?, + @SerializedName("resume_position_ms") + val resumePositionMS: Int? +) +