✨ 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>
390 lines
12 KiB
Swift
390 lines
12 KiB
Swift
import SwiftUI
|
|
|
|
struct DownloadsView: View {
|
|
@StateObject private var viewModel = DownloadsViewModel()
|
|
@State private var selectedTab: DownloadsViewModel.DownloadTab = .active
|
|
@State private var showingClearAlert = false
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Tab selector
|
|
Picker("Tipo de descarga", selection: $selectedTab) {
|
|
ForEach(DownloadsViewModel.DownloadTab.allCases, id: \.self) { tab in
|
|
Label(tab.rawValue, systemImage: tab.icon)
|
|
.tag(tab)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.padding()
|
|
|
|
// Content
|
|
ScrollView {
|
|
VStack(spacing: 16) {
|
|
switch selectedTab {
|
|
case .active:
|
|
activeDownloadsView
|
|
case .completed:
|
|
completedDownloadsView
|
|
case .failed:
|
|
failedDownloadsView
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
|
|
// Storage info footer
|
|
storageInfoFooter
|
|
}
|
|
.navigationTitle("Descargas")
|
|
.alert("Limpiar almacenamiento", isPresented: $showingClearAlert) {
|
|
Button("Cancelar", role: .cancel) { }
|
|
Button("Limpiar", role: .destructive) {
|
|
viewModel.clearAllStorage()
|
|
}
|
|
} message: {
|
|
Text("Esta acción eliminará todos los capítulos descargados. ¿Estás seguro?")
|
|
}
|
|
}
|
|
|
|
private var activeDownloadsView: some View {
|
|
VStack(spacing: 12) {
|
|
if viewModel.downloadManager.activeDownloads.isEmpty {
|
|
emptyStateView(
|
|
icon: "arrow.down.circle",
|
|
title: "No hay descargas activas",
|
|
message: "Las descargas aparecerán aquí cuando comiences a descargar capítulos"
|
|
)
|
|
} else {
|
|
ForEach(viewModel.downloadManager.activeDownloads) { task in
|
|
ActiveDownloadCard(task: task)
|
|
}
|
|
|
|
// Cancel all button
|
|
if !viewModel.downloadManager.activeDownloads.isEmpty {
|
|
Button(action: {
|
|
viewModel.downloadManager.cancelAllDownloads()
|
|
}) {
|
|
Label("Cancelar todas", systemImage: "xmark.circle")
|
|
.foregroundColor(.red)
|
|
.frame(maxWidth: .infinity)
|
|
.padding()
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.fill(Color.red.opacity(0.1))
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var completedDownloadsView: some View {
|
|
VStack(spacing: 12) {
|
|
if viewModel.downloadManager.completedDownloads.isEmpty {
|
|
emptyStateView(
|
|
icon: "checkmark.circle",
|
|
title: "No hay descargas completadas",
|
|
message: "Los capítulos descargados aparecerán aquí"
|
|
)
|
|
} else {
|
|
ForEach(viewModel.downloadManager.completedDownloads.reversed()) { task in
|
|
CompletedDownloadCard(task: task)
|
|
}
|
|
|
|
// Clear history button
|
|
Button(action: {
|
|
viewModel.downloadManager.clearCompletedHistory()
|
|
}) {
|
|
Text("Limpiar historial")
|
|
.foregroundColor(.blue)
|
|
.padding()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var failedDownloadsView: some View {
|
|
VStack(spacing: 12) {
|
|
if viewModel.downloadManager.failedDownloads.isEmpty {
|
|
emptyStateView(
|
|
icon: "exclamationmark.triangle",
|
|
title: "No hay descargas fallidas",
|
|
message: "Las descargas con errores aparecerán aquí"
|
|
)
|
|
} else {
|
|
ForEach(viewModel.downloadManager.failedDownloads.reversed()) { task in
|
|
FailedDownloadCard(task: task)
|
|
}
|
|
|
|
// Clear history button
|
|
Button(action: {
|
|
viewModel.downloadManager.clearFailedHistory()
|
|
}) {
|
|
Text("Limpiar historial")
|
|
.foregroundColor(.blue)
|
|
.padding()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var storageInfoFooter: some View {
|
|
VStack(spacing: 8) {
|
|
Divider()
|
|
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Almacenamiento usado")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
Text(viewModel.storageSizeString)
|
|
.font(.headline)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button(action: {
|
|
showingClearAlert = true
|
|
}) {
|
|
Text("Limpiar todo")
|
|
.font(.caption)
|
|
.foregroundColor(.red)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(Color.red.opacity(0.1))
|
|
)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
.background(Color(.systemGray6))
|
|
}
|
|
|
|
private func emptyStateView(icon: String, title: String, message: String) -> some View {
|
|
VStack(spacing: 16) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 60))
|
|
.foregroundColor(.gray)
|
|
|
|
VStack(spacing: 8) {
|
|
Text(title)
|
|
.font(.headline)
|
|
.foregroundColor(.primary)
|
|
|
|
Text(message)
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, 32)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 300)
|
|
}
|
|
}
|
|
|
|
// MARK: - ViewModel
|
|
|
|
@MainActor
|
|
class DownloadsViewModel: ObservableObject {
|
|
@Published var downloadManager = DownloadManager.shared
|
|
@Published var storage = StorageService.shared
|
|
@Published var storageSize: Int64 = 0
|
|
|
|
enum DownloadTab: String, CaseIterable {
|
|
case active = "Activas"
|
|
case completed = "Completadas"
|
|
case failed = "Fallidas"
|
|
|
|
var icon: String {
|
|
switch self {
|
|
case .active: return "arrow.down.circle"
|
|
case .completed: return "checkmark.circle"
|
|
case .failed: return "exclamationmark.triangle"
|
|
}
|
|
}
|
|
}
|
|
|
|
var storageSizeString: String {
|
|
storage.formatFileSize(storage.getStorageSize())
|
|
}
|
|
|
|
init() {
|
|
updateStorageSize()
|
|
}
|
|
|
|
func clearAllStorage() {
|
|
storage.clearAllDownloads()
|
|
downloadManager.clearCompletedHistory()
|
|
downloadManager.clearFailedHistory()
|
|
updateStorageSize()
|
|
}
|
|
|
|
private func updateStorageSize() {
|
|
storageSize = storage.getStorageSize()
|
|
}
|
|
}
|
|
|
|
// MARK: - Download Cards
|
|
|
|
struct ActiveDownloadCard: View {
|
|
@ObservedObject var task: DownloadTask
|
|
@StateObject private var downloadManager = DownloadManager.shared
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
// Header
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(task.mangaTitle)
|
|
.font(.headline)
|
|
.lineLimit(1)
|
|
|
|
Text("Capítulo \(task.chapterNumber)")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Cancel button
|
|
Button(action: {
|
|
downloadManager.cancelDownload(taskId: task.id)
|
|
}) {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundColor(.red)
|
|
.font(.title3)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
// Progress bar
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
ProgressView(value: task.progress)
|
|
.progressViewStyle(.linear)
|
|
.tint(.blue)
|
|
|
|
HStack {
|
|
Text("\(task.downloadedPages) de \(task.imageURLs.count) páginas")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
Spacer()
|
|
|
|
Text("\(Int(task.progress * 100))%")
|
|
.font(.caption)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.blue)
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(Color(.systemBackground))
|
|
.shadow(color: .black.opacity(0.05), radius: 3, x: 0, y: 1)
|
|
)
|
|
}
|
|
}
|
|
|
|
struct CompletedDownloadCard: View {
|
|
let task: DownloadTask
|
|
@StateObject private var storage = StorageService.shared
|
|
|
|
var body: some View {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(task.mangaTitle)
|
|
.font(.headline)
|
|
.lineLimit(1)
|
|
|
|
Text("Capítulo \(task.chapterNumber)")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
|
|
Text("Completado")
|
|
.font(.caption)
|
|
.foregroundColor(.green)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundColor(.green)
|
|
.font(.title3)
|
|
}
|
|
.padding()
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(Color(.systemBackground))
|
|
.shadow(color: .black.opacity(0.05), radius: 3, x: 0, y: 1)
|
|
)
|
|
}
|
|
}
|
|
|
|
struct FailedDownloadCard: View {
|
|
let task: DownloadTask
|
|
@StateObject private var downloadManager = DownloadManager.shared
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
// Header
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(task.mangaTitle)
|
|
.font(.headline)
|
|
.lineLimit(1)
|
|
|
|
Text("Capítulo \(task.chapterNumber)")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundColor(.red)
|
|
.font(.title3)
|
|
}
|
|
|
|
// Error message
|
|
if let error = task.error {
|
|
Text(error)
|
|
.font(.caption)
|
|
.foregroundColor(.red)
|
|
.lineLimit(2)
|
|
}
|
|
|
|
// Retry button
|
|
Button(action: {
|
|
// TODO: Implement retry functionality
|
|
print("Retry download for chapter \(task.chapterNumber)")
|
|
}) {
|
|
Label("Reintentar", systemImage: "arrow.clockwise")
|
|
.font(.subheadline)
|
|
.foregroundColor(.blue)
|
|
.frame(maxWidth: .infinity)
|
|
.padding()
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(Color.blue.opacity(0.1))
|
|
)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(Color(.systemBackground))
|
|
.shadow(color: .black.opacity(0.05), radius: 3, x: 0, y: 1)
|
|
)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
NavigationView {
|
|
DownloadsView()
|
|
}
|
|
}
|