diff --git a/.gitignore b/.gitignore index 2846f81..0fe69ab 100644 --- a/.gitignore +++ b/.gitignore @@ -131,4 +131,10 @@ iOSInjectionProject/ /*.gcno **/xcshareddata/WorkspaceSettings.xcsettings -# End of https://www.toptal.com/developers/gitignore/api/xcode,swift,macos \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/xcode,swift,macos + +.env +opencode* +.kilocode +.swiftzilla +tmp \ No newline at end of file diff --git a/TabNews/Models/ContentSummary.swift b/TabNews/Models/ContentSummary.swift new file mode 100644 index 0000000..96731b5 --- /dev/null +++ b/TabNews/Models/ContentSummary.swift @@ -0,0 +1,20 @@ +// +// ContentSummary.swift +// TabNews +// +// Created by Assistant on 09/12/25. +// + +import Foundation + +struct ContentSummary: Codable, Identifiable { + let id: String // Same as ContentResponse id + let summary: String + let createdAt: Date + + init(id: String, summary: String) { + self.id = id + self.summary = summary + self.createdAt = Date() + } +} diff --git a/TabNews/Services/SummarizationService.swift b/TabNews/Services/SummarizationService.swift new file mode 100644 index 0000000..2b60d75 --- /dev/null +++ b/TabNews/Services/SummarizationService.swift @@ -0,0 +1,103 @@ +// +// SummarizationService.swift +// TabNews +// +// Created by Assistant on 09/12/25. +// + +import Foundation +import FoundationModels + +@MainActor +class SummarizationService { + static let shared = SummarizationService() + + private init() {} + + /// Check if FoundationModels is available on the current device + func isAvailable() -> Bool { + // Check if the system language model is available + let model = SystemLanguageModel.default + + switch model.availability { + case .available: + return true + case .unavailable: + return false + } + } + + /// Get the availability status with detailed reason + func getAvailabilityStatus() -> SystemLanguageModel.Availability { + return SystemLanguageModel.default.availability + } + + /// Summarize content from a ContentResponse (includes title, owner, date, body, and source) + func summarize(content: ContentResponse) async throws -> String { + guard isAvailable() else { + throw NSError( + domain: "SummarizationService", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "FoundationModels not available on this device"] + ) + } + + // Create a session with instructions for summarization + let instructions = """ + Você é um assistente que resume conteúdos de forma concisa e objetiva. + Responda sempre em português. + Use apenas texto simples, SEM markdown, sem negrito, sem itálico, sem formatação. + Gere APENAS UM PARÁGRAFO único, sem quebras de linha ou espaços extras. + """ + + let session = LanguageModelSession(instructions: instructions) + + // Build comprehensive content string with all information that appears in PostDetailView + var contentToSummarize = """ + Título: \(content.title) + Autor: \(content.ownerUsername) + """ + + // Add publication date if available + if !content.publishedAt.isEmpty { + contentToSummarize += "\nPublicado: \(content.publishedAt)" + } + + // Add body content + if let body = content.body, !body.isEmpty { + contentToSummarize += "\n\nConteúdo:\n\(body)" + } + + // Add source URL if available + if let sourceURL = content.sourceURL, !sourceURL.isEmpty { + contentToSummarize += "\n\nFonte: \(sourceURL)" + } + + // Add instructions for the model + contentToSummarize += """ + + + Forneça um resumo conciso em texto simples (sem formatação) como um único parágrafo. + Não use quebras de linha. Resuma os pontos principais de forma objetiva. + """ + + // Generate the summary + let prompt = Prompt(contentToSummarize) + let response = try await session.respond(to: prompt) + + // Clean the response to ensure single paragraph + return cleanSummary(response.content) + } + + /// Clean summary to ensure it's a single paragraph without newlines + private func cleanSummary(_ text: String) -> String { + // Replace newlines with spaces + let cleaned = text + .replacingOccurrences(of: "\n", with: " ") + .replacingOccurrences(of: "\r", with: " ") + .replacingOccurrences(of: "\t", with: " ") + + // Remove multiple spaces + return cleaned.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression).trimmingCharacters(in: .whitespaces) + } +} diff --git a/TabNews/ViewModels/HomeViewModel.swift b/TabNews/ViewModels/HomeViewModel.swift index 68f7f4a..e0d19de 100644 --- a/TabNews/ViewModels/HomeViewModel.swift +++ b/TabNews/ViewModels/HomeViewModel.swift @@ -17,7 +17,14 @@ final class HomeViewModel: ObservableObject { @Published private(set) var isLoadingNextPage = false @Published private(set) var isRefreshing = false + // Summary-related state + @Published var summaries: [String: ContentSummary] = [:] + @Published var summarizingIds: Set = [] + @Published var summaryErrors: [String: String] = [:] + @Published var isFoundationModelsAvailable: Bool = false + private let contentService = ContentService.shared + private let summarizationService = SummarizationService.shared private let isoFormatter = ISO8601DateFormatter() private let relativeFormatter: RelativeDateTimeFormatter = { let formatter = RelativeDateTimeFormatter() @@ -30,6 +37,10 @@ final class HomeViewModel: ObservableObject { private let perPage = 20 private let strategy = "relevant" + init() { + checkFoundationModelsAvailability() + } + func loadContents(reset: Bool = false) async { if isLoading || isLoadingNextPage { return } @@ -77,6 +88,57 @@ final class HomeViewModel: ObservableObject { return relativeFormatter.localizedString(for: date, relativeTo: Date()) } + + // MARK: - Summary Methods + + func checkFoundationModelsAvailability() { + isFoundationModelsAvailable = summarizationService.isAvailable() + } + + func summarizeContent(_ content: ContentResponse) async { + // Check if already summarizing + if summarizingIds.contains(content.id) { + return + } + + // Mark as summarizing + summarizingIds.insert(content.id) + summaryErrors.removeValue(forKey: content.id) + + do { + // Summarize all content (title, owner, date, body, source URL) + let summaryText = try await summarizationService.summarize(content: content) + + // Create and store summary + let summary = ContentSummary(id: content.id, summary: summaryText) + summaries[content.id] = summary + + } catch { + // Store error message + summaryErrors[content.id] = error.localizedDescription + } + + // Remove from summarizing set + summarizingIds.remove(content.id) + } + + func getSummary(for contentId: String) -> ContentSummary? { + return summaries[contentId] + } + + func isSummarizing(_ contentId: String) -> Bool { + return summarizingIds.contains(contentId) + } + + func getSummaryError(for contentId: String) -> String? { + return summaryErrors[contentId] + } + + func clearSummary(for contentId: String) { + summaries.removeValue(forKey: contentId) + summaryErrors.removeValue(forKey: contentId) + summarizingIds.remove(contentId) + } } private extension HomeViewModel { diff --git a/TabNews/Views/HomeView.swift b/TabNews/Views/HomeView.swift index a508daa..40c5cbd 100644 --- a/TabNews/Views/HomeView.swift +++ b/TabNews/Views/HomeView.swift @@ -22,9 +22,50 @@ struct HomeView: View { errorView(with: error) } else { List { + // Show FoundationModels availability banner if not available + if !homeViewModel.isFoundationModelsAvailable && !homeViewModel.contents.isEmpty { + foundationModelsBanner() + } + ForEach(Array(homeViewModel.contents.enumerated()), id: \.element.id) { index, content in - NavigationLink(destination: PostDetailView(username: content.ownerUsername, slug: content.slug)) { - contentRow(index: index + 1, content: content) + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 8) { + // Vertical stack for number and icon button + VStack(alignment: .center, spacing: 4) { + Text("\(index + 1).") + .font(.headline) + .foregroundColor(.secondary) + .frame(width: 25, alignment: .trailing) + + // Summary icon button (only if FoundationModels is available) + if homeViewModel.isFoundationModelsAvailable { + summaryIconButton(for: content) + } + } + + // Main content area + VStack(alignment: .leading, spacing: 6) { + // Title and subtitle (clickable for navigation) + NavigationLink(destination: PostDetailView(username: content.ownerUsername, slug: content.slug)) { + VStack(alignment: .leading, spacing: 6) { + Text(content.title) + .font(.headline) + .foregroundColor(.primary) + + Text(rowSubtitle(for: content)) + .font(.footnote) + .foregroundColor(.secondary) + } + } + .buttonStyle(.plain) + + // Summary display area (loading, summary, or error) + if homeViewModel.isFoundationModelsAvailable { + summaryDisplay(for: content) + } + } + } + .padding(.vertical, 8) } .listRowSeparator(.hidden) .onAppear { @@ -118,23 +159,162 @@ private extension HomeView { .frame(maxWidth: .infinity) } - func contentRow(index: Int, content: ContentResponse) -> some View { - HStack(alignment: .top, spacing: 12) { - Text("\(index).") - .font(.headline) - .foregroundColor(.secondary) + func foundationModelsBanner() -> some View { + HStack(spacing: 12) { + Image(systemName: "brain.head") + .foregroundColor(.orange) + .font(.title3) - VStack(alignment: .leading, spacing: 6) { - Text(content.title) - .font(.headline) - .foregroundColor(.primary) - - Text(rowSubtitle(for: content)) - .font(.footnote) + VStack(alignment: .leading, spacing: 4) { + Text("Resumo inteligente indisponível") + .font(.caption) + .fontWeight(.semibold) + Text("Seu dispositivo não suporta Apple Intelligence. Os botões de resumo foram removidos.") + .font(.caption2) .foregroundColor(.secondary) } + + Spacer() + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.orange.opacity(0.1)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.orange.opacity(0.3), lineWidth: 1) + ) + ) + .padding(.horizontal) + .padding(.vertical, 4) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + + @ViewBuilder + func summaryIconButton(for content: ContentResponse) -> some View { + let isSummarizing = homeViewModel.isSummarizing(content.id) + let hasSummary = homeViewModel.getSummary(for: content.id) != nil + let hasError = homeViewModel.getSummaryError(for: content.id) != nil + + // Only show button if not summarizing, no summary, and no error + if !isSummarizing && !hasSummary && !hasError { + Button { + Task { + await homeViewModel.summarizeContent(content) + } + } label: { + Image(systemName: "sparkles") + .font(.caption2) + .fontWeight(.bold) + .foregroundColor(.white) + .frame(width: 18, height: 18) + .background( + Circle() + .fill( + LinearGradient( + colors: [Color.pink, Color.purple], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + ) + } + .buttonStyle(.plain) + } + } + + @ViewBuilder + func summaryDisplay(for content: ContentResponse) -> some View { + let summary = homeViewModel.getSummary(for: content.id) + let isSummarizing = homeViewModel.isSummarizing(content.id) + let error = homeViewModel.getSummaryError(for: content.id) + + // Show this section if any of these conditions are true + if isSummarizing || summary != nil || error != nil { + if isSummarizing { + // Activity indicator while generating + HStack(spacing: 8) { + ProgressView() + .scaleEffect(0.8) + .tint(.purple) + Text("Gerando resumo...") + .font(.caption) + .foregroundColor(.purple) + Spacer() + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.purple.opacity(0.08)) + ) + .padding(.top, 4) + } else if let summary = summary { + // Show the summary content (clickable to remove) + Button { + // Remove the summary + homeViewModel.clearSummary(for: content.id) + } label: { + Text(summary.summary) + .font(.caption) + .foregroundColor(.primary) + .lineLimit(nil) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8) + .fill( + LinearGradient( + colors: [Color.pink.opacity(0.12), Color.purple.opacity(0.12)], + startPoint: .leading, + endPoint: .trailing + ) + ) + ) + .overlay( + HStack { + Spacer() + Image(systemName: "xmark") + .font(.caption2) + .foregroundColor(.purple) + .padding(4) + } + .padding(4) + ) + .padding(.top, 4) + } else if let error = error { + // Show error (clickable to retry) + Button { + // Retry summarization + Task { + homeViewModel.clearSummary(for: content.id) + await homeViewModel.summarizeContent(content) + } + } label: { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.purple) + .font(.caption) + Text(error) + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Image(systemName: "arrow.counterclockwise") + .font(.caption2) + .foregroundColor(.purple) + } + } + .buttonStyle(.plain) + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.purple.opacity(0.08)) + ) + .padding(.top, 4) + } } - .padding(.vertical, 8) } func rowSubtitle(for content: ContentResponse) -> String {