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..439f82b3 --- /dev/null +++ b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/CollectionViewWithHeader.swift @@ -0,0 +1,150 @@ +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..d5b7782e --- /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.toTimeAgoDisplay(showTime: true)) + } + .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.toTimeAgoDisplay(showTime: true))" + 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 + } } }