import SwiftUI struct MangaDetailView: View { let manga: Manga @StateObject private var viewModel: MangaDetailViewModel @StateObject private var storage = StorageService.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) } 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?") } .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) } ) } } } } } 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 @StateObject private var storage = StorageService.shared @ObservedObject private var downloadManager = DownloadManager.shared @State private var isDownloading = 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 if let downloadTask = currentDownloadTask { HStack { ProgressView(value: downloadTask.progress) .progressViewStyle(.linear) .frame(maxWidth: 150) Text("\(Int(downloadTask.progress * 100))%") .font(.caption2) .foregroundColor(.secondary) } } } Spacer() // Botón de descarga 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) } private var currentDownloadTask: DownloadTask? { let taskId = "\(mangaSlug)-\(chapter.number)" return downloadManager.activeDownloads.first { $0.id == taskId } } } // 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 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 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: - 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 )) } }