import SwiftUI struct MangaDetailView: View { let manga: Manga @StateObject private var viewModel: MangaDetailViewModel @StateObject private var storage = StorageService.shared @StateObject private var vpsClient = VPSAPIClient.shared init(manga: Manga) { self.manga = manga _viewModel = StateObject(wrappedValue: MangaDetailViewModel(manga: manga)) } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { // Header con info del manga mangaHeader Divider() // Lista de capítulos chaptersList } .padding() } .navigationTitle(manga.title) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { HStack { Button { viewModel.toggleFavorite() } label: { Image(systemName: viewModel.isFavorite ? "heart.fill" : "heart") .foregroundColor(viewModel.isFavorite ? .red : .primary) } // VPS Download Button Button { viewModel.showingVPSDownloadAll = true } label: { Image(systemName: "icloud.and.arrow.down") } .disabled(viewModel.chapters.isEmpty) Button { viewModel.showingDownloadAll = true } label: { Image(systemName: "arrow.down.doc") } .disabled(viewModel.chapters.isEmpty) } } } .alert("Descargar capítulos", isPresented: $viewModel.showingDownloadAll) { Button("Cancelar", role: .cancel) { } Button("Descargar últimos 10") { viewModel.downloadLastChapters(count: 10) } Button("Descargar todos") { viewModel.downloadAllChapters() } } message: { Text("¿Cuántos capítulos quieres descargar localmente?") } .alert("Descargar a VPS", isPresented: $viewModel.showingVPSDownloadAll) { Button("Cancelar", role: .cancel) { } Button("Últimos 10 a VPS") { Task { await viewModel.downloadLastChaptersToVPS(count: 10) } } Button("Todos a VPS") { Task { await viewModel.downloadAllChaptersToVPS() } } } message: { Text("¿Cuántos capítulos quieres descargar al servidor VPS?") } .task { await viewModel.loadChapters() } .overlay( Group { if viewModel.showDownloadNotification { VStack { Spacer() HStack { Image(systemName: viewModel.notificationMessage.contains("Error") ? "exclamationmark.triangle" : "checkmark.circle.fill") .foregroundColor(viewModel.notificationMessage.contains("Error") ? .red : .green) Text(viewModel.notificationMessage) .font(.subheadline) .foregroundColor(.primary) Spacer() } .padding() .background( RoundedRectangle(cornerRadius: 12) .fill(Color(.systemBackground)) .shadow(color: .black.opacity(0.2), radius: 10, x: 0, y: 5) ) .padding(.horizontal, 16) .padding(.bottom, 50) .transition(.move(edge: .bottom).combined(with: .opacity)) } .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 3) { viewModel.showDownloadNotification = false } } } } ) } private var mangaHeader: some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .top, spacing: 16) { // Cover image AsyncImage(url: URL(string: manga.coverImage ?? "")) { image in image .resizable() .aspectRatio(contentMode: .fill) } placeholder: { Rectangle() .fill(Color.gray.opacity(0.2)) .overlay( Image(systemName: "book.closed") .foregroundColor(.gray) ) } .frame(width: 100, height: 140) .cornerRadius(8) .clipped() // Info VStack(alignment: .leading, spacing: 8) { Text(manga.title) .font(.headline) Text(manga.displayStatus) .font(.caption) .padding(.horizontal, 8) .padding(.vertical, 2) .background(statusColor.opacity(0.2)) .foregroundColor(statusColor) .cornerRadius(4) if !manga.genres.isEmpty { FlowLayout(spacing: 4) { ForEach(manga.genres, id: \.self) { genre in Text(genre) .font(.caption2) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.gray.opacity(0.2)) .cornerRadius(4) } } } } Spacer() } if !manga.description.isEmpty { Text(manga.description) .font(.subheadline) .foregroundColor(.secondary) } // Stats HStack(spacing: 20) { Label("\(viewModel.chapters.count) caps.", systemImage: "list.bullet") if let lastRead = storage.getLastReadChapter(mangaSlug: manga.slug) { Label("Último: \(lastRead.chapterNumber)", systemImage: "book.closed") } } .font(.caption) .foregroundColor(.secondary) } .padding() .background( RoundedRectangle(cornerRadius: 12) .fill(Color(.systemBackground)) .shadow(color: .black.opacity(0.05), radius: 3, x: 0, y: 1) ) } private var chaptersList: some View { VStack(alignment: .leading, spacing: 12) { Text("Capítulos") .font(.headline) if viewModel.isLoadingChapters { ProgressView("Cargando capítulos...") .frame(maxWidth: .infinity, minHeight: 200) } else if viewModel.chapters.isEmpty { Text("No hay capítulos disponibles") .foregroundColor(.secondary) .frame(maxWidth: .infinity, minHeight: 200) } else { LazyVStack(spacing: 8) { ForEach(viewModel.chapters) { chapter in ChapterRowView( chapter: chapter, mangaSlug: manga.slug, onTap: { viewModel.selectedChapter = chapter }, onDownloadToggle: { await viewModel.downloadChapter(chapter) }, onVPSDownloadToggle: { await viewModel.downloadChapterToVPS(chapter) } ) } } } } } private var statusColor: Color { switch manga.status { case "PUBLICANDOSE": return .green case "FINALIZADO": return .blue case "EN_PAUSA", "EN_ESPERA": return .orange default: return .gray } } } struct ChapterRowView: View { let chapter: Chapter let mangaSlug: String let onTap: () -> Void let onDownloadToggle: () async -> Void let onVPSDownloadToggle: () async -> Void @StateObject private var storage = StorageService.shared @ObservedObject private var downloadManager = DownloadManager.shared @ObservedObject var vpsClient = VPSAPIClient.shared @State private var isDownloading = false @State private var isVPSDownloaded = false @State private var isVPSChecked = false var body: some View { Button(action: onTap) { HStack { VStack(alignment: .leading, spacing: 4) { Text(chapter.displayNumber) .font(.subheadline) .fontWeight(.medium) if let progress = storage.getReadingProgress(mangaSlug: mangaSlug, chapterNumber: chapter.number) { Text("Leído hasta página \(progress.pageNumber)") .font(.caption) .foregroundColor(.secondary) ProgressView(value: Double(progress.pageNumber), total: 100) .progressViewStyle(.linear) } // Mostrar progreso de descarga local if let downloadTask = currentDownloadTask { HStack { ProgressView(value: downloadTask.progress) .progressViewStyle(.linear) .frame(maxWidth: 150) Text("\(Int(downloadTask.progress * 100))%") .font(.caption2) .foregroundColor(.secondary) } } // Mostrar progreso de descarga VPS if vpsClient.activeDownloads.contains("\(mangaSlug)-\(chapter.number)"), let progress = vpsClient.downloadProgress["\(mangaSlug)-\(chapter.number)"] { HStack { Image(systemName: "icloud.and.arrow.down") .font(.caption2) .foregroundColor(.blue) ProgressView(value: progress) .progressViewStyle(.linear) .frame(maxWidth: 100) Text("VPS \(Int(progress * 100))%") .font(.caption2) .foregroundColor(.blue) } } } Spacer() // VPS Download Button / Status if isVPSChecked { if isVPSDownloaded { Image(systemName: "icloud.fill") .foregroundColor(.blue) } else { Button { Task { await onVPSDownloadToggle() } } label: { Image(systemName: "icloud.and.arrow.up") .foregroundColor(.blue) } .buttonStyle(.plain) } } // Botón de descarga local if !storage.isChapterDownloaded(mangaSlug: mangaSlug, chapterNumber: chapter.number) { Button { Task { await onDownloadToggle() } } label: { if currentDownloadTask != nil { Image(systemName: "xmark.circle.fill") .foregroundColor(.red) } else { Image(systemName: "arrow.down.circle") .foregroundColor(.blue) } } .buttonStyle(.plain) } else if chapter.isRead { Image(systemName: "eye") .foregroundColor(.blue) } if storage.isChapterDownloaded(mangaSlug: mangaSlug, chapterNumber: chapter.number) { Image(systemName: "checkmark.circle.fill") .foregroundColor(.green) } Image(systemName: "chevron.right") .font(.caption) .foregroundColor(.secondary) } .padding() .background( RoundedRectangle(cornerRadius: 8) .fill(Color(.systemGray6)) ) } .buttonStyle(.plain) .task { // Check VPS status when row appears if !isVPSChecked { await checkVPSStatus() } } } private var currentDownloadTask: DownloadTask? { let taskId = "\(mangaSlug)-\(chapter.number)" return downloadManager.activeDownloads.first { $0.id == taskId } } private func checkVPSStatus() async { do { let manifest = try await vpsClient.getChapterManifest( mangaSlug: mangaSlug, chapterNumber: chapter.number ) isVPSDownloaded = manifest != nil isVPSChecked = true } catch { // If error, assume not downloaded on VPS isVPSDownloaded = false isVPSChecked = true } } } // MARK: - ViewModel @MainActor class MangaDetailViewModel: ObservableObject { @Published var chapters: [Chapter] = [] @Published var isLoadingChapters = false @Published var isFavorite: Bool @Published var selectedChapter: Chapter? @Published var showingDownloadAll = false @Published var showingVPSDownloadAll = false @Published var isDownloading = false @Published var downloadProgress: [String: Double] = [:] @Published var showDownloadNotification = false @Published var notificationMessage = "" private let manga: Manga private let scraper = ManhwaWebScraper.shared private let storage = StorageService.shared private let downloadManager = DownloadManager.shared private let vpsClient = VPSAPIClient.shared init(manga: Manga) { self.manga = manga _isFavorite = Published(initialValue: storage.isFavorite(mangaSlug: manga.slug)) } func loadChapters() async { isLoadingChapters = true do { let fetchedChapters = try await scraper.scrapeChapters(mangaSlug: manga.slug) // Marcar capítulos descargados var chaptersWithStatus = fetchedChapters for index in chaptersWithStatus.indices { if storage.isChapterDownloaded(mangaSlug: manga.slug, chapterNumber: chaptersWithStatus[index].number) { chaptersWithStatus[index].isDownloaded = true } if let progress = storage.getReadingProgress(mangaSlug: manga.slug, chapterNumber: chaptersWithStatus[index].number) { chaptersWithStatus[index].lastReadPage = progress.pageNumber chaptersWithStatus[index].isRead = progress.isCompleted } } chapters = chaptersWithStatus } catch { print("Error loading chapters: \(error)") } isLoadingChapters = false } func toggleFavorite() { if storage.isFavorite(mangaSlug: manga.slug) { storage.removeFavorite(mangaSlug: manga.slug) } else { storage.saveFavorite(mangaSlug: manga.slug) } isFavorite.toggle() } func downloadAllChapters() { isDownloading = true Task { await downloadManager.downloadChapters( mangaSlug: manga.slug, mangaTitle: manga.title, chapters: chapters ) await showDownloadCompletionNotification(chapters.count) isDownloading = false // Recargar estado de capítulos await loadChapters() } } func downloadLastChapters(count: Int) { let lastChapters = Array(chapters.prefix(count)) isDownloading = true Task { await downloadManager.downloadChapters( mangaSlug: manga.slug, mangaTitle: manga.title, chapters: lastChapters ) await showDownloadCompletionNotification(lastChapters.count) isDownloading = false // Recargar estado de capítulos await loadChapters() } } func downloadChapter(_ chapter: Chapter) async { do { try await downloadManager.downloadChapter( mangaSlug: manga.slug, mangaTitle: manga.title, chapter: chapter ) await showDownloadCompletionNotification(1) // Recargar estado de capítulos await loadChapters() } catch { print("Error downloading chapter: \(error.localizedDescription)") notificationMessage = "Error al descargar capítulo \(chapter.number)" showDownloadNotification = true } } func getDownloadProgress(for chapter: Chapter) -> Double? { let taskId = "\(manga.slug)-\(chapter.number)" return downloadManager.activeDownloads.first { $0.id == taskId }?.progress } func isDownloadingChapter(_ chapter: Chapter) -> Bool { let taskId = "\(manga.slug)-\(chapter.number)" return downloadManager.activeDownloads.contains { $0.id == taskId } } private func showDownloadCompletionNotification(_ count: Int) async { notificationMessage = "\(count) capítulo(s) descargado(s) correctamente" showDownloadNotification = true // Ocultar notificación después de 3 segundos try? await Task.sleep(nanoseconds: 3_000_000_000) showDownloadNotification = false } // MARK: - VPS Download Methods /// Download a single chapter to VPS func downloadChapterToVPS(_ chapter: Chapter) async { do { // First, get the image URLs for the chapter let imageUrls = try await scraper.scrapeChapterImages(chapterSlug: chapter.slug) // Download to VPS let result = try await vpsClient.downloadChapter( mangaSlug: manga.slug, chapterNumber: chapter.number, chapterSlug: chapter.slug, imageUrls: imageUrls ) if result.success { if result.alreadyDownloaded { notificationMessage = "Capítulo \(chapter.number) ya estaba en VPS" } else { notificationMessage = "Capítulo \(chapter.number) descargado a VPS" } } else { notificationMessage = "Error al descargar capítulo \(chapter.number) a VPS" } showDownloadNotification = true // Hide notification after 3 seconds try? await Task.sleep(nanoseconds: 3_000_000_000) showDownloadNotification = false } catch { notificationMessage = "Error VPS: \(error.localizedDescription)" showDownloadNotification = true } } /// Download all chapters to VPS func downloadAllChaptersToVPS() async { isDownloading = true var successCount = 0 var failCount = 0 for chapter in chapters { do { let imageUrls = try await scraper.scrapeChapterImages(chapterSlug: chapter.slug) let result = try await vpsClient.downloadChapter( mangaSlug: manga.slug, chapterNumber: chapter.number, chapterSlug: chapter.slug, imageUrls: imageUrls ) if result.success { successCount += 1 } else { failCount += 1 } } catch { failCount += 1 } } isDownloading = false if failCount == 0 { notificationMessage = "\(successCount) capítulos descargados a VPS" } else { notificationMessage = "\(successCount) exitosos, \(failCount) fallidos en VPS" } showDownloadNotification = true try? await Task.sleep(nanoseconds: 3_000_000_000) showDownloadNotification = false } /// Download last N chapters to VPS func downloadLastChaptersToVPS(count: Int) async { let lastChapters = Array(chapters.prefix(count)) isDownloading = true var successCount = 0 var failCount = 0 for chapter in lastChapters { do { let imageUrls = try await scraper.scrapeChapterImages(chapterSlug: chapter.slug) let result = try await vpsClient.downloadChapter( mangaSlug: manga.slug, chapterNumber: chapter.number, chapterSlug: chapter.slug, imageUrls: imageUrls ) if result.success { successCount += 1 } else { failCount += 1 } } catch { failCount += 1 } } isDownloading = false if failCount == 0 { notificationMessage = "\(successCount) capítulos descargados a VPS" } else { notificationMessage = "\(successCount) exitosos, \(failCount) fallidos en VPS" } showDownloadNotification = true try? await Task.sleep(nanoseconds: 3_000_000_000) showDownloadNotification = false } } // MARK: - FlowLayout struct FlowLayout: Layout { var spacing: CGFloat = 8 func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { let result = FlowResult( in: proposal.replacingUnspecifiedDimensions().width, subviews: subviews, spacing: spacing ) return result.size } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { let result = FlowResult( in: bounds.width, subviews: subviews, spacing: spacing ) for (index, subview) in subviews.enumerated() { subview.place(at: CGPoint(x: bounds.minX + result.frames[index].minX, y: bounds.minY + result.frames[index].minY), proposal: .unspecified) } } struct FlowResult { var frames: [CGRect] = [] var size: CGSize = .zero init(in maxWidth: CGFloat, subviews: Subviews, spacing: CGFloat) { var currentX: CGFloat = 0 var currentY: CGFloat = 0 var lineHeight: CGFloat = 0 for subview in subviews { let size = subview.sizeThatFits(.unspecified) if currentX + size.width > maxWidth && currentX > 0 { currentX = 0 currentY += lineHeight + spacing lineHeight = 0 } frames.append(CGRect(origin: CGPoint(x: currentX, y: currentY), size: size)) currentX += size.width + spacing lineHeight = max(lineHeight, size.height) } self.size = CGSize(width: maxWidth, height: currentY + lineHeight) } } } #Preview { NavigationView { MangaDetailView(manga: Manga( slug: "one-piece_1695365223767", title: "One Piece", description: "La historia de piratas y aventuras", genres: ["Acción", "Aventura", "Comedia"], status: "PUBLICANDOSE", url: "https://manhwaweb.com/manga/one-piece_1695365223767", coverImage: nil )) } }