Skip to content

Commit 50b6e1d

Browse files
committed
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.
1 parent 688720e commit 50b6e1d

9 files changed

Lines changed: 911 additions & 23 deletions

File tree

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import Foundation
2+
3+
// MARK: - Date Extensions for Feed
4+
5+
public extension Date {
6+
7+
// MARK: - Custom Format
8+
9+
/// Format date with custom format string
10+
func formatted(as format: String) -> String {
11+
let formatter = DateFormatter()
12+
formatter.dateFormat = format
13+
formatter.locale = Locale(identifier: "pt_BR")
14+
return formatter.string(from: self)
15+
}
16+
17+
// MARK: - Relative Formatting
18+
19+
/// Returns a human-readable relative time string
20+
var relativeTimeString: String {
21+
let formatter = RelativeDateTimeFormatter()
22+
formatter.unitsStyle = .abbreviated
23+
formatter.locale = Locale(identifier: "pt_BR")
24+
return formatter.localizedString(for: self, relativeTo: Date())
25+
}
26+
27+
/// Returns a formatted date string for feed display
28+
var feedDisplayString: String {
29+
let calendar = Calendar.current
30+
let now = Date()
31+
32+
if calendar.isDateInToday(self) {
33+
return "Hoje, \(formatted(date: .omitted, time: .shortened))"
34+
} else if calendar.isDateInYesterday(self) {
35+
return "Ontem, \(formatted(date: .omitted, time: .shortened))"
36+
} else if let daysAgo = calendar.dateComponents([.day], from: self, to: now).day, daysAgo < 7 {
37+
return relativeTimeString
38+
} else {
39+
return formatted(date: .abbreviated, time: .omitted)
40+
}
41+
}
42+
43+
// MARK: - Time Ago String
44+
45+
/// Returns a detailed time ago string
46+
var timeAgoString: String {
47+
let calendar = Calendar.current
48+
let now = Date()
49+
let components = calendar.dateComponents([.minute, .hour, .day, .weekOfYear, .month, .year], from: self, to: now)
50+
51+
if let years = components.year, years > 0 {
52+
return years == 1 ? "há 1 ano" : "\(years) anos"
53+
}
54+
if let months = components.month, months > 0 {
55+
return months == 1 ? "há 1 mês" : "\(months) meses"
56+
}
57+
if let weeks = components.weekOfYear, weeks > 0 {
58+
return weeks == 1 ? "há 1 semana" : "\(weeks) semanas"
59+
}
60+
if let days = components.day, days > 0 {
61+
return days == 1 ? "há 1 dia" : "\(days) dias"
62+
}
63+
if let hours = components.hour, hours > 0 {
64+
return hours == 1 ? "há 1 hora" : "\(hours) horas"
65+
}
66+
if let minutes = components.minute, minutes > 0 {
67+
return minutes == 1 ? "há 1 minuto" : "\(minutes) minutos"
68+
}
69+
70+
return "agora"
71+
}
72+
73+
// MARK: - Accessibility String
74+
75+
/// Returns a detailed accessibility-friendly date string
76+
var accessibilityDateString: String {
77+
let formatter = DateFormatter()
78+
formatter.dateStyle = .full
79+
formatter.timeStyle = .short
80+
formatter.locale = Locale(identifier: "pt_BR")
81+
return formatter.string(from: self)
82+
}
83+
84+
/// Hoje às HH:mm / Ontem às HH:mm / dd/MM/yyyy às HH:mm
85+
var feedDateTimeDisplay: String {
86+
let calendar = Calendar.current
87+
88+
if calendar.isDateInToday(self) {
89+
return "Hoje às \(Self.feedTimeFormatter.string(from: self))"
90+
}
91+
92+
if calendar.isDateInYesterday(self) {
93+
return "Ontem às \(Self.feedTimeFormatter.string(from: self))"
94+
}
95+
96+
return Self.feedDateTimeFormatter.string(from: self)
97+
}
98+
99+
// MARK: - Private formatters (cache)
100+
101+
private static let feedTimeFormatter: DateFormatter = {
102+
let formatter = DateFormatter()
103+
formatter.locale = Locale(identifier: "pt_BR")
104+
formatter.dateFormat = "HH:mm"
105+
return formatter
106+
}()
107+
108+
private static let feedDateTimeFormatter: DateFormatter = {
109+
let formatter = DateFormatter()
110+
formatter.locale = Locale(identifier: "pt_BR")
111+
formatter.dateFormat = "dd/MM/yyyy 'às' HH:mm"
112+
return formatter
113+
}()
114+
}
115+
116+
// MARK: - Preview Helper
117+
118+
#if DEBUG
119+
extension Date {
120+
static var mockDates: [Date] {
121+
let now = Date()
122+
return [
123+
now,
124+
now.addingTimeInterval(-60 * 5), // 5 minutes ago
125+
now.addingTimeInterval(-60 * 60), // 1 hour ago
126+
now.addingTimeInterval(-60 * 60 * 5), // 5 hours ago
127+
now.addingTimeInterval(-60 * 60 * 24), // 1 day ago
128+
now.addingTimeInterval(-60 * 60 * 24 * 3), // 3 days ago
129+
now.addingTimeInterval(-60 * 60 * 24 * 7), // 1 week ago
130+
now.addingTimeInterval(-60 * 60 * 24 * 30) // 1 month ago
131+
]
132+
}
133+
}
134+
#endif
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// TODO: Migrate to the CollectionView component located in Libraries
2+
import SwiftUI
3+
import UIComponentsLibrary
4+
5+
// MARK: - Collection View With Header
6+
7+
/// CollectionView with optional header support
8+
/// Header appears above the grid and scrolls with the content
9+
public struct CollectionViewWithHeader<Header: View, Content: View>: View {
10+
11+
// MARK: - Properties
12+
13+
@State private var cardWidth = CGFloat.zero
14+
@Binding var scrollPosition: ScrollPosition
15+
16+
private let title: String
17+
private let status: APIStatus
18+
private let usesDensity: Bool
19+
private var favorite: Bool
20+
private var isSearching: Bool
21+
private var quantity: Int
22+
private var density: CardDensity { .density(using: cardWidth) }
23+
24+
private let header: (() -> Header)?
25+
private let retryAction: (() -> Void)?
26+
private let content: () -> Content
27+
28+
private let grid = GridItem(
29+
.adaptive(minimum: 280),
30+
spacing: 20,
31+
alignment: .top
32+
)
33+
34+
// MARK: - Initialization
35+
36+
public init(
37+
title: String,
38+
status: APIStatus,
39+
usesDensity: Bool = true,
40+
scrollPosition: Binding<ScrollPosition>,
41+
favorite: Bool = false,
42+
isSearching: Bool = false,
43+
quantity: Int = 0,
44+
@ViewBuilder header: @escaping () -> Header,
45+
@ViewBuilder content: @escaping () -> Content,
46+
retryAction: (() -> Void)? = nil
47+
) {
48+
self.title = title
49+
self.status = status
50+
self.usesDensity = usesDensity
51+
self.favorite = favorite
52+
self.isSearching = isSearching
53+
self.quantity = quantity
54+
self.header = header
55+
self.content = content
56+
self.retryAction = retryAction
57+
58+
_scrollPosition = scrollPosition
59+
}
60+
61+
// MARK: - Body
62+
63+
public var body: some View {
64+
VStack {
65+
LoadingAndErrorView(
66+
title: title,
67+
status: status,
68+
favorite: favorite,
69+
isSearching: isSearching,
70+
quantity: quantity,
71+
retryAction: retryAction
72+
)
73+
74+
ScrollView {
75+
VStack(spacing: 20) {
76+
// Header (full width, outside grid)
77+
if let header {
78+
header()
79+
}
80+
81+
// Grid content
82+
LazyVGrid(
83+
columns: Array(repeating: grid, count: usesDensity ? density.columns : 1),
84+
spacing: 20
85+
) {
86+
content()
87+
}
88+
.padding(.horizontal)
89+
}
90+
}
91+
.scrollPosition($scrollPosition)
92+
}
93+
.cardSize { value in
94+
cardWidth = value
95+
}
96+
}
97+
}
98+
99+
// MARK: - Convenience Init (No Header)
100+
101+
extension CollectionViewWithHeader where Header == EmptyView {
102+
public init(
103+
title: String,
104+
status: APIStatus,
105+
usesDensity: Bool = true,
106+
scrollPosition: Binding<ScrollPosition>,
107+
favorite: Bool = false,
108+
isSearching: Bool = false,
109+
quantity: Int = 0,
110+
@ViewBuilder content: @escaping () -> Content,
111+
retryAction: (() -> Void)? = nil
112+
) {
113+
self.title = title
114+
self.status = status
115+
self.usesDensity = usesDensity
116+
self.favorite = favorite
117+
self.isSearching = isSearching
118+
self.quantity = quantity
119+
self.header = nil
120+
self.content = content
121+
self.retryAction = retryAction
122+
123+
_scrollPosition = scrollPosition
124+
}
125+
}
126+
127+
// MARK: - Preview
128+
129+
#if DEBUG
130+
#Preview {
131+
CollectionViewWithHeader(
132+
title: "Notícias",
133+
status: .done,
134+
scrollPosition: .constant(ScrollPosition()),
135+
header: {
136+
RoundedRectangle(cornerRadius: 16)
137+
.fill(Color.blue.opacity(0.3))
138+
.frame(height: 200)
139+
.overlay(Text("Header"))
140+
},
141+
content: {
142+
ForEach(0..<10, id: \.self) { index in
143+
RoundedRectangle(cornerRadius: 12)
144+
.fill(Color.gray.opacity(0.3))
145+
.frame(height: 150)
146+
.overlay(Text("Card \(index)"))
147+
}
148+
}
149+
)
150+
}
151+
#endif

0 commit comments

Comments
 (0)