Skip to content

Commit 735523d

Browse files
committed
Add forecast detail screen and app store screenshot notes
1 parent ed265d6 commit 735523d

18 files changed

+641
-40
lines changed
1.2 MB
Loading
1.6 MB
Loading
1.38 MB
Loading

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,16 @@ WeatherApp/
2424
Services/Weather/
2525
Shared/
2626
WeatherAppTests/
27+
AppStore Screenshots/
2728
docs/
2829
```
2930

3031
See [docs/Architecture.md](/Users/blessingmabunda/Documents/WeatherApp/docs/Architecture.md) for the detailed architecture breakdown.
3132

33+
## App Store Screenshots
34+
35+
App Store screenshots were generated with [appscreenshot.xyz](https://appscreenshot.xyz/) and are saved in the [AppStore Screenshots](/Users/blessingmabunda/Documents/WeatherApp/AppStore%20Screenshots) folder.
36+
3237
## Third-Party Dependencies
3338

3439
No third-party dependencies are currently used.
Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,32 @@
11
import Foundation
22

3-
struct ForecastDay: Equatable, Identifiable {
3+
struct ForecastHour: Equatable, Hashable, Identifiable {
44
let date: Date
55
let temperatureCelsius: Int
6+
let feelsLikeTemperatureCelsius: Int
7+
let humidityPercentage: Int
8+
let windSpeedKilometersPerHour: Int
9+
let precipitationProbabilityPercentage: Int
10+
let description: String
611
let condition: WeatherConditionCategory
712
let icon: WeatherIconAsset
813

914
var id: Date { date }
1015
}
16+
17+
struct ForecastDay: Equatable, Hashable, Identifiable {
18+
let date: Date
19+
let temperatureCelsius: Int
20+
let feelsLikeTemperatureCelsius: Int
21+
let minTemperatureCelsius: Int
22+
let maxTemperatureCelsius: Int
23+
let description: String
24+
let humidityPercentage: Int
25+
let windSpeedKilometersPerHour: Int
26+
let precipitationProbabilityPercentage: Int
27+
let condition: WeatherConditionCategory
28+
let icon: WeatherIconAsset
29+
let hourlyForecasts: [ForecastHour]
30+
31+
var id: Date { date }
32+
}

WeatherApp/Domain/Models/WeatherConditionCategory.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import Foundation
22

3-
enum WeatherConditionCategory: String, Equatable, CaseIterable {
3+
enum WeatherConditionCategory: String, Equatable, CaseIterable, Hashable {
44
case sunny
55
case cloudy
66
case rainy

WeatherApp/Domain/Models/WeatherIconAsset.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import Foundation
22

3-
enum WeatherIconAsset: String, Equatable {
3+
enum WeatherIconAsset: String, Equatable, Hashable {
44
case sun = "Property 1=01.sun-light"
55
case partlyCloudy = "Property 1=05.partial-cloudy-light"
66
case lightRainWithSun = "Property 1=06.rainyday-light"

WeatherApp/Domain/Models/WeatherSnapshot.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ struct WeatherSnapshot: Equatable {
44
let locationName: String
55
let coordinate: LocationCoordinate
66
let currentTemperatureCelsius: Int
7+
let currentFeelsLikeTemperatureCelsius: Int
78
let primaryDescription: String
89
let humidityPercentage: Int
910
let windSpeedKilometersPerHour: Int
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import SwiftUI
2+
import UIKit
3+
4+
struct ForecastDetailScreen: View {
5+
let snapshot: WeatherSnapshot
6+
let selectedDay: ForecastDay
7+
8+
var body: some View {
9+
ZStack {
10+
WeatherBackgroundView(category: selectedDay.condition)
11+
12+
ScrollView(showsIndicators: false) {
13+
VStack(alignment: .leading, spacing: 24) {
14+
summaryCard
15+
16+
if comparisonDays.isEmpty == false {
17+
comparisonSection
18+
}
19+
20+
if selectedDay.hourlyForecasts.isEmpty == false {
21+
hourlySection
22+
}
23+
24+
if snapshot.forecastDays.isEmpty == false {
25+
futureDaysSection
26+
}
27+
}
28+
.padding(.horizontal, 24)
29+
.padding(.vertical, 24)
30+
.frame(maxWidth: .infinity, alignment: .leading)
31+
}
32+
}
33+
.navigationBarTitleDisplayMode(.inline)
34+
}
35+
36+
private var summaryCard: some View {
37+
VStack(alignment: .leading, spacing: 18) {
38+
HStack {
39+
Label(snapshot.locationName, systemImage: "location.fill")
40+
.font(.custom("Poppins-Medium", size: 18))
41+
.foregroundStyle(.black.opacity(0.76))
42+
43+
Spacer()
44+
45+
Text(summaryTimeLabel)
46+
.font(.custom("Poppins-Medium", size: 16))
47+
.foregroundStyle(.black.opacity(0.56))
48+
}
49+
50+
VStack(spacing: 8) {
51+
weatherIcon(selectedDay.icon, condition: selectedDay.condition, size: 70)
52+
53+
Text(ForecastPresentationFormatter.temperatureString(celsius: selectedDay.temperatureCelsius))
54+
.font(.system(size: 54, weight: .medium, design: .rounded))
55+
.foregroundStyle(.black.opacity(0.86))
56+
57+
Text(selectedDay.description)
58+
.font(.custom("Poppins-Regular", size: 18))
59+
.foregroundStyle(.black.opacity(0.62))
60+
}
61+
.frame(maxWidth: .infinity)
62+
63+
VStack(spacing: 16) {
64+
metricRow(title: "Feels like", systemImage: "thermometer.medium", value: ForecastPresentationFormatter.temperatureString(celsius: selectedDay.feelsLikeTemperatureCelsius))
65+
metricRow(title: "Chance of rain", systemImage: "drop.fill", value: "\(selectedDay.precipitationProbabilityPercentage)%")
66+
metricRow(title: "Wind", systemImage: "wind", value: "\(selectedDay.windSpeedKilometersPerHour) km/h")
67+
metricRow(title: "Humidity", systemImage: "humidity.fill", value: "\(selectedDay.humidityPercentage)%")
68+
}
69+
}
70+
.padding(24)
71+
.frame(maxWidth: .infinity, alignment: .leading)
72+
.background(Color.white, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
73+
.shadow(color: .black.opacity(0.12), radius: 16, y: 10)
74+
}
75+
76+
private var comparisonSection: some View {
77+
VStack(alignment: .leading, spacing: 14) {
78+
HStack(spacing: 14) {
79+
ForEach(comparisonDays) { day in
80+
VStack(alignment: .leading, spacing: 12) {
81+
Text(ForecastPresentationFormatter.relativeDayLabel(for: day.date, relativeTo: selectedDay.date))
82+
.font(.custom("Poppins-Medium", size: 16))
83+
.foregroundStyle(.black.opacity(0.72))
84+
85+
HStack(alignment: .center, spacing: 12) {
86+
weatherIcon(day.icon, condition: day.condition, size: 34)
87+
88+
VStack(alignment: .leading, spacing: 4) {
89+
Text(ForecastPresentationFormatter.temperatureRangeString(lowCelsius: day.minTemperatureCelsius, highCelsius: day.maxTemperatureCelsius))
90+
.font(.custom("Poppins-SemiBold", size: 18))
91+
.foregroundStyle(.black.opacity(0.84))
92+
93+
Text(day.description)
94+
.font(.custom("Poppins-Regular", size: 14))
95+
.foregroundStyle(.black.opacity(0.55))
96+
}
97+
}
98+
}
99+
.padding(18)
100+
.frame(maxWidth: .infinity, alignment: .leading)
101+
.background(Color.white, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
102+
}
103+
}
104+
}
105+
}
106+
107+
private var hourlySection: some View {
108+
VStack(alignment: .leading, spacing: 14) {
109+
Text("24-hour forecast")
110+
.font(.custom("Poppins-SemiBold", size: 22))
111+
.foregroundStyle(.white)
112+
113+
ScrollView(.horizontal, showsIndicators: false) {
114+
HStack(alignment: .top, spacing: 18) {
115+
ForEach(selectedDay.hourlyForecasts) { hour in
116+
VStack(spacing: 10) {
117+
Text(ForecastPresentationFormatter.timeString(from: hour.date))
118+
.font(.custom("Poppins-Medium", size: 14))
119+
.foregroundStyle(.black.opacity(0.54))
120+
121+
weatherIcon(hour.icon, condition: hour.condition, size: 30)
122+
123+
Text(ForecastPresentationFormatter.temperatureString(celsius: hour.temperatureCelsius))
124+
.font(.custom("Poppins-SemiBold", size: 18))
125+
.foregroundStyle(.black.opacity(0.84))
126+
}
127+
.frame(width: 72)
128+
}
129+
}
130+
.padding(.horizontal, 18)
131+
.padding(.vertical, 18)
132+
}
133+
.background(Color.white, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
134+
}
135+
}
136+
137+
private var futureDaysSection: some View {
138+
VStack(alignment: .leading, spacing: 14) {
139+
Text("\(snapshot.forecastDays.count)-day forecast")
140+
.font(.custom("Poppins-SemiBold", size: 22))
141+
.foregroundStyle(.white)
142+
143+
VStack(spacing: 0) {
144+
ForEach(snapshot.forecastDays) { day in
145+
HStack(spacing: 16) {
146+
Text(ForecastPresentationFormatter.shortDateString(from: day.date))
147+
.font(.custom("Poppins-Medium", size: 18))
148+
.foregroundStyle(.black.opacity(0.84))
149+
150+
Spacer()
151+
152+
weatherIcon(day.icon, condition: day.condition, size: 26)
153+
154+
Text(ForecastPresentationFormatter.slashTemperatureRangeString(lowCelsius: day.minTemperatureCelsius, highCelsius: day.maxTemperatureCelsius))
155+
.font(.custom("Poppins-Medium", size: 18))
156+
.foregroundStyle(.black.opacity(day.id == selectedDay.id ? 0.88 : 0.62))
157+
}
158+
.padding(.vertical, 14)
159+
160+
if day.id != snapshot.forecastDays.last?.id {
161+
Divider()
162+
.overlay(.black.opacity(0.08))
163+
}
164+
}
165+
}
166+
.padding(.horizontal, 20)
167+
.padding(.vertical, 8)
168+
.background(Color.white, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
169+
}
170+
}
171+
172+
private var comparisonDays: [ForecastDay] {
173+
guard let selectedIndex = snapshot.forecastDays.firstIndex(where: { $0.id == selectedDay.id }) else {
174+
return []
175+
}
176+
177+
return Array(snapshot.forecastDays.dropFirst(selectedIndex + 1).prefix(2))
178+
}
179+
180+
private var summaryTimeLabel: String {
181+
ForecastPresentationFormatter.summaryTimeLabel(for: selectedDay.date, hourlyForecasts: selectedDay.hourlyForecasts)
182+
}
183+
184+
private func metricRow(title: String, systemImage: String, value: String) -> some View {
185+
HStack(spacing: 12) {
186+
Image(systemName: systemImage)
187+
.font(.system(size: 18, weight: .medium))
188+
.foregroundStyle(.black.opacity(0.54))
189+
.frame(width: 28)
190+
191+
Text(title)
192+
.font(.custom("Poppins-Regular", size: 18))
193+
.foregroundStyle(.black.opacity(0.64))
194+
195+
Spacer()
196+
197+
Text(value)
198+
.font(.custom("Poppins-Medium", size: 18))
199+
.foregroundStyle(.black.opacity(0.84))
200+
}
201+
}
202+
203+
@ViewBuilder
204+
private func weatherIcon(_ icon: WeatherIconAsset, condition: WeatherConditionCategory, size: CGFloat) -> some View {
205+
if UIImage(named: icon.assetName) != nil {
206+
Image(icon.assetName)
207+
.resizable()
208+
.scaledToFit()
209+
.frame(width: size, height: size)
210+
} else {
211+
Image(systemName: icon.fallbackSFSymbolName)
212+
.font(.system(size: size * 0.72))
213+
.foregroundStyle(iconColor(for: condition))
214+
}
215+
}
216+
217+
private func iconColor(for condition: WeatherConditionCategory) -> Color {
218+
switch condition {
219+
case .sunny:
220+
return .yellow.opacity(0.92)
221+
case .cloudy:
222+
return .gray.opacity(0.82)
223+
case .rainy:
224+
return .blue.opacity(0.88)
225+
}
226+
}
227+
}

WeatherApp/Features/Forecast/Views/ForecastListView.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@ import SwiftUI
22

33
struct ForecastListView: View {
44
let days: [ForecastDay]
5+
let onDoubleTapDay: (ForecastDay) -> Void
56

67
var body: some View {
78
VStack(spacing: 18) {
89
ForEach(days) { day in
910
ForecastRowView(day: day)
11+
.onTapGesture(count: 2) {
12+
onDoubleTapDay(day)
13+
}
1014
}
1115
}
1216
}

0 commit comments

Comments
 (0)