Files
MangaReader/ios-app/Sources/Views/DownloadsView.swift
renato97 b474182dd9 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>
2026-02-04 15:34:18 +01:00

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()
}
}