Skip to content

Commit d8d24cb

Browse files
authored
Merge pull request #264 from rfcbf/feature/highlight_feeds
feat: adds news highlights carousel
2 parents 688720e + 4f8a0d0 commit d8d24cb

8 files changed

Lines changed: 776 additions & 23 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import SwiftUI
2+
import UIComponentsLibrary
3+
4+
// MARK: - Collection View With Header
5+
6+
/// CollectionView with optional header support
7+
/// Header appears above the grid and scrolls with the content
8+
public struct CollectionViewWithHeader<Header: View, Content: View>: View {
9+
10+
// MARK: - Properties
11+
12+
@State private var cardWidth = CGFloat.zero
13+
@Binding var scrollPosition: ScrollPosition
14+
15+
private let title: String
16+
private let status: APIStatus
17+
private let usesDensity: Bool
18+
private var favorite: Bool
19+
private var isSearching: Bool
20+
private var quantity: Int
21+
private var density: CardDensity { .density(using: cardWidth) }
22+
23+
private let header: (() -> Header)?
24+
private let retryAction: (() -> Void)?
25+
private let content: () -> Content
26+
27+
private let grid = GridItem(
28+
.adaptive(minimum: 280),
29+
spacing: 20,
30+
alignment: .top
31+
)
32+
33+
// MARK: - Initialization
34+
35+
public init(
36+
title: String,
37+
status: APIStatus,
38+
usesDensity: Bool = true,
39+
scrollPosition: Binding<ScrollPosition>,
40+
favorite: Bool = false,
41+
isSearching: Bool = false,
42+
quantity: Int = 0,
43+
@ViewBuilder header: @escaping () -> Header,
44+
@ViewBuilder content: @escaping () -> Content,
45+
retryAction: (() -> Void)? = nil
46+
) {
47+
self.title = title
48+
self.status = status
49+
self.usesDensity = usesDensity
50+
self.favorite = favorite
51+
self.isSearching = isSearching
52+
self.quantity = quantity
53+
self.header = header
54+
self.content = content
55+
self.retryAction = retryAction
56+
57+
_scrollPosition = scrollPosition
58+
}
59+
60+
// MARK: - Body
61+
62+
public var body: some View {
63+
VStack {
64+
LoadingAndErrorView(
65+
title: title,
66+
status: status,
67+
favorite: favorite,
68+
isSearching: isSearching,
69+
quantity: quantity,
70+
retryAction: retryAction
71+
)
72+
73+
ScrollView {
74+
VStack(spacing: 20) {
75+
// Header (full width, outside grid)
76+
if let header {
77+
header()
78+
}
79+
80+
// Grid content
81+
LazyVGrid(
82+
columns: Array(repeating: grid, count: usesDensity ? density.columns : 1),
83+
spacing: 20
84+
) {
85+
content()
86+
}
87+
.padding(.horizontal)
88+
}
89+
}
90+
.scrollPosition($scrollPosition)
91+
}
92+
.cardSize { value in
93+
cardWidth = value
94+
}
95+
}
96+
}
97+
98+
// MARK: - Convenience Init (No Header)
99+
100+
extension CollectionViewWithHeader where Header == EmptyView {
101+
public init(
102+
title: String,
103+
status: APIStatus,
104+
usesDensity: Bool = true,
105+
scrollPosition: Binding<ScrollPosition>,
106+
favorite: Bool = false,
107+
isSearching: Bool = false,
108+
quantity: Int = 0,
109+
@ViewBuilder content: @escaping () -> Content,
110+
retryAction: (() -> Void)? = nil
111+
) {
112+
self.title = title
113+
self.status = status
114+
self.usesDensity = usesDensity
115+
self.favorite = favorite
116+
self.isSearching = isSearching
117+
self.quantity = quantity
118+
self.header = nil
119+
self.content = content
120+
self.retryAction = retryAction
121+
122+
_scrollPosition = scrollPosition
123+
}
124+
}
125+
126+
// MARK: - Preview
127+
128+
#if DEBUG
129+
#Preview {
130+
CollectionViewWithHeader(
131+
title: "Notícias",
132+
status: .done,
133+
scrollPosition: .constant(ScrollPosition()),
134+
header: {
135+
RoundedRectangle(cornerRadius: 16)
136+
.fill(Color.blue.opacity(0.3))
137+
.frame(height: 200)
138+
.overlay(Text("Header"))
139+
},
140+
content: {
141+
ForEach(0..<10, id: \.self) { index in
142+
RoundedRectangle(cornerRadius: 12)
143+
.fill(Color.gray.opacity(0.3))
144+
.frame(height: 150)
145+
.overlay(Text("Card \(index)"))
146+
}
147+
}
148+
)
149+
}
150+
#endif
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import FeedLibrary
2+
import MacMagazineLibrary
3+
import SwiftUI
4+
import UIComponentsLibrary
5+
6+
// MARK: - Feed Highlight Card View
7+
8+
/// Card view for featured/highlighted posts in the carousel
9+
public struct FeedHighlightCardView: View {
10+
11+
// MARK: - Properties
12+
13+
let post: FeedDB
14+
15+
@Environment(\.theme) private var theme: ThemeColor
16+
17+
// MARK: - Body
18+
19+
public var body: some View {
20+
GeometryReader { geometry in
21+
ZStack(alignment: .bottom) {
22+
backgroundImage
23+
.frame(width: geometry.size.width, height: geometry.size.height)
24+
25+
gradientOverlay
26+
27+
contentOverlay
28+
.frame(width: geometry.size.width)
29+
}
30+
}
31+
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
32+
.shadow(color: .black.opacity(0.3), radius: 12, x: 0, y: 6)
33+
.accessibilityElement(children: .combine)
34+
.accessibilityLabel(accessibilityLabel)
35+
}
36+
37+
// MARK: - Background Image
38+
39+
@ViewBuilder
40+
private var backgroundImage: some View {
41+
if let url = URL(string: post.artworkURL) {
42+
CachedAsyncImage(image: url, contentMode: .fill)
43+
} else {
44+
placeholderImage
45+
}
46+
}
47+
48+
private var placeholderImage: some View {
49+
LinearGradient(
50+
colors: [.gray.opacity(0.4), .gray.opacity(0.6)],
51+
startPoint: .topLeading,
52+
endPoint: .bottomTrailing
53+
)
54+
}
55+
56+
// MARK: - Gradient Overlay
57+
58+
private var gradientOverlay: some View {
59+
LinearGradient(
60+
gradient: Gradient(stops: [
61+
.init(color: .clear, location: 0.0),
62+
.init(color: .black.opacity(0.2), location: 0.5),
63+
.init(color: .black.opacity(0.85), location: 1.0)
64+
]),
65+
startPoint: .top,
66+
endPoint: .bottom
67+
)
68+
}
69+
70+
// MARK: - Content Overlay
71+
72+
private var contentOverlay: some View {
73+
VStack(alignment: .leading, spacing: 8) {
74+
Spacer()
75+
76+
Text(post.title)
77+
.font(.title2)
78+
.fontWeight(.bold)
79+
.foregroundStyle(.white)
80+
.lineLimit(3)
81+
.multilineTextAlignment(.leading)
82+
83+
dateLabel
84+
}
85+
.padding(20)
86+
.frame(maxWidth: .infinity, alignment: .leading)
87+
}
88+
89+
private var dateLabel: some View {
90+
HStack(spacing: 6) {
91+
Image(systemName: "calendar")
92+
Text(post.pubDate.toTimeAgoDisplay(showTime: true))
93+
}
94+
.font(.subheadline)
95+
.foregroundStyle(.white.opacity(0.85))
96+
}
97+
98+
// MARK: - Accessibility
99+
100+
private var accessibilityLabel: String {
101+
var label = post.title
102+
if post.favorite {
103+
label += ", favoritado"
104+
}
105+
label += ", publicado em \(post.pubDate.toTimeAgoDisplay(showTime: true))"
106+
return label
107+
}
108+
109+
// MARK: - Init
110+
111+
public init(post: FeedDB) {
112+
self.post = post
113+
}
114+
}
115+
116+
// MARK: - Preview
117+
118+
#if DEBUG
119+
private struct FeedHighlightCardPreview: View {
120+
var body: some View {
121+
ZStack {
122+
Color.black.opacity(0.9).ignoresSafeArea()
123+
124+
if let post = PreviewData.sampleHighlights.first {
125+
FeedHighlightCardView(post: post)
126+
.frame(width: 340, height: 420)
127+
.padding()
128+
}
129+
}
130+
}
131+
}
132+
133+
#Preview {
134+
FeedHighlightCardPreview()
135+
}
136+
#endif

0 commit comments

Comments
 (0)