Initial commit: MangaReader iOS App
✨ Features: - App iOS completa para leer manga sin publicidad - Scraper con WKWebView para manhwaweb.com - Sistema de descargas offline - Lector con zoom y navegación - Favoritos y progreso de lectura - Compatible con iOS 15+ y Sideloadly/3uTools 📦 Contenido: - Backend Node.js con Puppeteer (opcional) - App iOS con SwiftUI - Scraper de capítulos e imágenes - Sistema de almacenamiento local - Testing completo - Documentación exhaustiva 🧪 Prueba: Capítulo 789 de One Piece descargado exitosamente - 21 páginas descargadas - 4.68 MB total - URLs verificadas y funcionales 🎉 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
992
docs/API.md
Normal file
992
docs/API.md
Normal file
@@ -0,0 +1,992 @@
|
||||
# API Documentation - MangaReader
|
||||
|
||||
Este documento proporciona la documentación completa de la API de MangaReader, incluyendo modelos de datos, servicios, ViewModels y sus responsabilidades.
|
||||
|
||||
## Tabla de Contenidos
|
||||
|
||||
- [Modelos de Datos](#modelos-de-datos)
|
||||
- [Servicios](#servicios)
|
||||
- [ViewModels](#viewmodels)
|
||||
- [Views](#views)
|
||||
- [Errores y Manejo de Excepciones](#errores-y-manejo-de-excepciones)
|
||||
|
||||
---
|
||||
|
||||
## Modelos de Datos
|
||||
|
||||
Los modelos de datos se encuentran en `ios-app/Sources/Models/Manga.swift`. Son estructuras inmutables que conforman a protocolos Swift estándar para serialización e identificación.
|
||||
|
||||
### Manga
|
||||
|
||||
Representa la información completa de un manga.
|
||||
|
||||
```swift
|
||||
struct Manga: Codable, Identifiable, Hashable {
|
||||
let id: String { slug }
|
||||
let slug: String
|
||||
let title: String
|
||||
let description: String
|
||||
let genres: [String]
|
||||
let status: String
|
||||
let url: String
|
||||
let coverImage: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case slug, title, description, genres, status, url, coverImage
|
||||
}
|
||||
|
||||
var displayStatus: String { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### Propiedades
|
||||
|
||||
| Propiedad | Tipo | Descripción | Ejemplo |
|
||||
|-----------|------|-------------|---------|
|
||||
| `id` | `String` | Identificador único (computed, igual a `slug`) | `"one-piece_1695365223767"` |
|
||||
| `slug` | `String` | Slug único del manga usado en URLs | `"one-piece_1695365223767"` |
|
||||
| `title` | `String` | Título del manga | `"One Piece"` |
|
||||
| `description` | `String` | Descripción o sinopsis | `"La historia de piratas..."` |
|
||||
| `genres` | `[String]` | Array de géneros literarios | `["Acción", "Aventura"]` |
|
||||
| `status` | `String` | Estado de publicación (crudo) | `"PUBLICANDOSE"` |
|
||||
| `url` | `String` | URL completa del manga | `"https://manhwaweb.com/manga/..."` |
|
||||
| `coverImage` | `String?` | URL de imagen de portada (opcional) | `"https://..."` |
|
||||
|
||||
#### Métodos Computados
|
||||
|
||||
**`displayStatus: String`**
|
||||
- Retorna el estado traducido y formateado para mostrar en UI
|
||||
- Mapeos:
|
||||
- `"PUBLICANDOSE"` → `"En publicación"`
|
||||
- `"FINALIZADO"` → `"Finalizado"`
|
||||
- `"EN_PAUSA"`, `"EN_ESPERA"` → `"En pausa"`
|
||||
- Otro → retorna valor original
|
||||
|
||||
#### Ejemplo de Uso
|
||||
|
||||
```swift
|
||||
let manga = Manga(
|
||||
slug: "one-piece_1695365223767",
|
||||
title: "One Piece",
|
||||
description: "La historia de Monkey D. Luffy...",
|
||||
genres: ["Acción", "Aventura", "Comedia"],
|
||||
status: "PUBLICANDOSE",
|
||||
url: "https://manhwaweb.com/manga/one-piece_1695365223767",
|
||||
coverImage: "https://example.com/cover.jpg"
|
||||
)
|
||||
|
||||
print(manga.displayStatus) // "En publicación"
|
||||
print(manga.id) // "one-piece_1695365223767"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Chapter
|
||||
|
||||
Representa un capítulo individual de un manga.
|
||||
|
||||
```swift
|
||||
struct Chapter: Codable, Identifiable, Hashable {
|
||||
let id: Int { number }
|
||||
let number: Int
|
||||
let title: String
|
||||
let url: String
|
||||
let slug: String
|
||||
var isRead: Bool = false
|
||||
var isDownloaded: Bool = false
|
||||
var lastReadPage: Int = 0
|
||||
|
||||
var displayNumber: String { ... }
|
||||
var progress: Double { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### Propiedades
|
||||
|
||||
| Propiedad | Tipo | Descripción | Ejemplo |
|
||||
|-----------|------|-------------|---------|
|
||||
| `id` | `Int` | Identificador único (computed, igual a `number`) | `1` |
|
||||
| `number` | `Int` | Número del capítulo | `1` |
|
||||
| `title` | `String` | Título del capítulo | `"El inicio de la aventura"` |
|
||||
| `url` | `String` | URL completa del capítulo | `"https://manhwaweb.com/leer/..."` |
|
||||
| `slug` | `String` | Slug para identificar el capítulo | `"one-piece/capitulo-1"` |
|
||||
| `isRead` | `Bool` | Estado de lectura (mutable) | `false` |
|
||||
| `isDownloaded` | `Bool` | Estado de descarga (mutable) | `false` |
|
||||
| `lastReadPage` | `Int` | Última página leída (mutable) | `5` |
|
||||
|
||||
#### Métodos Computados
|
||||
|
||||
**`displayNumber: String`**
|
||||
- Retorna string formateado para mostrar
|
||||
- Formato: `"Capítulo {number}"`
|
||||
|
||||
**`progress: Double`**
|
||||
- Retorna progreso como `Double` para ProgressViews
|
||||
- Valor: `Double(lastReadPage)`
|
||||
|
||||
#### Ejemplo de Uso
|
||||
|
||||
```swift
|
||||
var chapter = Chapter(
|
||||
number: 1,
|
||||
title: "El inicio",
|
||||
url: "https://manhwaweb.com/leer/one-piece/1",
|
||||
slug: "one-piece/1"
|
||||
)
|
||||
|
||||
chapter.isRead = true
|
||||
chapter.lastReadPage = 15
|
||||
|
||||
print(chapter.displayNumber) // "Capítulo 1"
|
||||
print(chapter.progress) // 15.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MangaPage
|
||||
|
||||
Representa una página individual (imagen) de un capítulo.
|
||||
|
||||
```swift
|
||||
struct MangaPage: Codable, Identifiable, Hashable {
|
||||
let id: String { url }
|
||||
let url: String
|
||||
let index: Int
|
||||
var isCached: Bool = false
|
||||
|
||||
var thumbnailURL: String { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### Propiedades
|
||||
|
||||
| Propiedad | Tipo | Descripción | Ejemplo |
|
||||
|-----------|------|-------------|---------|
|
||||
| `id` | `String` | Identificador único (computed, igual a `url`) | `"https://..."` |
|
||||
| `url` | `String` | URL completa de la imagen | `"https://example.com/page1.jpg"` |
|
||||
| `index` | `Int` | Índice de la página en el capítulo | `0` |
|
||||
| `isCached` | `Bool` | Estado de cache local (mutable) | `false` |
|
||||
|
||||
#### Métodos Computados
|
||||
|
||||
**`thumbnailURL: String`**
|
||||
- Actualmente retorna la misma URL
|
||||
- Futuro: implementar versión thumbnail optimizada
|
||||
|
||||
---
|
||||
|
||||
### ReadingProgress
|
||||
|
||||
Almacena el progreso de lectura de un usuario.
|
||||
|
||||
```swift
|
||||
struct ReadingProgress: Codable {
|
||||
let mangaSlug: String
|
||||
let chapterNumber: Int
|
||||
let pageNumber: Int
|
||||
let timestamp: Date
|
||||
|
||||
var isCompleted: Bool { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### Propiedades
|
||||
|
||||
| Propiedad | Tipo | Descripción |
|
||||
|-----------|------|-------------|
|
||||
| `mangaSlug` | `String` | Slug del manga |
|
||||
| `chapterNumber` | `Int` | Número del capítulo |
|
||||
| `pageNumber` | `Int` | Página actual |
|
||||
| `timestamp` | `Date` | Fecha/hora de lectura |
|
||||
|
||||
#### Métodos Computados
|
||||
|
||||
**`isCompleted: Bool`**
|
||||
- Retorna `true` si el usuario leyó más de 5 páginas
|
||||
- Lógica: `return pageNumber > 5`
|
||||
|
||||
---
|
||||
|
||||
### DownloadedChapter
|
||||
|
||||
Representa un capítulo descargado localmente.
|
||||
|
||||
```swift
|
||||
struct DownloadedChapter: Codable, Identifiable {
|
||||
let id: String { "\(mangaSlug)-chapter\(chapterNumber)" }
|
||||
let mangaSlug: String
|
||||
let mangaTitle: String
|
||||
let chapterNumber: Int
|
||||
let pages: [MangaPage]
|
||||
let downloadedAt: Date
|
||||
var totalSize: Int64 = 0
|
||||
|
||||
var displayTitle: String { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### Propiedades
|
||||
|
||||
| Propiedad | Tipo | Descripción |
|
||||
|-----------|------|-------------|
|
||||
| `id` | `String` | ID compuesto único |
|
||||
| `mangaSlug` | `String` | Slug del manga |
|
||||
| `mangaTitle` | `String` | Título del manga |
|
||||
| `chapterNumber` | `Int` | Número del capítulo |
|
||||
| `pages` | `[MangaPage]` | Array de páginas |
|
||||
| `downloadedAt` | `Date` | Fecha de descarga |
|
||||
| `totalSize` | `Int64` | Tamaño total en bytes |
|
||||
|
||||
---
|
||||
|
||||
## Servicios
|
||||
|
||||
Los servicios encapsulan la lógica de negocio y se encuentran en `ios-app/Sources/Services/`.
|
||||
|
||||
### ManhwaWebScraper
|
||||
|
||||
Servicio responsable del scraping de contenido web desde manhwaweb.com.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
class ManhwaWebScraper: NSObject, ObservableObject
|
||||
```
|
||||
|
||||
#### Propiedades
|
||||
|
||||
| Propiedad | Tipo | Acceso | Descripción |
|
||||
|-----------|------|--------|-------------|
|
||||
| `shared` | `ManhwaWebScraper` | `static` | Instancia singleton compartida |
|
||||
| `webView` | `WKWebView?` | `private` | WebView para ejecutar JavaScript |
|
||||
|
||||
#### Métodos Públicos
|
||||
|
||||
##### `scrapeMangaInfo(mangaSlug:)`
|
||||
|
||||
Obtiene la información completa de un manga.
|
||||
|
||||
**Firma:**
|
||||
```swift
|
||||
func scrapeMangaInfo(mangaSlug: String) async throws -> Manga
|
||||
```
|
||||
|
||||
**Parámetros:**
|
||||
- `mangaSlug`: `String` - Slug único del manga
|
||||
|
||||
**Retorna:**
|
||||
- `Manga` - Objeto con información completa del manga
|
||||
|
||||
**Errors:**
|
||||
- `ScrapingError.webViewNotInitialized`
|
||||
- `ScrapingError.pageLoadFailed`
|
||||
- `ScrapingError.noContentFound`
|
||||
|
||||
**Ejemplo:**
|
||||
```swift
|
||||
do {
|
||||
let manga = try await ManhwaWebScraper.shared.scrapeMangaInfo(
|
||||
mangaSlug: "one-piece_1695365223767"
|
||||
)
|
||||
print("Manga: \(manga.title)")
|
||||
} catch {
|
||||
print("Error: \(error.localizedDescription)")
|
||||
}
|
||||
```
|
||||
|
||||
**Proceso Interno:**
|
||||
1. Construye URL: `https://manhwaweb.com/manga/{slug}`
|
||||
2. Carga URL en WKWebView
|
||||
3. Espera 3 segundos a que JavaScript renderice
|
||||
4. Ejecuta JavaScript para extraer:
|
||||
- Título (de `<h1>` o `<title>`)
|
||||
- Descripción (de `<p>` con >100 caracteres)
|
||||
- Géneros (de links `/genero/*`)
|
||||
- Estado (via regex)
|
||||
- Cover image (de `.cover img`)
|
||||
5. Parsea resultado a `Manga`
|
||||
|
||||
---
|
||||
|
||||
##### `scrapeChapters(mangaSlug:)`
|
||||
|
||||
Obtiene la lista de capítulos de un manga.
|
||||
|
||||
**Firma:**
|
||||
```swift
|
||||
func scrapeChapters(mangaSlug: String) async throws -> [Chapter]
|
||||
```
|
||||
|
||||
**Parámetros:**
|
||||
- `mangaSlug`: `String` - Slug del manga
|
||||
|
||||
**Retorna:**
|
||||
- `[Chapter]` - Array de capítulos ordenados descendente
|
||||
|
||||
**Errors:**
|
||||
- `ScrapingError.webViewNotInitialized`
|
||||
- `ScrapingError.pageLoadFailed`
|
||||
- `ScrapingError.parsingError`
|
||||
|
||||
**Ejemplo:**
|
||||
```swift
|
||||
do {
|
||||
let chapters = try await ManhwaWebScraper.shared.scrapeChapters(
|
||||
mangaSlug: "one-piece_1695365223767"
|
||||
)
|
||||
print("Found \(chapters.count) chapters")
|
||||
} catch {
|
||||
print("Error: \(error.localizedDescription)")
|
||||
}
|
||||
```
|
||||
|
||||
**Proceso Interno:**
|
||||
1. Carga página del manga
|
||||
2. Ejecuta JavaScript que:
|
||||
- Busca todos los links `/leer/*`
|
||||
- Extrae número de capítulo via regex
|
||||
- Filtra duplicados
|
||||
- Ordena descendente
|
||||
3. Parsea a array de `Chapter`
|
||||
|
||||
---
|
||||
|
||||
##### `scrapeChapterImages(chapterSlug:)`
|
||||
|
||||
Obtiene las URLs de las imágenes de un capítulo.
|
||||
|
||||
**Firma:**
|
||||
```swift
|
||||
func scrapeChapterImages(chapterSlug: String) async throws -> [String]
|
||||
```
|
||||
|
||||
**Parámetros:**
|
||||
- `chapterSlug`: `String` - Slug del capítulo
|
||||
|
||||
**Retorna:**
|
||||
- `[String]` - Array de URLs de imágenes en orden
|
||||
|
||||
**Errors:**
|
||||
- `ScrapingError.webViewNotInitialized`
|
||||
- `ScrapingError.pageLoadFailed`
|
||||
|
||||
**Ejemplo:**
|
||||
```swift
|
||||
do {
|
||||
let images = try await ManhwaWebScraper.shared.scrapeChapterImages(
|
||||
chapterSlug: "one-piece/1"
|
||||
)
|
||||
print("Found \(images.count) pages")
|
||||
for (index, imageUrl) in images.enumerated() {
|
||||
print("Page \(index + 1): \(imageUrl)")
|
||||
}
|
||||
} catch {
|
||||
print("Error: \(error.localizedDescription)")
|
||||
}
|
||||
```
|
||||
|
||||
**Proceso Interno:**
|
||||
1. Carga URL del capítulo
|
||||
2. Espera 5 segundos (para imágenes)
|
||||
3. Ejecuta JavaScript que:
|
||||
- Selecciona todas las etiquetas `<img>`
|
||||
- Filtra elementos de UI (avatars, icons, logos)
|
||||
- Elimina duplicados
|
||||
4. Retorna array de URLs
|
||||
|
||||
---
|
||||
|
||||
#### Métodos Privados
|
||||
|
||||
##### `setupWebView()`
|
||||
|
||||
Configura el WKWebView con preferencias optimizadas.
|
||||
|
||||
##### `loadURLAndWait(_:waitForImages:)`
|
||||
|
||||
Carga una URL y espera a que JavaScript termine de renderizar.
|
||||
|
||||
**Parámetros:**
|
||||
- `url`: `URL` - URL a cargar
|
||||
- `waitForImages`: `Bool` - Si `true`, espera 5 segundos; si `false`, 3 segundos
|
||||
|
||||
---
|
||||
|
||||
### StorageService
|
||||
|
||||
Servicio responsable del almacenamiento local de datos.
|
||||
|
||||
```swift
|
||||
class StorageService
|
||||
```
|
||||
|
||||
#### Propiedades
|
||||
|
||||
| Propiedad | Tipo | Acceso | Descripción |
|
||||
|-----------|------|--------|-------------|
|
||||
| `shared` | `StorageService` | `static` | Instancia singleton compartida |
|
||||
| `documentsDirectory` | `URL` | `private` | Directorio de documentos |
|
||||
| `chaptersDirectory` | `URL` | `private` | Directorio de capítulos |
|
||||
|
||||
#### Gestión de Favoritos
|
||||
|
||||
##### `getFavorites()`
|
||||
|
||||
```swift
|
||||
func getFavorites() -> [String]
|
||||
```
|
||||
Retorna array de slugs de mangas favoritos.
|
||||
|
||||
##### `saveFavorite(mangaSlug:)`
|
||||
|
||||
```swift
|
||||
func saveFavorite(mangaSlug: String)
|
||||
```
|
||||
Guarda un manga como favorito (no duplica si ya existe).
|
||||
|
||||
##### `removeFavorite(mangaSlug:)`
|
||||
|
||||
```swift
|
||||
func removeFavorite(mangaSlug: String)
|
||||
```
|
||||
Elimina un manga de favoritos.
|
||||
|
||||
##### `isFavorite(mangaSlug:)`
|
||||
|
||||
```swift
|
||||
func isFavorite(mangaSlug: String) -> Bool
|
||||
```
|
||||
Verifica si un manga es favorito.
|
||||
|
||||
**Ejemplo:**
|
||||
```swift
|
||||
let storage = StorageService.shared
|
||||
|
||||
// Guardar favorito
|
||||
storage.saveFavorite(mangaSlug: "one-piece_1695365223767")
|
||||
|
||||
// Verificar
|
||||
if storage.isFavorite(mangaSlug: "one-piece_1695365223767") {
|
||||
print("Es favorito")
|
||||
}
|
||||
|
||||
// Listar todos
|
||||
let favorites = storage.getFavorites()
|
||||
print("Tienes \(favorites.count) favoritos")
|
||||
|
||||
// Eliminar
|
||||
storage.removeFavorite(mangaSlug: "one-piece_1695365223767")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Gestión de Progreso de Lectura
|
||||
|
||||
##### `saveReadingProgress(_:)`
|
||||
|
||||
```swift
|
||||
func saveReadingProgress(_ progress: ReadingProgress)
|
||||
```
|
||||
Guarda o actualiza el progreso de lectura.
|
||||
|
||||
##### `getReadingProgress(mangaSlug:chapterNumber:)`
|
||||
|
||||
```swift
|
||||
func getReadingProgress(mangaSlug: String, chapterNumber: Int) -> ReadingProgress?
|
||||
```
|
||||
Retorna el progreso de un capítulo específico.
|
||||
|
||||
##### `getAllReadingProgress()`
|
||||
|
||||
```swift
|
||||
func getAllReadingProgress() -> [ReadingProgress]
|
||||
```
|
||||
Retorna todo el progreso guardado.
|
||||
|
||||
##### `getLastReadChapter(mangaSlug:)`
|
||||
|
||||
```swift
|
||||
func getLastReadChapter(mangaSlug: String) -> ReadingProgress?
|
||||
```
|
||||
Retorna el capítulo más reciente leído de un manga.
|
||||
|
||||
**Ejemplo:**
|
||||
```swift
|
||||
let storage = StorageService.shared
|
||||
|
||||
// Guardar progreso
|
||||
let progress = ReadingProgress(
|
||||
mangaSlug: "one-piece_1695365223767",
|
||||
chapterNumber: 1,
|
||||
pageNumber: 15,
|
||||
timestamp: Date()
|
||||
)
|
||||
storage.saveReadingProgress(progress)
|
||||
|
||||
// Recuperar progreso
|
||||
if let savedProgress = storage.getReadingProgress(
|
||||
mangaSlug: "one-piece_1695365223767",
|
||||
chapterNumber: 1
|
||||
) {
|
||||
print("Última página: \(savedProgress.pageNumber)")
|
||||
}
|
||||
|
||||
// Último capítulo leído
|
||||
if let lastChapter = storage.getLastReadChapter(mangaSlug: "one-piece_1695365223767") {
|
||||
print("Último capítulo: \(lastChapter.chapterNumber)")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Gestión de Capítulos Descargados
|
||||
|
||||
##### `saveDownloadedChapter(_:)`
|
||||
|
||||
```swift
|
||||
func saveDownloadedChapter(_ chapter: DownloadedChapter)
|
||||
```
|
||||
Guarda metadata de un capítulo descargado.
|
||||
|
||||
##### `getDownloadedChapters()`
|
||||
|
||||
```swift
|
||||
func getDownloadedChapters() -> [DownloadedChapter]
|
||||
```
|
||||
Retorna todos los capítulos descargados.
|
||||
|
||||
##### `isChapterDownloaded(mangaSlug:chapterNumber:)`
|
||||
|
||||
```swift
|
||||
func isChapterDownloaded(mangaSlug: String, chapterNumber: Int) -> Bool
|
||||
```
|
||||
Verifica si un capítulo está descargado.
|
||||
|
||||
##### `deleteDownloadedChapter(mangaSlug:chapterNumber:)`
|
||||
|
||||
```swift
|
||||
func deleteDownloadedChapter(mangaSlug: String, chapterNumber: Int)
|
||||
```
|
||||
Elimina un capítulo descargado (archivos + metadata).
|
||||
|
||||
**Ejemplo:**
|
||||
```swift
|
||||
let storage = StorageService.shared
|
||||
|
||||
// Verificar si está descargado
|
||||
if storage.isChapterDownloaded(
|
||||
mangaSlug: "one-piece_1695365223767",
|
||||
chapterNumber: 1
|
||||
) {
|
||||
print("Capítulo ya descargado")
|
||||
} else {
|
||||
print("Capítulo no descargado")
|
||||
}
|
||||
|
||||
// Eliminar capítulo
|
||||
storage.deleteDownloadedChapter(
|
||||
mangaSlug: "one-piece_1695365223767",
|
||||
chapterNumber: 1
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Gestión de Imágenes
|
||||
|
||||
##### `saveImage(_:mangaSlug:chapterNumber:pageIndex:)`
|
||||
|
||||
```swift
|
||||
func saveImage(_ image: UIImage, mangaSlug: String, chapterNumber: Int, pageIndex: Int) async throws -> URL
|
||||
```
|
||||
Guarda una imagen en disco local.
|
||||
|
||||
**Parámetros:**
|
||||
- `image`: `UIImage` - Imagen a guardar
|
||||
- `mangaSlug`: `String` - Slug del manga
|
||||
- `chapterNumber`: `Int` - Número del capítulo
|
||||
- `pageIndex`: `Int` - Índice de la página
|
||||
|
||||
**Retorna:**
|
||||
- `URL` - Ruta del archivo guardado
|
||||
|
||||
**Errors:**
|
||||
- Error si no se puede convertir o guardar
|
||||
|
||||
**Ejemplo:**
|
||||
```swift
|
||||
let storage = StorageService.shared
|
||||
|
||||
do {
|
||||
let imageURL = try await storage.saveImage(
|
||||
image: myUIImage,
|
||||
mangaSlug: "one-piece_1695365223767",
|
||||
chapterNumber: 1,
|
||||
pageIndex: 0
|
||||
)
|
||||
print("Imagen guardada en: \(imageURL.path)")
|
||||
} catch {
|
||||
print("Error guardando imagen: \(error)")
|
||||
}
|
||||
```
|
||||
|
||||
##### `loadImage(mangaSlug:chapterNumber:pageIndex:)`
|
||||
|
||||
```swift
|
||||
func loadImage(mangaSlug: String, chapterNumber: Int, pageIndex: Int) -> UIImage?
|
||||
```
|
||||
Carga una imagen desde disco.
|
||||
|
||||
##### `getImageURL(mangaSlug:chapterNumber:pageIndex:)`
|
||||
|
||||
```swift
|
||||
func getImageURL(mangaSlug: String, chapterNumber: Int, pageIndex: Int) -> URL?
|
||||
```
|
||||
Retorna la URL local de una imagen si existe.
|
||||
|
||||
---
|
||||
|
||||
#### Gestión de Almacenamiento
|
||||
|
||||
##### `getStorageSize()`
|
||||
|
||||
```swift
|
||||
func getStorageSize() -> Int64
|
||||
```
|
||||
Retorna el tamaño total usado en bytes.
|
||||
|
||||
##### `clearAllDownloads()`
|
||||
|
||||
```swift
|
||||
func clearAllDownloads()
|
||||
```
|
||||
Elimina todos los capítulos descargados.
|
||||
|
||||
##### `formatFileSize(_:)`
|
||||
|
||||
```swift
|
||||
func formatFileSize(_ bytes: Int64) -> String
|
||||
```
|
||||
Formatea bytes a string legible (KB, MB, GB).
|
||||
|
||||
**Ejemplo:**
|
||||
```swift
|
||||
let storage = StorageService.shared
|
||||
|
||||
// Obtener tamaño usado
|
||||
let size = storage.getStorageSize()
|
||||
let formatted = storage.formatFileSize(size)
|
||||
print("Almacenamiento usado: \(formatted)")
|
||||
|
||||
// Limpiar todo
|
||||
storage.clearAllDownloads()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ViewModels
|
||||
|
||||
Los ViewModels coordinan entre Services y Views.
|
||||
|
||||
### MangaListViewModel
|
||||
|
||||
ViewModel para la lista principal de mangas.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
class MangaListViewModel: ObservableObject
|
||||
```
|
||||
|
||||
#### Propiedades Publicadas
|
||||
|
||||
| Propiedad | Tipo | Descripción |
|
||||
|-----------|------|-------------|
|
||||
| `mangas` | `[Manga]` | Lista de mangas cargados |
|
||||
| `isLoading` | `Bool` | Indicador de carga |
|
||||
| `searchText` | `String` | Texto de búsqueda |
|
||||
| `filter` | `MangaFilter` | Filtro actual (all/favorites/downloaded) |
|
||||
| `newMangaSlug` | `String` | Slug del nuevo manga a agregar |
|
||||
|
||||
#### Métodos Públicos
|
||||
|
||||
##### `loadMangas()`
|
||||
|
||||
```swift
|
||||
func loadMangas() async
|
||||
```
|
||||
Carga los mangas guardados en favoritos.
|
||||
|
||||
##### `addManga(_:)`
|
||||
|
||||
```swift
|
||||
func addManga(_ slug: String) async
|
||||
```
|
||||
Agrega un nuevo manga mediante scraping.
|
||||
|
||||
#### Propiedades Computadas
|
||||
|
||||
##### `filteredMangas`
|
||||
|
||||
```swift
|
||||
var filteredMangas: [Manga]
|
||||
```
|
||||
Retorna mangas filtrados por búsqueda y categoría.
|
||||
|
||||
**Ejemplo:**
|
||||
```swift
|
||||
@StateObject private var viewModel = MangaListViewModel()
|
||||
|
||||
// Cargar mangas
|
||||
await viewModel.loadMangas()
|
||||
|
||||
// Agregar manga
|
||||
await viewModel.addManga("one-piece_1695365223767")
|
||||
|
||||
// Filtrar
|
||||
viewModel.filter = .favorites
|
||||
viewModel.searchText = "one"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MangaDetailViewModel
|
||||
|
||||
ViewModel para el detalle de un manga.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
class MangaDetailViewModel: ObservableObject
|
||||
```
|
||||
|
||||
#### Propiedades Publicadas
|
||||
|
||||
| Propiedad | Tipo | Descripción |
|
||||
|-----------|------|-------------|
|
||||
| `chapters` | `[Chapter]` | Lista de capítulos |
|
||||
| `isLoadingChapters` | `Bool` | Indicador de carga |
|
||||
| `isFavorite` | `Bool` | Estado de favorito |
|
||||
| `selectedChapter` | `Chapter?` | Capítulo seleccionado |
|
||||
| `showingDownloadAll` | `Bool` | Mostrar diálogo de descarga |
|
||||
|
||||
#### Métodos Públicos
|
||||
|
||||
##### `loadChapters()`
|
||||
|
||||
```swift
|
||||
func loadChapters() async
|
||||
```
|
||||
Carga los capítulos del manga.
|
||||
|
||||
##### `toggleFavorite()`
|
||||
|
||||
```swift
|
||||
func toggleFavorite()
|
||||
```
|
||||
Alterna el estado de favorito.
|
||||
|
||||
##### `downloadAllChapters()`
|
||||
|
||||
```swift
|
||||
func downloadAllChapters()
|
||||
```
|
||||
Inicia descarga de todos los capítulos.
|
||||
|
||||
##### `downloadLastChapters(count:)`
|
||||
|
||||
```swift
|
||||
func downloadLastChapters(count: Int)
|
||||
```
|
||||
Descarga los últimos N capítulos.
|
||||
|
||||
**Ejemplo:**
|
||||
```swift
|
||||
@StateObject private var viewModel = MangaDetailViewModel(manga: manga)
|
||||
|
||||
// Cargar capítulos
|
||||
await viewModel.loadChapters()
|
||||
|
||||
// Marcar favorito
|
||||
viewModel.toggleFavorite()
|
||||
|
||||
// Descargar últimos 10
|
||||
viewModel.downloadLastChapters(count: 10)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ReaderViewModel
|
||||
|
||||
ViewModel para el lector de capítulos.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
class ReaderViewModel: ObservableObject
|
||||
```
|
||||
|
||||
#### Propiedades Publicadas
|
||||
|
||||
| Propiedad | Tipo | Descripción |
|
||||
|-----------|------|-------------|
|
||||
| `pages` | `[MangaPage]` | Lista de páginas |
|
||||
| `currentPage` | `Int` | Página actual |
|
||||
| `isLoading` | `Bool` | Indicador de carga |
|
||||
| `showError` | `Bool` | Mostrar error |
|
||||
| `showControls` | `Bool` | Mostrar controles UI |
|
||||
| `isFavorite` | `Bool` | Estado de favorito |
|
||||
| `isDownloaded` | `Bool` | Capítulo descargado |
|
||||
| `backgroundColor` | `Color` | Color de fondo |
|
||||
| `readingMode` | `ReadingMode` | Modo de lectura |
|
||||
|
||||
#### Métodos Públicos
|
||||
|
||||
##### `loadPages()`
|
||||
|
||||
```swift
|
||||
func loadPages() async
|
||||
```
|
||||
Carga las páginas del capítulo (desde local o web).
|
||||
|
||||
##### `cachePage(_:image:)`
|
||||
|
||||
```swift
|
||||
func cachePage(_ page: MangaPage, image: Image) async
|
||||
```
|
||||
Cachea una página localmente (TODO: implementar).
|
||||
|
||||
##### `toggleFavorite()`
|
||||
|
||||
```swift
|
||||
func toggleFavorite()
|
||||
```
|
||||
Alterna favorito del manga actual.
|
||||
|
||||
##### `cycleBackgroundColor()`
|
||||
|
||||
```swift
|
||||
func cycleBackgroundColor()
|
||||
```
|
||||
Cicla entre colores de fondo (blanco/negro/sepia).
|
||||
|
||||
#### Propiedades Computadas
|
||||
|
||||
- `currentPageIndex`: `Int` - Índice de página actual
|
||||
- `totalPages`: `Int` - Total de páginas
|
||||
|
||||
**Ejemplo:**
|
||||
```swift
|
||||
@StateObject private var viewModel = ReaderViewModel(manga: manga, chapter: chapter)
|
||||
|
||||
// Cargar páginas
|
||||
await viewModel.loadPages()
|
||||
|
||||
// Ir a página específica
|
||||
viewModel.currentPage = 10
|
||||
|
||||
// Cambiar fondo
|
||||
viewModel.cycleBackgroundColor()
|
||||
|
||||
// Cambiar modo lectura
|
||||
viewModel.readingMode = .horizontal
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Views
|
||||
|
||||
Las vistas son componentes SwiftUI que presentan la UI.
|
||||
|
||||
### ContentView
|
||||
|
||||
Vista principal que muestra la lista de mangas.
|
||||
|
||||
**Componentes:**
|
||||
- `MangaRowView`: Fila individual de manga
|
||||
- `MangaListViewModel`: ViewModel asociado
|
||||
|
||||
**Funcionalidades:**
|
||||
- Búsqueda de mangas
|
||||
- Filtros (todos/favoritos/descargados)
|
||||
- Agregar manga manualmente
|
||||
- Pull-to-refresh
|
||||
|
||||
### MangaDetailView
|
||||
|
||||
Vista de detalle de un manga específico.
|
||||
|
||||
**Componentes:**
|
||||
- `ChapterRowView`: Fila de capítulo
|
||||
- `FlowLayout`: Layout de géneros
|
||||
- `MangaDetailViewModel`: ViewModel asociado
|
||||
|
||||
**Funcionalidades:**
|
||||
- Mostrar información del manga
|
||||
- Listar capítulos
|
||||
- Marcar favorito
|
||||
- Descargar capítulos
|
||||
|
||||
### ReaderView
|
||||
|
||||
Vista de lectura de capítulos.
|
||||
|
||||
**Componentes:**
|
||||
- `PageView`: Vista de página individual
|
||||
- `ReaderViewModel`: ViewModel asociado
|
||||
|
||||
**Funcionalidades:**
|
||||
- Mostrar páginas con zoom/pan
|
||||
- Navegación entre páginas
|
||||
- Configurar fondo
|
||||
- Cambiar modo de lectura
|
||||
- Slider de navegación
|
||||
|
||||
---
|
||||
|
||||
## Errores y Manejo de Excepciones
|
||||
|
||||
### ScrapingError
|
||||
|
||||
Errores específicos del scraper.
|
||||
|
||||
```swift
|
||||
enum ScrapingError: LocalizedError {
|
||||
case webViewNotInitialized
|
||||
case pageLoadFailed
|
||||
case noContentFound
|
||||
case parsingError
|
||||
|
||||
var errorDescription: String? { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### Casos
|
||||
|
||||
| Error | Descripción | Mensaje (Español) |
|
||||
|-------|-------------|-------------------|
|
||||
| `webViewNotInitialized` | WKWebView no configurado | "WebView no está inicializado" |
|
||||
| `pageLoadFailed` | Error cargando página | "Error al cargar la página" |
|
||||
| `noContentFound` | No se encontró contenido | "No se encontró contenido" |
|
||||
| `parsingError` | Error procesando datos | "Error al procesar el contenido" |
|
||||
|
||||
### Estrategias de Manejo de Errores
|
||||
|
||||
1. **ViewModels**: Capturan errores del scraper
|
||||
2. **Views**: Muestran alertas al usuario
|
||||
3. **Servicios**: Propagan errores con `throws`
|
||||
|
||||
**Ejemplo:**
|
||||
```swift
|
||||
do {
|
||||
let manga = try await scraper.scrapeMangaInfo(mangaSlug: slug)
|
||||
// Manejar éxito
|
||||
} catch ScrapingError.webViewNotInitialized {
|
||||
// Mostrar alerta específica
|
||||
errorMessage = "Error de configuración"
|
||||
} catch {
|
||||
// Error genérico
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Última actualización**: Febrero 2026
|
||||
**Versión**: 1.0.0
|
||||
Reference in New Issue
Block a user