import Foundation import SwiftUI /// Servicio para manejar el almacenamiento local de capítulos y progreso de lectura. /// /// `StorageService` centraliza todas las operaciones de persistencia de la aplicación, /// incluyendo: /// - Gestión de favoritos (UserDefaults) /// - Seguimiento de progreso de lectura (UserDefaults) /// - Metadata de capítulos descargados (JSON en disco) /// - Almacenamiento de imágenes (FileManager) /// /// El servicio usa UserDefaults para datos pequeños y simples, y FileManager para /// almacenamiento de archivos binarios como imágenes. /// /// # Example /// ```swift /// let storage = StorageService.shared /// /// // Guardar favorito /// storage.saveFavorite(mangaSlug: "one-piece") /// /// // Guardar progreso /// let progress = ReadingProgress( /// mangaSlug: "one-piece", /// chapterNumber: 1, /// pageNumber: 15, /// timestamp: Date() /// ) /// storage.saveReadingProgress(progress) /// /// // Verificar tamaño usado /// let size = storage.getStorageSize() /// print("Used: \(storage.formatFileSize(size))") /// ``` class StorageService { // MARK: - Singleton /// Instancia compartida del servicio (Singleton pattern) static let shared = StorageService() // MARK: - Properties /// FileManager para operaciones de sistema de archivos private let fileManager = FileManager.default /// Directorio de Documents de la app private let documentsDirectory: URL /// Subdirectorio para capítulos descargados private let chaptersDirectory: URL /// URL del archivo de metadata de descargas private let metadataURL: URL /// Claves para UserDefaults private let favoritesKey = "favoriteMangas" private let readingProgressKey = "readingProgress" private let downloadedChaptersKey = "downloadedChaptersMetadata" // MARK: - Initialization /// Inicializador privado para implementar Singleton. /// /// Configura las rutas de directorios y crea la estructura necesaria /// si no existe. private init() { documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] chaptersDirectory = documentsDirectory.appendingPathComponent("Chapters") metadataURL = documentsDirectory.appendingPathComponent("metadata.json") createDirectoriesIfNeeded() } // MARK: - Directory Management /// Crea el directorio de capítulos si no existe. private func createDirectoriesIfNeeded() { if !fileManager.fileExists(atPath: chaptersDirectory.path) { try? fileManager.createDirectory(at: chaptersDirectory, withIntermediateDirectories: true) } } func getChapterDirectory(mangaSlug: String, chapterNumber: Int) -> URL { let chapterPath = "\(mangaSlug)/Chapter\(chapterNumber)" return chaptersDirectory.appendingPathComponent(chapterPath) } // MARK: - Favorites /// Retorna la lista de slugs de mangas favoritos. /// /// - Returns: Array de strings con los slugs de mangas marcados como favoritos func getFavorites() -> [String] { UserDefaults.standard.stringArray(forKey: favoritesKey) ?? [] } /// Guarda un manga como favorito. /// /// Si el manga ya está en favoritos, no hace nada (no duplica). /// /// - Parameter mangaSlug: Slug del manga a marcar como favorito /// /// # Example /// ```swift /// storage.saveFavorite(mangaSlug: "one-piece_1695365223767") /// ``` func saveFavorite(mangaSlug: String) { var favorites = getFavorites() if !favorites.contains(mangaSlug) { favorites.append(mangaSlug) UserDefaults.standard.set(favorites, forKey: favoritesKey) } } /// Elimina un manga de favoritos. /// /// Si el manga no está en favoritos, no hace nada. /// /// - Parameter mangaSlug: Slug del manga a eliminar de favoritos /// /// # Example /// ```swift /// storage.removeFavorite(mangaSlug: "one-piece_1695365223767") /// ``` func removeFavorite(mangaSlug: String) { var favorites = getFavorites() favorites.removeAll { $0 == mangaSlug } UserDefaults.standard.set(favorites, forKey: favoritesKey) } /// Verifica si un manga está marcado como favorito. /// /// - Parameter mangaSlug: Slug del manga a verificar /// - Returns: `true` si el manga está en favoritos, `false` en caso contrario /// /// # Example /// ```swift /// if storage.isFavorite(mangaSlug: "one-piece_1695365223767") { /// print("Este manga es favorito") /// } /// ``` func isFavorite(mangaSlug: String) -> Bool { getFavorites().contains(mangaSlug) } // MARK: - Reading Progress /// Guarda o actualiza el progreso de lectura de un capítulo. /// /// Si ya existe progreso para el mismo manga y capítulo, lo actualiza. /// Si no existe, agrega un nuevo registro. /// /// - Parameter progress: Objeto `ReadingProgress` con la información a guardar /// /// # Example /// ```swift /// let progress = ReadingProgress( /// mangaSlug: "one-piece", /// chapterNumber: 1, /// pageNumber: 15, /// timestamp: Date() /// ) /// storage.saveReadingProgress(progress) /// ``` func saveReadingProgress(_ progress: ReadingProgress) { var allProgress = getAllReadingProgress() // Actualizar o agregar el progreso if let index = allProgress.firstIndex(where: { $0.mangaSlug == progress.mangaSlug && $0.chapterNumber == progress.chapterNumber }) { allProgress[index] = progress } else { allProgress.append(progress) } saveProgressToDisk(allProgress) } /// Retorna el progreso de lectura de un capítulo específico. /// /// - Parameters: /// - mangaSlug: Slug del manga /// - chapterNumber: Número del capítulo /// - Returns: Objeto `ReadingProgress` si existe, `nil` en caso contrario /// /// # Example /// ```swift /// if let progress = storage.getReadingProgress( /// mangaSlug: "one-piece", /// chapterNumber: 1 /// ) { /// print("Última página: \(progress.pageNumber)") /// } /// ``` func getReadingProgress(mangaSlug: String, chapterNumber: Int) -> ReadingProgress? { getAllReadingProgress().first { $0.mangaSlug == mangaSlug && $0.chapterNumber == chapterNumber } } /// Retorna todo el progreso de lectura almacenado. /// /// - Returns: Array de todos los objetos `ReadingProgress` almacenados func getAllReadingProgress() -> [ReadingProgress] { guard let data = UserDefaults.standard.data(forKey: readingProgressKey), let progress = try? JSONDecoder().decode([ReadingProgress].self, from: data) else { return [] } return progress } /// Retorna el capítulo más recientemente leído de un manga. /// /// Busca entre todos los progresos del manga y retorna el que tiene /// el timestamp más reciente. /// /// - Parameter mangaSlug: Slug del manga /// - Returns: Objeto `ReadingProgress` más reciente, o `nil` si no hay progreso /// /// # Example /// ```swift /// if let lastRead = storage.getLastReadChapter(mangaSlug: "one-piece") { /// print("Último capítulo leído: \(lastRead.chapterNumber)") /// } /// ``` func getLastReadChapter(mangaSlug: String) -> ReadingProgress? { let progress = getAllReadingProgress().filter { $0.mangaSlug == mangaSlug } return progress.max { $0.timestamp < $1.timestamp } } /// Guarda el array de progresos en UserDefaults. /// /// - Parameter progress: Array de `ReadingProgress` a guardar private func saveProgressToDisk(_ progress: [ReadingProgress]) { if let data = try? JSONEncoder().encode(progress) { UserDefaults.standard.set(data, forKey: readingProgressKey) } } // MARK: - Downloaded Chapters /// Guarda la metadata de un capítulo descargado. /// /// Si el capítulo ya existe en la metadata, lo actualiza. /// Si no existe, agrega un nuevo registro. /// /// - Parameter chapter: Objeto `DownloadedChapter` con la metadata /// /// # Example /// ```swift /// let downloaded = DownloadedChapter( /// mangaSlug: "one-piece", /// mangaTitle: "One Piece", /// chapterNumber: 1, /// pages: pages, /// downloadedAt: Date() /// ) /// storage.saveDownloadedChapter(downloaded) /// ``` func saveDownloadedChapter(_ chapter: DownloadedChapter) { var downloaded = getDownloadedChapters() if let index = downloaded.firstIndex(where: { $0.id == chapter.id }) { downloaded[index] = chapter } else { downloaded.append(chapter) } if let data = try? JSONEncoder().encode(downloaded) { try? data.write(to: metadataURL) } } /// Retorna todos los capítulos descargados. /// /// - Returns: Array de objetos `DownloadedChapter` /// /// # Example /// ```swift /// let downloads = storage.getDownloadedChapters() /// print("Tienes \(downloads.count) capítulos descargados") /// for chapter in downloads { /// print("- \(chapter.displayTitle)") /// } /// ``` func getDownloadedChapters() -> [DownloadedChapter] { guard let data = try? Data(contentsOf: metadataURL), let downloaded = try? JSONDecoder().decode([DownloadedChapter].self, from: data) else { return [] } return downloaded } /// Retorna un capítulo descargado específico. /// /// - Parameters: /// - mangaSlug: Slug del manga /// - chapterNumber: Número del capítulo /// - Returns: Objeto `DownloadedChapter` si existe, `nil` en caso contrario func getDownloadedChapter(mangaSlug: String, chapterNumber: Int) -> DownloadedChapter? { getDownloadedChapters().first { $0.mangaSlug == mangaSlug && $0.chapterNumber == chapterNumber } } /// Verifica si un capítulo está descargado. /// /// - Parameters: /// - mangaSlug: Slug del manga /// - chapterNumber: Número del capítulo /// - Returns: `true` si el capítulo está descargado, `false` en caso contrario /// /// # Example /// ```swift /// if storage.isChapterDownloaded( /// mangaSlug: "one-piece", /// chapterNumber: 1 /// ) { /// print("Capítulo ya descargado") /// } /// ``` func isChapterDownloaded(mangaSlug: String, chapterNumber: Int) -> Bool { getDownloadedChapter(mangaSlug: mangaSlug, chapterNumber: chapterNumber) != nil } /// Elimina un capítulo descargado (archivos y metadata). /// /// Elimina todos los archivos de imagen del capítulo del disco /// y remueve la metadata del registro de descargas. /// /// - Parameters: /// - mangaSlug: Slug del manga /// - chapterNumber: Número del capítulo a eliminar /// /// # Example /// ```swift /// storage.deleteDownloadedChapter( /// mangaSlug: "one-piece", /// chapterNumber: 1 /// ) /// ``` func deleteDownloadedChapter(mangaSlug: String, chapterNumber: Int) { // Eliminar archivos let chapterDir = getChapterDirectory(mangaSlug: mangaSlug, chapterNumber: chapterNumber) try? fileManager.removeItem(at: chapterDir) // Eliminar metadata var downloaded = getDownloadedChapters() downloaded.removeAll { $0.mangaSlug == mangaSlug && $0.chapterNumber == chapterNumber } if let data = try? JSONEncoder().encode(downloaded) { try? data.write(to: metadataURL) } } // MARK: - Image Caching /// Guarda una imagen en disco local. /// /// Comprime la imagen como JPEG con 80% de calidad y la guarda /// en el directorio del capítulo correspondiente. /// /// - Parameters: /// - image: Imagen (UIImage) a guardar /// - mangaSlug: Slug del manga /// - chapterNumber: Número del capítulo /// - pageIndex: Índice de la página (para nombre de archivo) /// - Returns: URL del archivo guardado /// - Throws: Error si no se puede crear el directorio o guardar la imagen /// /// # Example /// ```swift /// do { /// let imageURL = try await storage.saveImage( /// image: myUIImage, /// mangaSlug: "one-piece", /// chapterNumber: 1, /// pageIndex: 0 /// ) /// print("Imagen guardada en: \(imageURL.path)") /// } catch { /// print("Error guardando imagen: \(error)") /// } /// ``` func saveImage(_ image: UIImage, mangaSlug: String, chapterNumber: Int, pageIndex: Int) async throws -> URL { let chapterDir = getChapterDirectory(mangaSlug: mangaSlug, chapterNumber: chapterNumber) // Crear directorio si no existe if !fileManager.fileExists(atPath: chapterDir.path) { try fileManager.createDirectory(at: chapterDir, withIntermediateDirectories: true) } let filename = "page_\(pageIndex).jpg" let fileURL = chapterDir.appendingPathComponent(filename) // Guardar imagen if let data = image.jpegData(compressionQuality: 0.8) { try data.write(to: fileURL) return fileURL } throw NSError(domain: "StorageService", code: 1, userInfo: [NSLocalizedDescriptionKey: "Error saving image"]) } /// Carga una imagen desde disco local. /// /// - Parameters: /// - mangaSlug: Slug del manga /// - chapterNumber: Número del capítulo /// - pageIndex: Índice de la página /// - Returns: Objeto `UIImage` si el archivo existe, `nil` en caso contrario /// /// # Example /// ```swift /// if let image = storage.loadImage( /// mangaSlug: "one-piece", /// chapterNumber: 1, /// pageIndex: 0 /// ) { /// print("Imagen cargada: \(image.size)") /// } /// ``` func loadImage(mangaSlug: String, chapterNumber: Int, pageIndex: Int) -> UIImage? { let chapterDir = getChapterDirectory(mangaSlug: mangaSlug, chapterNumber: chapterNumber) let filename = "page_\(pageIndex).jpg" let fileURL = chapterDir.appendingPathComponent(filename) guard fileManager.fileExists(atPath: fileURL.path) else { return nil } return UIImage(contentsOfFile: fileURL.path) } /// Retorna la URL local de una imagen si está cacheada. /// /// - Parameters: /// - mangaSlug: Slug del manga /// - chapterNumber: Número del capítulo /// - pageIndex: Índice de la página /// - Returns: URL del archivo si existe, `nil` en caso contrario /// /// # Example /// ```swift /// if let imageURL = storage.getImageURL( /// mangaSlug: "one-piece", /// chapterNumber: 1, /// pageIndex: 0 /// ) { /// print("Imagen cacheada en: \(imageURL.path)") /// } /// ``` func getImageURL(mangaSlug: String, chapterNumber: Int, pageIndex: Int) -> URL? { let chapterDir = getChapterDirectory(mangaSlug: mangaSlug, chapterNumber: chapterNumber) let filename = "page_\(pageIndex).jpg" let fileURL = chapterDir.appendingPathComponent(filename) return fileManager.fileExists(atPath: fileURL.path) ? fileURL : nil } // MARK: - Storage Management /// Calcula el tamaño total usado por los capítulos descargados. /// /// Recursivamente suma el tamaño de todos los archivos en el /// directorio de capítulos. /// /// - Returns: Tamaño total en bytes /// /// # Example /// ```swift /// let bytes = storage.getStorageSize() /// let formatted = storage.formatFileSize(bytes) /// print("Usando \(formatted) de espacio") /// ``` func getStorageSize() -> Int64 { var totalSize: Int64 = 0 if let enumerator = fileManager.enumerator(at: chaptersDirectory, includingPropertiesForKeys: [.fileSizeKey]) { for case let fileURL as URL in enumerator { if let resourceValues = try? fileURL.resourceValues(forKeys: [.fileSizeKey]), let fileSize = resourceValues.fileSize { totalSize += Int64(fileSize) } } } return totalSize } /// Elimina todos los capítulos descargados y su metadata. /// /// Elimina completamente el directorio de capítulos y el archivo /// de metadata, liberando todo el espacio usado. /// /// # Example /// ```swift /// storage.clearAllDownloads() /// print("Todos los descargados han sido eliminados") /// ``` func clearAllDownloads() { try? fileManager.removeItem(at: chaptersDirectory) createDirectoriesIfNeeded() // Limpiar metadata try? fileManager.removeItem(at: metadataURL) } /// Formatea un tamaño en bytes a un string legible. /// /// Usa `ByteCountFormatter` para convertir bytes a KB, MB, GB según /// corresponda, con el formato apropiado para archivos. /// /// - Parameter bytes: Tamaño en bytes /// - Returns: String formateado (ej: "15.2 MB", "1.3 GB") /// /// # Example /// ```swift /// print(storage.formatFileSize(1024)) // "1 KB" /// print(storage.formatFileSize(15728640)) // "15 MB" /// print(storage.formatFileSize(2147483648)) // "2 GB" /// ``` func formatFileSize(_ bytes: Int64) -> String { let formatter = ByteCountFormatter() formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB] formatter.countStyle = .file return formatter.string(fromByteCount: bytes) } }