From 50b6e1d5a4f4d40d17aaab9e4d4a8cb345545410 Mon Sep 17 00:00:00 2001 From: Renato Ferraz Date: Mon, 29 Dec 2025 16:49:11 -0300 Subject: [PATCH 1/3] Adds news highlights carousel Introduces a carousel view for displaying featured news at the top of the news feed. This enhances the user experience by prominently showcasing important stories, and supports landscape and iPad layouts. It also introduces the ability to handle horizontal swipes with a UIKit view representable. --- .../Extensions/DateExtensions.swift | 134 +++++++++ .../Components/CollectionViewWithHeader.swift | 151 ++++++++++ .../Components/FeedHighlightCardView.swift | 136 +++++++++ .../FeedHighlightsCarouselView.swift | 257 ++++++++++++++++++ .../Components/HorizontalPanCaptureView.swift | 82 ++++++ .../NewsLibrary/Views/Mock/PreviewData.swift | 65 +++++ .../Sources/NewsLibrary/Views/NewsView.swift | 86 ++++-- .../PodcastTests/PodcastViewModelTests.swift | 1 - .../MacMagazine/Features/News/NewsView.swift | 22 +- 9 files changed, 911 insertions(+), 23 deletions(-) create mode 100644 MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Extensions/DateExtensions.swift create mode 100644 MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/CollectionViewWithHeader.swift create mode 100644 MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightCardView.swift create mode 100644 MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightsCarouselView.swift create mode 100644 MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/HorizontalPanCaptureView.swift create mode 100644 MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Mock/PreviewData.swift diff --git a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Extensions/DateExtensions.swift b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Extensions/DateExtensions.swift new file mode 100644 index 00000000..7fabe4c9 --- /dev/null +++ b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Extensions/DateExtensions.swift @@ -0,0 +1,134 @@ +import Foundation + +// MARK: - Date Extensions for Feed + +public extension Date { + + // MARK: - Custom Format + + /// Format date with custom format string + func formatted(as format: String) -> String { + let formatter = DateFormatter() + formatter.dateFormat = format + formatter.locale = Locale(identifier: "pt_BR") + return formatter.string(from: self) + } + + // MARK: - Relative Formatting + + /// Returns a human-readable relative time string + var relativeTimeString: String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + formatter.locale = Locale(identifier: "pt_BR") + return formatter.localizedString(for: self, relativeTo: Date()) + } + + /// Returns a formatted date string for feed display + var feedDisplayString: String { + let calendar = Calendar.current + let now = Date() + + if calendar.isDateInToday(self) { + return "Hoje, \(formatted(date: .omitted, time: .shortened))" + } else if calendar.isDateInYesterday(self) { + return "Ontem, \(formatted(date: .omitted, time: .shortened))" + } else if let daysAgo = calendar.dateComponents([.day], from: self, to: now).day, daysAgo < 7 { + return relativeTimeString + } else { + return formatted(date: .abbreviated, time: .omitted) + } + } + + // MARK: - Time Ago String + + /// Returns a detailed time ago string + var timeAgoString: String { + let calendar = Calendar.current + let now = Date() + let components = calendar.dateComponents([.minute, .hour, .day, .weekOfYear, .month, .year], from: self, to: now) + + if let years = components.year, years > 0 { + return years == 1 ? "há 1 ano" : "há \(years) anos" + } + if let months = components.month, months > 0 { + return months == 1 ? "há 1 mês" : "há \(months) meses" + } + if let weeks = components.weekOfYear, weeks > 0 { + return weeks == 1 ? "há 1 semana" : "há \(weeks) semanas" + } + if let days = components.day, days > 0 { + return days == 1 ? "há 1 dia" : "há \(days) dias" + } + if let hours = components.hour, hours > 0 { + return hours == 1 ? "há 1 hora" : "há \(hours) horas" + } + if let minutes = components.minute, minutes > 0 { + return minutes == 1 ? "há 1 minuto" : "há \(minutes) minutos" + } + + return "agora" + } + + // MARK: - Accessibility String + + /// Returns a detailed accessibility-friendly date string + var accessibilityDateString: String { + let formatter = DateFormatter() + formatter.dateStyle = .full + formatter.timeStyle = .short + formatter.locale = Locale(identifier: "pt_BR") + return formatter.string(from: self) + } + + /// Hoje às HH:mm / Ontem às HH:mm / dd/MM/yyyy às HH:mm + var feedDateTimeDisplay: String { + let calendar = Calendar.current + + if calendar.isDateInToday(self) { + return "Hoje às \(Self.feedTimeFormatter.string(from: self))" + } + + if calendar.isDateInYesterday(self) { + return "Ontem às \(Self.feedTimeFormatter.string(from: self))" + } + + return Self.feedDateTimeFormatter.string(from: self) + } + + // MARK: - Private formatters (cache) + + private static let feedTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "pt_BR") + formatter.dateFormat = "HH:mm" + return formatter + }() + + private static let feedDateTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "pt_BR") + formatter.dateFormat = "dd/MM/yyyy 'às' HH:mm" + return formatter + }() +} + +// MARK: - Preview Helper + +#if DEBUG +extension Date { + static var mockDates: [Date] { + let now = Date() + return [ + now, + now.addingTimeInterval(-60 * 5), // 5 minutes ago + now.addingTimeInterval(-60 * 60), // 1 hour ago + now.addingTimeInterval(-60 * 60 * 5), // 5 hours ago + now.addingTimeInterval(-60 * 60 * 24), // 1 day ago + now.addingTimeInterval(-60 * 60 * 24 * 3), // 3 days ago + now.addingTimeInterval(-60 * 60 * 24 * 7), // 1 week ago + now.addingTimeInterval(-60 * 60 * 24 * 30) // 1 month ago + ] + } +} +#endif diff --git a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/CollectionViewWithHeader.swift b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/CollectionViewWithHeader.swift new file mode 100644 index 00000000..599ee484 --- /dev/null +++ b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/CollectionViewWithHeader.swift @@ -0,0 +1,151 @@ +// TODO: Migrate to the CollectionView component located in Libraries +import SwiftUI +import UIComponentsLibrary + +// MARK: - Collection View With Header + +/// CollectionView with optional header support +/// Header appears above the grid and scrolls with the content +public struct CollectionViewWithHeader: View { + + // MARK: - Properties + + @State private var cardWidth = CGFloat.zero + @Binding var scrollPosition: ScrollPosition + + private let title: String + private let status: APIStatus + private let usesDensity: Bool + private var favorite: Bool + private var isSearching: Bool + private var quantity: Int + private var density: CardDensity { .density(using: cardWidth) } + + private let header: (() -> Header)? + private let retryAction: (() -> Void)? + private let content: () -> Content + + private let grid = GridItem( + .adaptive(minimum: 280), + spacing: 20, + alignment: .top + ) + + // MARK: - Initialization + + public init( + title: String, + status: APIStatus, + usesDensity: Bool = true, + scrollPosition: Binding, + favorite: Bool = false, + isSearching: Bool = false, + quantity: Int = 0, + @ViewBuilder header: @escaping () -> Header, + @ViewBuilder content: @escaping () -> Content, + retryAction: (() -> Void)? = nil + ) { + self.title = title + self.status = status + self.usesDensity = usesDensity + self.favorite = favorite + self.isSearching = isSearching + self.quantity = quantity + self.header = header + self.content = content + self.retryAction = retryAction + + _scrollPosition = scrollPosition + } + + // MARK: - Body + + public var body: some View { + VStack { + LoadingAndErrorView( + title: title, + status: status, + favorite: favorite, + isSearching: isSearching, + quantity: quantity, + retryAction: retryAction + ) + + ScrollView { + VStack(spacing: 20) { + // Header (full width, outside grid) + if let header { + header() + } + + // Grid content + LazyVGrid( + columns: Array(repeating: grid, count: usesDensity ? density.columns : 1), + spacing: 20 + ) { + content() + } + .padding(.horizontal) + } + } + .scrollPosition($scrollPosition) + } + .cardSize { value in + cardWidth = value + } + } +} + +// MARK: - Convenience Init (No Header) + +extension CollectionViewWithHeader where Header == EmptyView { + public init( + title: String, + status: APIStatus, + usesDensity: Bool = true, + scrollPosition: Binding, + favorite: Bool = false, + isSearching: Bool = false, + quantity: Int = 0, + @ViewBuilder content: @escaping () -> Content, + retryAction: (() -> Void)? = nil + ) { + self.title = title + self.status = status + self.usesDensity = usesDensity + self.favorite = favorite + self.isSearching = isSearching + self.quantity = quantity + self.header = nil + self.content = content + self.retryAction = retryAction + + _scrollPosition = scrollPosition + } +} + +// MARK: - Preview + +#if DEBUG +#Preview { + CollectionViewWithHeader( + title: "Notícias", + status: .done, + scrollPosition: .constant(ScrollPosition()), + header: { + RoundedRectangle(cornerRadius: 16) + .fill(Color.blue.opacity(0.3)) + .frame(height: 200) + .overlay(Text("Header")) + }, + content: { + ForEach(0..<10, id: \.self) { index in + RoundedRectangle(cornerRadius: 12) + .fill(Color.gray.opacity(0.3)) + .frame(height: 150) + .overlay(Text("Card \(index)")) + } + } + ) +} +#endif diff --git a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightCardView.swift b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightCardView.swift new file mode 100644 index 00000000..8c03fa4f --- /dev/null +++ b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightCardView.swift @@ -0,0 +1,136 @@ +import FeedLibrary +import MacMagazineLibrary +import SwiftUI +import UIComponentsLibrary + +// MARK: - Feed Highlight Card View + +/// Card view for featured/highlighted posts in the carousel +public struct FeedHighlightCardView: View { + + // MARK: - Properties + + let post: FeedDB + + @Environment(\.theme) private var theme: ThemeColor + + // MARK: - Body + + public var body: some View { + GeometryReader { geometry in + ZStack(alignment: .bottom) { + backgroundImage + .frame(width: geometry.size.width, height: geometry.size.height) + + gradientOverlay + + contentOverlay + .frame(width: geometry.size.width) + } + } + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .shadow(color: .black.opacity(0.3), radius: 12, x: 0, y: 6) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + } + + // MARK: - Background Image + + @ViewBuilder + private var backgroundImage: some View { + if let url = URL(string: post.artworkURL) { + CachedAsyncImage(image: url, contentMode: .fill) + } else { + placeholderImage + } + } + + private var placeholderImage: some View { + LinearGradient( + colors: [.gray.opacity(0.4), .gray.opacity(0.6)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + // MARK: - Gradient Overlay + + private var gradientOverlay: some View { + LinearGradient( + gradient: Gradient(stops: [ + .init(color: .clear, location: 0.0), + .init(color: .black.opacity(0.2), location: 0.5), + .init(color: .black.opacity(0.85), location: 1.0) + ]), + startPoint: .top, + endPoint: .bottom + ) + } + + // MARK: - Content Overlay + + private var contentOverlay: some View { + VStack(alignment: .leading, spacing: 8) { + Spacer() + + Text(post.title) + .font(.title2) + .fontWeight(.bold) + .foregroundStyle(.white) + .lineLimit(3) + .multilineTextAlignment(.leading) + + dateLabel + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var dateLabel: some View { + HStack(spacing: 6) { + Image(systemName: "calendar") + Text(post.pubDate.feedDateTimeDisplay) + } + .font(.subheadline) + .foregroundStyle(.white.opacity(0.85)) + } + + // MARK: - Accessibility + + private var accessibilityLabel: String { + var label = post.title + if post.favorite { + label += ", favoritado" + } + label += ", publicado em \(post.pubDate.formatted(as: "dd 'de' MMMM 'de' yyyy 'às' HH:mm"))" + return label + } + + // MARK: - Init + + public init(post: FeedDB) { + self.post = post + } +} + +// MARK: - Preview + +#if DEBUG +private struct FeedHighlightCardPreview: View { + var body: some View { + ZStack { + Color.black.opacity(0.9).ignoresSafeArea() + + if let post = PreviewData.sampleHighlights.first { + FeedHighlightCardView(post: post) + .frame(width: 340, height: 420) + .padding() + } + } + } +} + +#Preview { + FeedHighlightCardPreview() +} +#endif diff --git a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightsCarouselView.swift b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightsCarouselView.swift new file mode 100644 index 00000000..70fb9df4 --- /dev/null +++ b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightsCarouselView.swift @@ -0,0 +1,257 @@ +import FeedLibrary +import MacMagazineLibrary +import SwiftUI +import UIKit + +// MARK: - Feed Highlights Carousel View + +/// Auto-scrolling carousel for featured posts with large centered card and peek on edges +public struct FeedHighlightsCarouselView: View { + + // MARK: - Properties + + let highlights: [FeedDB] + let sectionId: String + let heroNamespace: Namespace.ID? + let onTap: (FeedDB) -> Void + + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.verticalSizeClass) private var verticalSizeClass + + @State private var currentIndex: Int = 0 + @State private var timer: Timer? + @State private var lastInteractionDate = Date() + @State private var isAutoScrollPaused = false + @State private var dragOffset: CGFloat = 0 + + // Auto-scroll settings + private let autoScrollInterval: TimeInterval = 10.0 + private let pauseDuration: TimeInterval = 30.0 + + // MARK: - Computed Properties + + /// Check if device is in landscape mode + private var isLandscape: Bool { + verticalSizeClass == .compact + } + + /// Check if running on iPad + private var isIPad: Bool { + horizontalSizeClass == .regular && verticalSizeClass == .regular + } + + /// Card height based on orientation + private var cardHeight: CGFloat { + if isIPad { + return 500 + } + return isLandscape ? 240 : 420 + } + + /// Spacing between cards + private var spacing: CGFloat { + if isIPad { + return 20 + } + return isLandscape ? 24 : 0 + } + + /// Peek width (visible portion of adjacent cards) + private var peekWidth: CGFloat { + if isIPad { + return 80 + } + return isLandscape ? 10 : 25 + } + + // MARK: - Initialization + + public init( + highlights: [FeedDB], + sectionId: String = "highlights", + heroNamespace: Namespace.ID? = nil, + onTap: @escaping (FeedDB) -> Void + ) { + self.highlights = highlights + self.sectionId = sectionId + self.heroNamespace = heroNamespace + self.onTap = onTap + } + + // MARK: - Body + + public var body: some View { + GeometryReader { geometry in + let screenWidth = geometry.size.width + let cardWidth = screenWidth - (peekWidth * 2) - spacing + + ZStack { + ForEach(Array(highlights.enumerated()), id: \.element.postId) { index, post in + let offset = calculateOffset( + for: index, + currentIndex: currentIndex, + cardWidth: cardWidth, + spacing: spacing, + dragOffset: dragOffset + ) + + let scale = calculateScale(for: index, currentIndex: currentIndex) + let opacity = calculateOpacity(for: index, currentIndex: currentIndex) + + FeedHighlightCardView(post: post) + .frame(width: cardWidth, height: cardHeight) + .scaleEffect(scale) + .opacity(opacity) + .offset(x: offset) + .zIndex(index == currentIndex ? 1 : 0) + .allowsHitTesting(index == currentIndex) + .contentShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .onTapGesture { + guard index == currentIndex else { return } + onTap(post) + } + .animation(.easeInOut(duration: 0.3), value: currentIndex) + .animation(.interactiveSpring(response: 0.3, dampingFraction: 0.8), value: dragOffset) + } + } + .frame(width: screenWidth, height: cardHeight + 20) + .contentShape(Rectangle()) + .overlay { + HorizontalPanCaptureView( + onTap: { + guard highlights.indices.contains(currentIndex) else { return } + onTap(highlights[currentIndex]) + }, + onChanged: { value in + pauseAutoScroll() + dragOffset = value + }, + onEnded: { translationX, velocityX in + handlePanEnded(translationX: translationX, + velocityX: velocityX) + } + ) + } + } + .frame(height: cardHeight + 20) + .onAppear { startAutoScroll() } + .onDisappear { stopAutoScroll() } + } + + // MARK: - Pan Handling (UIKit) + + private func handlePanEnded(translationX: CGFloat, velocityX: CGFloat) { + let threshold: CGFloat = 50 + + withAnimation(.easeInOut(duration: 0.3)) { + if translationX < -threshold || velocityX < -600 { + if currentIndex < highlights.count - 1 { + currentIndex += 1 + } + } else if translationX > threshold || velocityX > 600 { + if currentIndex > 0 { + currentIndex -= 1 + } + } + dragOffset = 0 + } + } + + // MARK: - Layout Calculations + + private func calculateOffset( + for index: Int, + currentIndex: Int, + cardWidth: CGFloat, + spacing: CGFloat, + dragOffset: CGFloat + ) -> CGFloat { + let diff = index - currentIndex + let baseOffset = CGFloat(diff) * (cardWidth + spacing) + return baseOffset + dragOffset + } + + private func calculateScale(for index: Int, currentIndex: Int) -> CGFloat { + index == currentIndex ? 1.0 : 0.92 + } + + private func calculateOpacity(for index: Int, currentIndex: Int) -> Double { + let diff = abs(index - currentIndex) + switch diff { + case 0: return 1.0 + case 1: return 0.7 + case 2: return 0.4 + default: return 0 + } + } + + // MARK: - Auto Scroll Control + + private func startAutoScroll() { + stopAutoScroll() + + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + Task { @MainActor in + checkAndAdvance() + } + } + } + + private func stopAutoScroll() { + timer?.invalidate() + timer = nil + } + + private func pauseAutoScroll() { + lastInteractionDate = Date() + isAutoScrollPaused = true + } + + private func checkAndAdvance() { + let timeSinceInteraction = Date().timeIntervalSince(lastInteractionDate) + + if isAutoScrollPaused { + if timeSinceInteraction >= pauseDuration { + isAutoScrollPaused = false + lastInteractionDate = Date() + } + return + } + + if timeSinceInteraction >= autoScrollInterval { + advanceToNextSlide() + lastInteractionDate = Date() + } + } + + private func advanceToNextSlide() { + guard !highlights.isEmpty else { return } + + withAnimation(.easeInOut(duration: 0.5)) { + if currentIndex < highlights.count - 1 { + currentIndex += 1 + } else { + currentIndex = 0 + } + } + } +} + +// MARK: - Preview + +#if DEBUG +#Preview("iPhone Portrait") { + ZStack { + Color.gray.opacity(0.2).ignoresSafeArea() + + VStack { + FeedHighlightsCarouselView( + highlights: PreviewData.sampleHighlights, + onTap: { _ in } + ) + + Spacer() + } + } +} +#endif diff --git a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/HorizontalPanCaptureView.swift b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/HorizontalPanCaptureView.swift new file mode 100644 index 00000000..24540d79 --- /dev/null +++ b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/HorizontalPanCaptureView.swift @@ -0,0 +1,82 @@ +import SwiftUI +import UIKit + +// MARK: - Horizontal Pan Capture View + +/// UIViewRepresentable that captures horizontal pan gestures +/// Used to handle carousel swipe without conflicting with vertical ScrollView +struct HorizontalPanCaptureView: UIViewRepresentable { + let onTap: () -> Void + let onChanged: (CGFloat) -> Void + let onEnded: (CGFloat, CGFloat) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onTap: onTap, onChanged: onChanged, onEnded: onEnded) + } + + func makeUIView(context: Context) -> UIView { + let view = UIView() + view.backgroundColor = .clear + + let pan = UIPanGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePan(_:))) + pan.delegate = context.coordinator + pan.cancelsTouchesInView = false + + let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:))) + tap.delegate = context.coordinator + tap.cancelsTouchesInView = false + + tap.require(toFail: pan) + + view.addGestureRecognizer(pan) + view.addGestureRecognizer(tap) + + return view + } + + func updateUIView(_ uiView: UIView, context: Context) {} + + final class Coordinator: NSObject, UIGestureRecognizerDelegate { + let onTap: () -> Void + let onChanged: (CGFloat) -> Void + let onEnded: (CGFloat, CGFloat) -> Void + + init( + onTap: @escaping () -> Void, + onChanged: @escaping (CGFloat) -> Void, + onEnded: @escaping (CGFloat, CGFloat) -> Void + ) { + self.onTap = onTap + self.onChanged = onChanged + self.onEnded = onEnded + } + + @objc func handleTap(_ recognizer: UITapGestureRecognizer) { + guard recognizer.state == .ended else { return } + onTap() + } + + @objc func handlePan(_ recognizer: UIPanGestureRecognizer) { + let translation = recognizer.translation(in: recognizer.view) + let velocity = recognizer.velocity(in: recognizer.view) + + switch recognizer.state { + case .began, .changed: + onChanged(translation.x) + + case .ended, .cancelled, .failed: + onEnded(translation.x, velocity.x) + + default: + break + } + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + true + } + } +} diff --git a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Mock/PreviewData.swift b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Mock/PreviewData.swift new file mode 100644 index 00000000..8e256304 --- /dev/null +++ b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Mock/PreviewData.swift @@ -0,0 +1,65 @@ +import FeedLibrary +import Foundation + +// MARK: - Preview Helpers + +/// Sample data for SwiftUI previews only +#if DEBUG +enum PreviewData { + + // MARK: - Sample Posts + + static func samplePost( + id: String = "1", + title: String = "Apple Intelligence: tudo sobre a nova IA da Apple", + favorite: Bool = false, + categories: [String] = ["Destaques"] + ) -> FeedDB { + FeedDB( + postId: id, + title: title, + subtitle: "Subtítulo da notícia", + pubDate: Date().addingTimeInterval(-3600), + artworkURL: "https://picsum.photos/id/\(Int.random(in: 100...500))/800/450", + link: "https://www.macmagazine.com/", + categories: categories, + excerpt: "Este é um resumo da notícia com informações relevantes para os leitores.", + fullContent: "Conteúdo completo...", + favorite: favorite + ) + } + + static var sampleHighlights: [FeedDB] { + (1...10).map { index in + samplePost( + id: "highlight_\(index)", + title: "Destaque \(index): Novidade importante da Apple", + favorite: index % 3 == 0, + categories: ["Destaques"] + ) + } + } + + static var sampleNews: [FeedDB] { + (1...20).map { index in + samplePost( + id: "news_\(index)", + title: "Notícia \(index): Atualização sobre produtos Apple", + favorite: index % 5 == 0, + categories: ["Últimas Notícias"] + ) + } + } + + static var sampleFavorites: [FeedDB] { + (1...5).map { index in + samplePost( + id: "fav_\(index)", + title: "Favorito \(index): Post salvo pelo usuário", + favorite: true, + categories: ["Últimas Notícias"] + ) + } + } +} +#endif diff --git a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/NewsView.swift b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/NewsView.swift index 5c5d9f97..60d0622f 100644 --- a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/NewsView.swift +++ b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/NewsView.swift @@ -21,7 +21,29 @@ public struct NewsView: View { @State private var search: String = "" - @Query private var news: [FeedDB] + @Query(sort: \FeedDB.pubDate, order: .reverse) + private var allNews: [FeedDB] + + /// Filtered highlights from allNews + private var highlights: [FeedDB] { + allNews.filter { $0.categories.contains("Destaques") } + } + + /// Filtered news based on favorite and category + private var news: [FeedDB] { + var filtered = favorite ? allNews.filter { $0.favorite } : allNews + + if category != .all { + filtered = filtered.filter { $0.categories.contains(category.filterKey) } + } + + return filtered + } + + /// Show highlights only when not filtering and category is "all" + private var shouldShowHighlights: Bool { + !favorite && category == .all && !highlights.isEmpty + } public init( storage: Database, @@ -33,17 +55,6 @@ public struct NewsView: View { _favorite = favorite _category = category _scrollPosition = scrollPosition - - let favorite = favorite.wrappedValue - let predicate = #Predicate { - $0.favorite == favorite - } - _news = Query( - filter: favorite ? predicate : nil, - sort: \FeedDB.pubDate, - order: .reverse, - animation: .smooth - ) } public var body: some View { @@ -61,6 +72,7 @@ public struct NewsView: View { } } } + extension NewsView { @ViewBuilder var content: some View { @@ -70,13 +82,7 @@ extension NewsView { } } - let news = if category == .all { - news - } else { - news.filter { $0.categories.contains(category.filterKey) } - } - - CollectionView( + CollectionViewWithHeader( title: "Notícias", status: viewModel.status, usesDensity: true, @@ -84,6 +90,14 @@ extension NewsView { favorite: favorite, isSearching: !search.isEmpty, quantity: search.isEmpty ? news.count : 0, + header: { + if shouldShowHighlights { + FeedHighlightsCarouselView( + highlights: Array(highlights.prefix(10)), + onTap: { _ in } + ) + } + }, content: { ForEach( 0.. [NetworkMockData] { ) ] } - diff --git a/MacMagazine/MacMagazine/Features/News/NewsView.swift b/MacMagazine/MacMagazine/Features/News/NewsView.swift index 7f2ce130..aa0baa24 100644 --- a/MacMagazine/MacMagazine/Features/News/NewsView.swift +++ b/MacMagazine/MacMagazine/Features/News/NewsView.swift @@ -15,11 +15,13 @@ struct NewsView: View { @State private var category = false @State private var scrollPosition = ScrollPosition() @State private var newsCategory = NewsCategory.all + @State private var isTransitioning = false var body: some View { ZStack(alignment: .top) { (theme.main.background.color ?? Color.secondary).ignoresSafeArea() content + .opacity(isTransitioning ? 0 : 1) } .navigationTitle("Notícias") .toolbar(show: !shouldUseSidebar, menu: favoriteButton, options: categoriesButton) @@ -28,12 +30,26 @@ struct NewsView: View { .presentationDragIndicator(.visible) .presentationDetents([.fraction(0.33)]) } - .task(id: viewModel.news) { - withAnimation(.easeInOut(duration: 0.4)) { - newsCategory = viewModel.news.toNewsCategory + .onChange(of: viewModel.news) { _, newValue in + let newCategory = newValue.toNewsCategory + guard newsCategory != newCategory else { return } + + withAnimation(.easeOut(duration: 0.15)) { + isTransitioning = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + newsCategory = newCategory category = false + + withAnimation(.easeIn(duration: 0.2)) { + isTransitioning = false + } } } + .onAppear { + newsCategory = viewModel.news.toNewsCategory + } } } From 40a234d8f5a7cde21bd10be06de3e194dc12ed7e Mon Sep 17 00:00:00 2001 From: Renato Ferraz Date: Mon, 29 Dec 2025 17:15:38 -0300 Subject: [PATCH 2/3] Updates feed highlight card date display Replaces the specific date formatting with a more user-friendly "time ago" display in the feed highlight card. This change improves the user experience by presenting relative time information, like "5 minutes ago" or "yesterday", instead of fixed date formats, making it easier for users to quickly understand when the news was published. --- .../Extensions/DateExtensions.swift | 134 ------------------ .../Components/FeedHighlightCardView.swift | 4 +- 2 files changed, 2 insertions(+), 136 deletions(-) delete mode 100644 MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Extensions/DateExtensions.swift diff --git a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Extensions/DateExtensions.swift b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Extensions/DateExtensions.swift deleted file mode 100644 index 7fabe4c9..00000000 --- a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Extensions/DateExtensions.swift +++ /dev/null @@ -1,134 +0,0 @@ -import Foundation - -// MARK: - Date Extensions for Feed - -public extension Date { - - // MARK: - Custom Format - - /// Format date with custom format string - func formatted(as format: String) -> String { - let formatter = DateFormatter() - formatter.dateFormat = format - formatter.locale = Locale(identifier: "pt_BR") - return formatter.string(from: self) - } - - // MARK: - Relative Formatting - - /// Returns a human-readable relative time string - var relativeTimeString: String { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - formatter.locale = Locale(identifier: "pt_BR") - return formatter.localizedString(for: self, relativeTo: Date()) - } - - /// Returns a formatted date string for feed display - var feedDisplayString: String { - let calendar = Calendar.current - let now = Date() - - if calendar.isDateInToday(self) { - return "Hoje, \(formatted(date: .omitted, time: .shortened))" - } else if calendar.isDateInYesterday(self) { - return "Ontem, \(formatted(date: .omitted, time: .shortened))" - } else if let daysAgo = calendar.dateComponents([.day], from: self, to: now).day, daysAgo < 7 { - return relativeTimeString - } else { - return formatted(date: .abbreviated, time: .omitted) - } - } - - // MARK: - Time Ago String - - /// Returns a detailed time ago string - var timeAgoString: String { - let calendar = Calendar.current - let now = Date() - let components = calendar.dateComponents([.minute, .hour, .day, .weekOfYear, .month, .year], from: self, to: now) - - if let years = components.year, years > 0 { - return years == 1 ? "há 1 ano" : "há \(years) anos" - } - if let months = components.month, months > 0 { - return months == 1 ? "há 1 mês" : "há \(months) meses" - } - if let weeks = components.weekOfYear, weeks > 0 { - return weeks == 1 ? "há 1 semana" : "há \(weeks) semanas" - } - if let days = components.day, days > 0 { - return days == 1 ? "há 1 dia" : "há \(days) dias" - } - if let hours = components.hour, hours > 0 { - return hours == 1 ? "há 1 hora" : "há \(hours) horas" - } - if let minutes = components.minute, minutes > 0 { - return minutes == 1 ? "há 1 minuto" : "há \(minutes) minutos" - } - - return "agora" - } - - // MARK: - Accessibility String - - /// Returns a detailed accessibility-friendly date string - var accessibilityDateString: String { - let formatter = DateFormatter() - formatter.dateStyle = .full - formatter.timeStyle = .short - formatter.locale = Locale(identifier: "pt_BR") - return formatter.string(from: self) - } - - /// Hoje às HH:mm / Ontem às HH:mm / dd/MM/yyyy às HH:mm - var feedDateTimeDisplay: String { - let calendar = Calendar.current - - if calendar.isDateInToday(self) { - return "Hoje às \(Self.feedTimeFormatter.string(from: self))" - } - - if calendar.isDateInYesterday(self) { - return "Ontem às \(Self.feedTimeFormatter.string(from: self))" - } - - return Self.feedDateTimeFormatter.string(from: self) - } - - // MARK: - Private formatters (cache) - - private static let feedTimeFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "pt_BR") - formatter.dateFormat = "HH:mm" - return formatter - }() - - private static let feedDateTimeFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "pt_BR") - formatter.dateFormat = "dd/MM/yyyy 'às' HH:mm" - return formatter - }() -} - -// MARK: - Preview Helper - -#if DEBUG -extension Date { - static var mockDates: [Date] { - let now = Date() - return [ - now, - now.addingTimeInterval(-60 * 5), // 5 minutes ago - now.addingTimeInterval(-60 * 60), // 1 hour ago - now.addingTimeInterval(-60 * 60 * 5), // 5 hours ago - now.addingTimeInterval(-60 * 60 * 24), // 1 day ago - now.addingTimeInterval(-60 * 60 * 24 * 3), // 3 days ago - now.addingTimeInterval(-60 * 60 * 24 * 7), // 1 week ago - now.addingTimeInterval(-60 * 60 * 24 * 30) // 1 month ago - ] - } -} -#endif diff --git a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightCardView.swift b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightCardView.swift index 8c03fa4f..d5b7782e 100644 --- a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightCardView.swift +++ b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightCardView.swift @@ -89,7 +89,7 @@ public struct FeedHighlightCardView: View { private var dateLabel: some View { HStack(spacing: 6) { Image(systemName: "calendar") - Text(post.pubDate.feedDateTimeDisplay) + Text(post.pubDate.toTimeAgoDisplay(showTime: true)) } .font(.subheadline) .foregroundStyle(.white.opacity(0.85)) @@ -102,7 +102,7 @@ public struct FeedHighlightCardView: View { if post.favorite { label += ", favoritado" } - label += ", publicado em \(post.pubDate.formatted(as: "dd 'de' MMMM 'de' yyyy 'às' HH:mm"))" + label += ", publicado em \(post.pubDate.toTimeAgoDisplay(showTime: true))" return label } From 4f8a0d0739a5ab838e68e717bd9fc9845db773ad Mon Sep 17 00:00:00 2001 From: Renato Ferraz Date: Mon, 29 Dec 2025 17:19:04 -0300 Subject: [PATCH 3/3] Removes obsolete TODO comment Removes a stale TODO comment regarding migration to a component in the UIComponentsLibrary. The migration may have already occurred or is no longer relevant, thus the comment is removed to reduce clutter. --- .../NewsLibrary/Views/Components/CollectionViewWithHeader.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/CollectionViewWithHeader.swift b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/CollectionViewWithHeader.swift index 599ee484..439f82b3 100644 --- a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/CollectionViewWithHeader.swift +++ b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/CollectionViewWithHeader.swift @@ -1,4 +1,3 @@ -// TODO: Migrate to the CollectionView component located in Libraries import SwiftUI import UIComponentsLibrary