Skip to content

Commit cde61d5

Browse files
committed
Map OpenWeather conditions to bundled icons
1 parent 52fdabb commit cde61d5

13 files changed

+362
-24
lines changed

README.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,100 @@ No third-party dependencies are currently used.
3636
- OpenWeather is used as an external HTTP API, not as a bundled SDK.
3737
- If cross-cutting tooling is added later, it must stay limited to concerns such as linting, logging, or CI support.
3838

39+
## Weather Presentation Mapping
40+
41+
The app now uses a two-layer weather presentation model:
42+
43+
- **Background images** stay intentionally coarse and unchanged.
44+
- **Weather icons** are now specific and are mapped from OpenWeather condition data into the provided bundled asset set.
45+
46+
This distinction is important:
47+
48+
- The background is meant to communicate the overall feel of the current weather using only three states:
49+
- `sunny`
50+
- `cloudy`
51+
- `rainy`
52+
- The iconography is meant to communicate the more specific weather condition returned by OpenWeather, such as:
53+
- clear sky
54+
- light rain
55+
- heavy rain
56+
- thunderstorm
57+
- snow
58+
- fog
59+
- wind-driven conditions
60+
61+
### Background Image Logic
62+
63+
Background selection has **not** changed.
64+
65+
The app still maps OpenWeather's broad `weather.main` field into the existing app-owned category enum:
66+
67+
- `Clear` -> `sunny`
68+
- `Clouds`, `Mist`, `Smoke`, `Haze`, `Dust`, `Fog`, `Sand`, `Ash`, `Squall`, `Tornado` -> `cloudy`
69+
- `Drizzle`, `Rain`, `Thunderstorm`, `Snow` -> `rainy`
70+
- anything unknown defaults to `cloudy`
71+
72+
That category is then used by `WeatherTheme` to select one of the three existing background assets:
73+
74+
- `sunny` -> `Sunny`
75+
- `cloudy` -> `Cloudy`
76+
- `rainy` -> `Rainy`
77+
78+
The background is therefore still driven by the app's primary condition and remains intentionally simple. It does **not** switch to a unique background per fine-grained OpenWeather condition code.
79+
80+
### Bundled Weather Icon Logic
81+
82+
In addition to the coarse background category, the app now keeps the primary OpenWeather condition payload for icon resolution:
83+
84+
- `weather.id`
85+
- `weather.main`
86+
- `weather.description`
87+
88+
The icon pipeline uses `weather.id` as the primary lookup key because it is the most stable field for condition matching. That ID is mapped to the provided bundled weather icon assets instead of relying only on SF Symbols.
89+
90+
The bundled icon set is treated as the app's primary weather icon library. OpenWeather conditions are mapped into the closest available asset in that set. For example:
91+
92+
- clear sky maps to the bundled sun icon
93+
- few clouds maps to the bundled partly cloudy icon
94+
- heavier cloud coverage maps to bundled cloud-based icons
95+
- drizzle maps to the bundled drizzle icon
96+
- light rain maps to the bundled light-rain icon
97+
- heavy rain maps to the bundled heavy-rain icon
98+
- thunder and thunderstorm conditions map to bundled storm icons
99+
- snow and heavy snow map to bundled snow assets
100+
- sleet and freezing-rain style conditions map to the bundled hailstorm asset
101+
- fog and haze map to a bundled cloud asset
102+
- strong wind, dust, sand, squall, and tornado families map to the bundled heavy-wind asset
103+
104+
Some provided assets are intentionally **not** used for weather-condition matching in the current app because they represent celestial or time-of-day imagery rather than weather conditions themselves. That includes icons such as:
105+
106+
- sunrise
107+
- sunset
108+
- eclipse
109+
- moon variants
110+
111+
Those assets are excluded from the OpenWeather condition mapping because this app is focused on weather-state presentation, not astronomy-state presentation.
112+
113+
### Fallback Behavior
114+
115+
The icon mapping system follows a fallback chain so the UI remains resilient if OpenWeather returns an unmapped or unexpected condition:
116+
117+
1. map from `weather.id`
118+
2. if needed, fall back to `weather.main` / `weather.description` heuristics
119+
3. if still unresolved, fall back to the bundled cloud icon as the default asset
120+
4. if the expected bundled asset cannot be loaded, fall back to an SF Symbol
121+
122+
This means the app now prefers the provided weather icons at runtime, while still keeping a safe fallback path so the UI can render even if an icon is missing or a new OpenWeather condition appears.
123+
124+
### Architectural Intent
125+
126+
The project now separates:
127+
128+
- **weather category for backgrounds**
129+
- **weather icon asset for condition display**
130+
131+
This keeps the background logic stable and easy to reason about, while allowing the forecast rows and current-condition UI to become much more specific without changing the rest of the app's visual architecture.
132+
39133
## Configuration
40134

41135
The current testing configuration is defined in [AppConfiguration.swift](/Users/blessingmabunda/Documents/WeatherApp/WeatherApp/App/AppConfiguration.swift).
@@ -62,6 +156,7 @@ xcodebuild test -project WeatherApp.xcodeproj -scheme WeatherApp -destination 'p
62156

63157
- Unit tests cover:
64158
- OpenWeather response mapping into app-owned models
159+
- OpenWeather condition-to-icon mapping for bundled weather assets
65160
- theme/background mapping
66161
- forecast view-model state transitions
67162
- presentation formatting helpers

WeatherApp/Domain/Models/ForecastDay.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ struct ForecastDay: Equatable, Identifiable {
44
let date: Date
55
let temperatureCelsius: Int
66
let condition: WeatherConditionCategory
7+
let icon: WeatherIconAsset
78

89
var id: Date { date }
910
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import Foundation
2+
3+
enum WeatherIconAsset: String, Equatable {
4+
case sun = "Property 1=01.sun-light"
5+
case partlyCloudy = "Property 1=05.partial-cloudy-light"
6+
case lightRainWithSun = "Property 1=06.rainyday-light"
7+
case mostlyCloudyWithSun = "Property 1=07.mostly-cloud-light"
8+
case cloudyNight = "Property 1=11.cloudy-night-light"
9+
case thunder = "Property 1=12.thunder-light"
10+
case thunderstorm = "Property 1=13.thunderstorm-light"
11+
case heavySnowfall = "Property 1=14.heavy-snowfall-light"
12+
case cloud = "Property 1=15.cloud-light"
13+
case cloudyNightAlt = "Property 1=16.cloudy-night-light"
14+
case cloudyNightStars = "Property 1=17.cloudy-night-stars-light"
15+
case heavyRain = "Property 1=18.heavy-rain-light"
16+
case rain = "Property 1=20.rain-light"
17+
case heavyWind = "Property 1=21.heavy-wind-light"
18+
case snow = "Property 1=22.snow-light"
19+
case hailstorm = "Property 1=23.hailstrom-light"
20+
case drizzle = "Property 1=24.drop-light"
21+
22+
var assetName: String { rawValue }
23+
24+
var fallbackSFSymbolName: String {
25+
switch self {
26+
case .sun:
27+
return "sun.max.fill"
28+
case .partlyCloudy, .mostlyCloudyWithSun:
29+
return "cloud.sun.fill"
30+
case .lightRainWithSun:
31+
return "cloud.sun.rain.fill"
32+
case .cloudyNight, .cloudyNightAlt, .cloudyNightStars:
33+
return "cloud.moon.fill"
34+
case .thunder:
35+
return "cloud.bolt.fill"
36+
case .thunderstorm:
37+
return "cloud.bolt.rain.fill"
38+
case .heavySnowfall, .snow:
39+
return "snow"
40+
case .cloud:
41+
return "cloud.fill"
42+
case .heavyRain, .rain, .drizzle:
43+
return "cloud.rain.fill"
44+
case .heavyWind:
45+
return "wind"
46+
case .hailstorm:
47+
return "cloud.hail.fill"
48+
}
49+
}
50+
}

WeatherApp/Domain/Models/WeatherSnapshot.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ struct WeatherSnapshot: Equatable {
55
let coordinate: LocationCoordinate
66
let currentTemperatureCelsius: Int
77
let primaryCondition: WeatherConditionCategory
8+
let primaryIcon: WeatherIconAsset
89
let forecastDays: [ForecastDay]
910
}

WeatherApp/Features/Forecast/Views/ForecastHeaderView.swift

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import SwiftUI
2+
import UIKit
23

34
struct ForecastHeaderView: View {
45
let snapshot: WeatherSnapshot
@@ -16,11 +17,29 @@ struct ForecastHeaderView: View {
1617
.font(.headline.weight(.semibold))
1718
.foregroundStyle(.white)
1819

19-
Text("\(snapshot.currentTemperatureCelsius)°")
20-
.font(.system(size: 52, weight: .bold, design: .rounded))
21-
.foregroundStyle(.white)
20+
HStack(alignment: .center, spacing: 16) {
21+
headerIcon
22+
23+
Text("\(snapshot.currentTemperatureCelsius)°")
24+
.font(.system(size: 52, weight: .bold, design: .rounded))
25+
.foregroundStyle(.white)
26+
}
2227
}
2328
}
2429
.frame(maxWidth: .infinity, alignment: .leading)
2530
}
31+
32+
@ViewBuilder
33+
private var headerIcon: some View {
34+
if UIImage(named: snapshot.primaryIcon.assetName) != nil {
35+
Image(snapshot.primaryIcon.assetName)
36+
.resizable()
37+
.scaledToFit()
38+
.frame(width: 54, height: 54)
39+
} else {
40+
Image(systemName: snapshot.primaryIcon.fallbackSFSymbolName)
41+
.font(.system(size: 42))
42+
.foregroundStyle(.white)
43+
}
44+
}
2645
}

WeatherApp/Features/Forecast/Views/ForecastRowView.swift

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import SwiftUI
2+
import UIKit
23

34
struct ForecastRowView: View {
45
let day: ForecastDay
@@ -12,9 +13,7 @@ struct ForecastRowView: View {
1213
.lineSpacing(8)
1314
.foregroundStyle(.black.opacity(0.88))
1415

15-
Image(systemName: day.condition.sfSymbolName)
16-
.font(.system(size: 28))
17-
.foregroundStyle(iconColor)
16+
weatherIcon
1817
}
1918

2019
Spacer()
@@ -33,6 +32,20 @@ struct ForecastRowView: View {
3332
.shadow(color: .black.opacity(0.08), radius: 12, y: 8)
3433
}
3534

35+
@ViewBuilder
36+
private var weatherIcon: some View {
37+
if UIImage(named: day.icon.assetName) != nil {
38+
Image(day.icon.assetName)
39+
.resizable()
40+
.scaledToFit()
41+
.frame(width: 36, height: 36)
42+
} else {
43+
Image(systemName: day.icon.fallbackSFSymbolName)
44+
.font(.system(size: 28))
45+
.foregroundStyle(iconColor)
46+
}
47+
}
48+
3649
private var iconColor: Color {
3750
switch day.condition {
3851
case .sunny:

WeatherApp/Features/Forecast/Views/ForecastScreen.swift

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,13 @@ private struct PreviewWeatherProvider: WeatherProviding {
108108
coordinate: coordinate,
109109
currentTemperatureCelsius: 23,
110110
primaryCondition: .cloudy,
111+
primaryIcon: .cloud,
111112
forecastDays: [
112-
ForecastDay(date: .now, temperatureCelsius: 20, condition: .sunny),
113-
ForecastDay(date: .now.addingTimeInterval(86_400), temperatureCelsius: 23, condition: .sunny),
114-
ForecastDay(date: .now.addingTimeInterval(172_800), temperatureCelsius: 27, condition: .sunny),
115-
ForecastDay(date: .now.addingTimeInterval(259_200), temperatureCelsius: 28, condition: .sunny),
116-
ForecastDay(date: .now.addingTimeInterval(345_600), temperatureCelsius: 30, condition: .sunny)
113+
ForecastDay(date: .now, temperatureCelsius: 20, condition: .sunny, icon: .sun),
114+
ForecastDay(date: .now.addingTimeInterval(86_400), temperatureCelsius: 23, condition: .sunny, icon: .sun),
115+
ForecastDay(date: .now.addingTimeInterval(172_800), temperatureCelsius: 27, condition: .sunny, icon: .sun),
116+
ForecastDay(date: .now.addingTimeInterval(259_200), temperatureCelsius: 28, condition: .sunny, icon: .sun),
117+
ForecastDay(date: .now.addingTimeInterval(345_600), temperatureCelsius: 30, condition: .sunny, icon: .sun)
117118
]
118119
)
119120
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import Foundation
2+
3+
struct OpenWeatherConditionIconMapper {
4+
func icon(for weather: OpenWeatherResponse.Weather?) -> WeatherIconAsset {
5+
if let code = weather?.id, let icon = icon(for: code) {
6+
return icon
7+
}
8+
9+
if let icon = heuristicIcon(main: weather?.main, description: weather?.description) {
10+
return icon
11+
}
12+
13+
return .cloud
14+
}
15+
16+
private func icon(for code: Int) -> WeatherIconAsset? {
17+
switch code {
18+
case 800:
19+
return .sun
20+
case 801:
21+
return .partlyCloudy
22+
case 802:
23+
return .mostlyCloudyWithSun
24+
case 803, 804:
25+
return .cloud
26+
27+
case 300, 301, 310, 311, 321:
28+
return .drizzle
29+
case 302, 312, 313, 314:
30+
return .rain
31+
32+
case 500, 520:
33+
return .lightRainWithSun
34+
case 501, 521, 531:
35+
return .rain
36+
case 502, 503, 504, 522:
37+
return .heavyRain
38+
case 511:
39+
return .hailstorm
40+
41+
case 200, 210, 230:
42+
return .thunder
43+
case 201, 202, 211, 212, 221, 231, 232:
44+
return .thunderstorm
45+
46+
case 600, 601, 620, 621:
47+
return .snow
48+
case 602, 622:
49+
return .heavySnowfall
50+
case 611, 612, 613, 615, 616:
51+
return .hailstorm
52+
53+
case 701, 711, 721, 741:
54+
return .cloud
55+
case 731, 751, 761, 762, 771, 781:
56+
return .heavyWind
57+
58+
default:
59+
return nil
60+
}
61+
}
62+
63+
private func heuristicIcon(main: String?, description: String?) -> WeatherIconAsset? {
64+
let combined = "\(main ?? "") \(description ?? "")".lowercased()
65+
66+
if combined.contains("thunderstorm") { return .thunderstorm }
67+
if combined.contains("thunder") { return .thunder }
68+
if combined.contains("freezing") || combined.contains("sleet") || combined.contains("hail") { return .hailstorm }
69+
if combined.contains("heavy snow") { return .heavySnowfall }
70+
if combined.contains("snow") { return .snow }
71+
if combined.contains("heavy rain") || combined.contains("extreme rain") || combined.contains("very heavy rain") { return .heavyRain }
72+
if combined.contains("drizzle") { return .drizzle }
73+
if combined.contains("rain") { return .rain }
74+
if combined.contains("tornado") || combined.contains("squall") || combined.contains("sand") || combined.contains("dust") || combined.contains("ash") {
75+
return .heavyWind
76+
}
77+
if combined.contains("mist") || combined.contains("smoke") || combined.contains("haze") || combined.contains("fog") || combined.contains("cloud") {
78+
return .cloud
79+
}
80+
if combined.contains("clear") { return .sun }
81+
82+
return nil
83+
}
84+
}

0 commit comments

Comments
 (0)