Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<Header: View, Content: View>: 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<ScrollPosition>,
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<ScrollPosition>,
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
Original file line number Diff line number Diff line change
@@ -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
Loading