Build a Weather App with SwiftUI and async/await — From API to Polished UI
Picture this: you open your phone in the morning and see the weather for Monstropolis — partly cloudy, with a high of 68°F and a 20% chance of scream-powered thunderstorms. By the end of this tutorial, you’ll have built exactly that kind of app — minus the monsters, but with all the polish.
In this tutorial, you’ll build Pixar Weather Station — a SwiftUI weather app that fetches live conditions and a
5-day forecast for a collection of beloved Pixar-themed cities using the free
Open-Meteo API. Along the way, you’ll learn how to model JSON responses with
Codable, make async network calls with
URLSession, display weather conditions with SF
Symbols, and style your UI with dynamic gradients and loading states.
Prerequisites
- Xcode 16+ with an iOS 18 deployment target
- Familiarity with networking basics and URLSession
- Familiarity with async/await in Swift
- Familiarity with SwiftUI state management
Contents
- Getting Started
- Step 1: Defining the Weather Models
- Step 2: Building the Weather Service
- Step 3: Creating the City List View
- Step 4: Displaying Current Weather
- Step 5: Building the Forecast Row
- Step 6: Adding City Selection
- Step 7: Polishing the UI with Gradients
- Step 8: Handling Errors and Empty States
- Where to Go From Here?
Getting Started
Open Xcode and create a new project:
- Select File > New > Project, then choose the App template.
- Set the Product Name to
PixarWeather. - Ensure Interface is set to SwiftUI and Language is Swift.
- Set your Deployment Target to iOS 18.0.
- Click Next and save the project somewhere you’ll find it.
Once the project is open, you’ll work primarily in three areas: a Models/ group for your data layer, a Services/
group for networking, and a Views/ group for your SwiftUI components. Go ahead and create these three groups now by
right-clicking on the PixarWeather folder in the project navigator and choosing New Group.
Your final project structure will look like this:
PixarWeather/
├── Models/
│ ├── WeatherModels.swift
│ └── City.swift
├── Services/
│ └── WeatherService.swift
├── Views/
│ ├── ContentView.swift
│ ├── WeatherDetailView.swift
│ ├── ForecastRowView.swift
│ └── CityPickerView.swift
└── PixarWeatherApp.swift
Step 1: Defining the Weather Models
The Open-Meteo API returns a JSON payload with current conditions and a daily forecast block. Before writing a single network call, you need Swift types that mirror this structure.
Create a new Swift file at Models/WeatherModels.swift and add the following:
import Foundation
// Top-level response from the Open-Meteo /v1/forecast endpoint
struct WeatherResponse: Codable {
let latitude: Double
let longitude: Double
let timezone: String
let current: CurrentWeather
let daily: DailyForecast
}
struct CurrentWeather: Codable {
let time: String
let temperature2m: Double // °C — we'll convert to the user's preference
let relativeHumidity2m: Int
let weatherCode: Int // WMO weather interpretation code
let windSpeed10m: Double
let isDay: Int // 1 = daytime, 0 = nighttime
enum CodingKeys: String, CodingKey {
case time
case temperature2m = "temperature_2m"
case relativeHumidity2m = "relative_humidity_2m"
case weatherCode = "weather_code"
case windSpeed10m = "wind_speed_10m"
case isDay = "is_day"
}
}
struct DailyForecast: Codable {
let time: [String]
let weatherCode: [Int]
let temperature2mMax: [Double]
let temperature2mMin: [Double]
enum CodingKeys: String, CodingKey {
case time
case weatherCode = "weather_code"
case temperature2mMax = "temperature_2m_max"
case temperature2mMin = "temperature_2m_min"
}
}
The CodingKeys enum is the key detail here. Open-Meteo uses snake_case property names (e.g., temperature_2m), but
Swift conventions favor camelCase. Rather than configuring a
JSONDecoder with .convertFromSnakeCase globally
— which can cause surprises with ambiguous names — you’re declaring explicit mappings for each type. This is safer and
easier to debug.
Next, create Models/City.swift to define your Pixar-themed locations:
import Foundation
struct City: Identifiable, Hashable {
let id = UUID()
let name: String
let country: String // The fictional "country" or film name
let latitude: Double
let longitude: Double
let emoji: String // A fun emoji representing each film's world
// A small set of beloved Pixar cities with real-world coordinates
static let all: [City] = [
City(name: "Monstropolis", country: "Monsters, Inc.",
latitude: 37.7749, longitude: -122.4194, emoji: "👾"),
City(name: "Radiator Springs", country: "Cars",
latitude: 35.1983, longitude: -111.6513, emoji: "🚗"),
City(name: "Nemo Bay", country: "Finding Nemo",
latitude: -33.8688, longitude: 151.2093, emoji: "🐠"),
City(name: "The Ratatouille Quarter", country: "Ratatouille",
latitude: 48.8566, longitude: 2.3522, emoji: "🐀"),
City(name: "Metroville", country: "The Incredibles",
latitude: 41.8781, longitude: -87.6298, emoji: "🦸"),
City(name: "Brave Highlands", country: "Brave",
latitude: 57.1497, longitude: -2.0943, emoji: "🏹"),
]
}
Each city maps to a real-world coordinate so the weather data will be genuine, but the names and lore belong entirely to Pixar. You’re using real coordinates for Sydney (Nemo Bay), Paris (Ratatouille Quarter), and so on — so the weather you fetch is accurate for those locations.
Step 2: Building the Weather Service
With your models in place, it’s time to write the networking layer. Create Services/WeatherService.swift:
import Foundation
// Errors the weather service can surface to the UI
enum WeatherError: LocalizedError {
case invalidURL
case networkFailure(Error)
case decodingFailure(Error)
case unexpectedStatusCode(Int)
var errorDescription: String? {
switch self {
case .invalidURL:
return "The weather service URL couldn't be constructed."
case .networkFailure(let error):
return "Network request failed: \(error.localizedDescription)"
case .decodingFailure(let error):
return "Couldn't decode weather data: \(error.localizedDescription)"
case .unexpectedStatusCode(let code):
return "The server returned an unexpected status code: \(code)."
}
}
}
actor WeatherService {
static let shared = WeatherService()
private init() {}
// Fetch current weather and a 7-day daily forecast for the given coordinates.
// Open-Meteo requires no API key for basic weather data.
func fetchWeather(latitude: Double, longitude: Double) async throws -> WeatherResponse {
var components = URLComponents(string: "https://api.open-meteo.com/v1/forecast")
components?.queryItems = [
URLQueryItem(name: "latitude", value: String(latitude)),
URLQueryItem(name: "longitude", value: String(longitude)),
URLQueryItem(name: "current", value: [
"temperature_2m",
"relative_humidity_2m",
"weather_code",
"wind_speed_10m",
"is_day"
].joined(separator: ",")),
URLQueryItem(name: "daily", value: [
"weather_code",
"temperature_2m_max",
"temperature_2m_min"
].joined(separator: ",")),
URLQueryItem(name: "forecast_days", value: "5"),
]
guard let url = components?.url else {
throw WeatherError.invalidURL
}
let (data, response): (Data, URLResponse)
do {
(data, response) = try await URLSession.shared.data(from: url)
} catch {
throw WeatherError.networkFailure(error)
}
if let httpResponse = response as? HTTPURLResponse,
!(200...299).contains(httpResponse.statusCode) {
throw WeatherError.unexpectedStatusCode(httpResponse.statusCode)
}
do {
let decoder = JSONDecoder()
return try decoder.decode(WeatherResponse.self, from: data)
} catch {
throw WeatherError.decodingFailure(error)
}
}
}
There are several deliberate choices worth noting:
WeatherServiceis declared as anactor. This protects its internal state from concurrent access, which is important since the service can be called from multiple views at once. Even though this actor has no mutable state today, usingactorfrom the start is a good habit for service types.- The Open-Meteo URL is built with
URLComponentsrather than manual string interpolation. This ensures special characters in query parameters are percent-encoded automatically. - Each failure mode is represented by a named case in
WeatherError. This makes error handling in the UI clear and exhaustive — you always know exactly what went wrong. - The
async throwssignature lets callers usetry awaitwithout any callbacks, keeping the call site readable.
Checkpoint: At this point, you have models and a service layer but no UI. Build the project (
Cmd+B) and confirm it compiles with zero errors. If you see aCodingKeysmismatch, double-check that everyrawValuestring matches exactly what the Open-Meteo docs show — property names are case-sensitive.
Step 3: Creating the City List View
The entry point of the app is a list of Pixar cities. Tapping one takes you to the weather detail. Open
Views/ContentView.swift and replace its contents with:
import SwiftUI
struct ContentView: View {
@State private var selectedCity: City? = City.all.first
var body: some View {
NavigationSplitView {
List(City.all, selection: $selectedCity) { city in
NavigationLink(value: city) {
CityRowView(city: city)
}
}
.navigationTitle("Pixar Weather")
} detail: {
if let city = selectedCity {
WeatherDetailView(city: city)
} else {
ContentUnavailableView(
"Select a City",
systemImage: "cloud.sun.fill",
description: Text("Choose a Pixar city to see the forecast.")
)
}
}
}
}
Now create a small helper view for the city row. Add this struct at the bottom of Views/ContentView.swift:
struct CityRowView: View {
let city: City
var body: some View {
HStack(spacing: 12) {
Text(city.emoji)
.font(.title2)
VStack(alignment: .leading, spacing: 2) {
Text(city.name)
.font(.headline)
Text(city.country)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
}
NavigationSplitView automatically adapts
between a sidebar-detail layout on iPad and a navigation stack on iPhone. You get adaptive behavior for free with no
extra code.
ContentUnavailableView is the standard iOS
17+ way to show placeholder content. Using it rather than a custom empty state means your app automatically adopts the
system’s look and feel for empty states.
Step 4: Displaying Current Weather
Now for the main event. Create Views/WeatherDetailView.swift:
import SwiftUI
@MainActor
@Observable
final class WeatherViewModel {
var weather: WeatherResponse?
var isLoading = false
var error: WeatherError?
func load(city: City) async {
isLoading = true
error = nil
do {
weather = try await WeatherService.shared.fetchWeather(
latitude: city.latitude,
longitude: city.longitude
)
} catch let weatherError as WeatherError {
error = weatherError
} catch {
self.error = .networkFailure(error)
}
isLoading = false
}
}
struct WeatherDetailView: View {
let city: City
@State private var viewModel = WeatherViewModel()
var body: some View {
ZStack {
backgroundGradient
.ignoresSafeArea()
if viewModel.isLoading {
ProgressView("Fetching weather for \(city.name)…")
.tint(.white)
.foregroundStyle(.white)
} else if let weather = viewModel.weather {
ScrollView {
VStack(spacing: 24) {
currentWeatherSection(weather.current)
Divider().overlay(.white.opacity(0.4))
forecastSection(weather.daily)
}
.padding()
}
} else if let error = viewModel.error {
ErrorView(message: error.localizedDescription) {
Task { await viewModel.load(city: city) }
}
}
}
.navigationTitle("\(city.emoji) \(city.name)")
.navigationBarTitleDisplayMode(.large)
.task {
await viewModel.load(city: city)
}
.onChange(of: city) {
Task { await viewModel.load(city: city) }
}
}
// MARK: - Subviews
@ViewBuilder
private func currentWeatherSection(_ current: CurrentWeather) -> some View {
VStack(spacing: 8) {
Image(systemName: sfSymbol(for: current.weatherCode, isDay: current.isDay == 1))
.font(.system(size: 80))
.symbolEffect(.bounce, value: current.weatherCode)
.foregroundStyle(.white)
Text(weatherDescription(for: current.weatherCode))
.font(.title2)
.foregroundStyle(.white.opacity(0.9))
Text(formatted(celsius: current.temperature2m))
.font(.system(size: 72, weight: .thin))
.foregroundStyle(.white)
HStack(spacing: 24) {
Label("\(current.relativeHumidity2m)%", systemImage: "humidity.fill")
Label("\(Int(current.windSpeed10m)) km/h", systemImage: "wind")
}
.font(.subheadline)
.foregroundStyle(.white.opacity(0.85))
}
.padding(.top, 16)
}
@ViewBuilder
private func forecastSection(_ daily: DailyForecast) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text("5-Day Forecast")
.font(.headline)
.foregroundStyle(.white.opacity(0.7))
.padding(.bottom, 4)
ForEach(Array(daily.time.enumerated()), id: \.offset) { index, dateString in
ForecastRowView(
date: dateString,
weatherCode: daily.weatherCode[index],
high: daily.temperature2mMax[index],
low: daily.temperature2mMin[index]
)
if index < daily.time.count - 1 {
Divider().overlay(.white.opacity(0.2))
}
}
}
}
// MARK: - Helpers
private var backgroundGradient: LinearGradient {
guard let weather = viewModel.weather else {
return LinearGradient(
colors: [Color(red: 0.28, green: 0.45, blue: 0.70),
Color(red: 0.14, green: 0.25, blue: 0.50)],
startPoint: .top, endPoint: .bottom
)
}
return gradientForTemperature(weather.current.temperature2m,
isDay: weather.current.isDay == 1)
}
private func formatted(celsius: Double) -> String {
let fahrenheit = celsius * 9 / 5 + 32
return "\(Int(fahrenheit))°F"
}
}
The WeatherViewModel is marked @MainActor because it drives UI updates — all property changes happen on the main
thread automatically. The @Observable macro
(introduced in iOS 17) eliminates boilerplate: SwiftUI tracks exactly which properties each view reads and only
re-renders when those specific values change.
The .task modifier kicks off the first load when the view appears, and .onChange(of: city) re-fetches whenever the
user picks a different city from the sidebar.
Step 5: Building the Forecast Row
Create Views/ForecastRowView.swift to display each day in the 5-day forecast:
import SwiftUI
struct ForecastRowView: View {
let date: String // ISO date string from the API, e.g. "2026-05-14"
let weatherCode: Int
let high: Double // °C max
let low: Double // °C min
var body: some View {
HStack {
Text(formattedDay)
.font(.subheadline)
.foregroundStyle(.white)
.frame(width: 44, alignment: .leading)
Spacer()
Image(systemName: sfSymbol(for: weatherCode, isDay: true))
.foregroundStyle(.white.opacity(0.9))
.frame(width: 28)
Spacer()
HStack(spacing: 4) {
Text("\(Int(high * 9 / 5 + 32))°")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
Text("/")
.foregroundStyle(.white.opacity(0.5))
Text("\(Int(low * 9 / 5 + 32))°")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.7))
}
}
.padding(.vertical, 8)
}
private var formattedDay: String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withFullDate]
guard let parsedDate = formatter.date(from: date) else { return date }
let display = DateFormatter()
display.dateFormat = "EEE" // "Mon", "Tue", etc.
return display.string(from: parsedDate)
}
}
Checkpoint: Build and run. Select “Monstropolis” from the city list. The app should show a loading spinner, then reveal the current temperature with a weather icon and the day-of-week labels for the 5-day forecast. If you see an error message instead, open the Xcode console — most likely the JSON keys don’t match the
CodingKeysyou defined. Compare them character by character against the Open-Meteo docs.
Step 6: Adding City Selection
On iPhone, NavigationSplitView collapses into a stack, so users need a way to switch cities without a visible sidebar.
Add a city picker sheet. Create Views/CityPickerView.swift:
import SwiftUI
struct CityPickerView: View {
@Binding var selectedCity: City
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List(City.all) { city in
Button {
selectedCity = city
dismiss()
} label: {
HStack(spacing: 12) {
Text(city.emoji)
.font(.title2)
VStack(alignment: .leading, spacing: 2) {
Text(city.name)
.font(.headline)
.foregroundStyle(.primary)
Text(city.country)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
if city == selectedCity {
Image(systemName: "checkmark")
.foregroundStyle(.accent)
}
}
}
}
.navigationTitle("Choose a City")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
}
}
}
Now wire the picker into WeatherDetailView. Add a @State property and a toolbar button to the detail view’s body.
Find the .navigationTitle modifier and add a .toolbar modifier below it:
// Add to WeatherDetailView
@State private var showingCityPicker = false
// Add inside .toolbar {}
ToolbarItem(placement: .topBarTrailing) {
Button {
showingCityPicker = true
} label: {
Image(systemName: "list.bullet.circle.fill")
.symbolRenderingMode(.hierarchical)
}
}
// Add to WeatherDetailView body
.sheet(isPresented: $showingCityPicker) {
CityPickerView(selectedCity: .init(
get: { city },
set: { _ in } // City changes propagate via ContentView's selection binding
))
}
Tip: Because
NavigationSplitViewdrives city selection via$selectedCityinContentView, the cleanest approach on a real project is to lift the selected city into an@Observableapp-level model and pass it through the environment. For this tutorial, keeping it as a@StateinContentViewis sufficient and easier to follow.
Step 7: Polishing the UI with Gradients
A weather app lives or dies by its visual atmosphere. Add the helper functions that drive the dynamic gradient and SF
Symbol selection. Create a new Swift file at Views/WeatherHelpers.swift:
import SwiftUI
// Maps a WMO weather code to a SF Symbol name.
// Full WMO code table: https://open-meteo.com/en/docs#weathervariables
func sfSymbol(for weatherCode: Int, isDay: Bool) -> String {
switch weatherCode {
case 0: return isDay ? "sun.max.fill" : "moon.stars.fill"
case 1...3: return isDay ? "cloud.sun.fill" : "cloud.moon.fill"
case 45, 48: return "cloud.fog.fill"
case 51...55: return "cloud.drizzle.fill"
case 61...65: return "cloud.rain.fill"
case 71...75: return "cloud.snow.fill"
case 80...82: return "cloud.heavyrain.fill"
case 95: return "cloud.bolt.fill"
case 96, 99: return "cloud.bolt.rain.fill"
default: return "cloud.fill"
}
}
// Returns a human-readable description for the WMO code.
func weatherDescription(for code: Int) -> String {
switch code {
case 0: return "Clear Sky"
case 1: return "Mainly Clear"
case 2: return "Partly Cloudy"
case 3: return "Overcast"
case 45, 48: return "Foggy"
case 51...55: return "Drizzle"
case 61...65: return "Rainy"
case 71...75: return "Snowy"
case 80...82: return "Heavy Rain"
case 95: return "Thunderstorm"
case 96, 99: return "Hail Storm"
default: return "Cloudy"
}
}
// Creates a temperature-driven background gradient.
// Cold blues for freezing temps, warm oranges for heat, and cool blues for mild days.
func gradientForTemperature(_ celsius: Double, isDay: Bool) -> LinearGradient {
let colors: [Color]
switch celsius {
case ..<0:
colors = [Color(red: 0.60, green: 0.78, blue: 0.94),
Color(red: 0.30, green: 0.50, blue: 0.78)] // Icy blue (like Brave Highlands in winter)
case 0..<10:
colors = [Color(red: 0.45, green: 0.60, blue: 0.85),
Color(red: 0.20, green: 0.35, blue: 0.65)] // Cool blue
case 10..<20:
colors = [Color(red: 0.28, green: 0.55, blue: 0.82),
Color(red: 0.10, green: 0.30, blue: 0.60)] // Mild day blue
case 20..<28:
colors = isDay
? [Color(red: 0.26, green: 0.60, blue: 0.96), Color(red: 0.07, green: 0.36, blue: 0.75)]
: [Color(red: 0.10, green: 0.15, blue: 0.40), Color(red: 0.05, green: 0.08, blue: 0.25)]
case 28..<35:
colors = [Color(red: 0.98, green: 0.60, blue: 0.15),
Color(red: 0.86, green: 0.30, blue: 0.05)] // Warm orange (Radiator Springs summer)
default:
colors = [Color(red: 0.95, green: 0.35, blue: 0.10),
Color(red: 0.75, green: 0.10, blue: 0.05)] // Hot red
}
return LinearGradient(colors: colors, startPoint: .top, endPoint: .bottom)
}
These helpers are free functions rather than methods on a type because they’re pure transformations with no state — they take a value and return a value. That makes them easy to test and reuse across views.
The gradient logic is the secret sauce of the visual design. A clear, sunny 32°C day in Radiator Springs gets a warm orange sky, while a foggy morning in the Ratatouille Quarter gets a muted blue. This gives every city a distinct feel without any hardcoded city-specific logic.
Checkpoint: Build and run. Select “Radiator Springs” from the list and wait for the data to load. The background should shift to a warm orange gradient if the current temperature is above 28°C, or a cool blue for milder conditions. Try selecting “Brave Highlands” (Aberdeen, Scotland) — it will almost always be cloudy and cool, giving a steel-blue sky. The weather icon should animate with a subtle bounce effect when it first appears.
Step 8: Handling Errors and Empty States
Robust apps anticipate failure. Add the ErrorView component referenced earlier in WeatherDetailView. Add this struct
to Views/WeatherDetailView.swift below the main view:
struct ErrorView: View {
let message: String
let retryAction: () -> Void
var body: some View {
VStack(spacing: 20) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 56))
.foregroundStyle(.yellow)
Text("Something went wrong")
.font(.title2.weight(.semibold))
.foregroundStyle(.white)
Text(message)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.8))
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
Button("Try Again") {
retryAction()
}
.buttonStyle(.borderedProminent)
.tint(.white)
.foregroundStyle(.black)
}
.padding()
}
}
Also add a loading skeleton to prevent layout jumps while data arrives. In WeatherDetailView, replace the
ProgressView with a more polished placeholder:
// Replace the ProgressView block with:
if viewModel.isLoading {
VStack(spacing: 16) {
RoundedRectangle(cornerRadius: 12)
.fill(.white.opacity(0.2))
.frame(width: 80, height: 80)
RoundedRectangle(cornerRadius: 8)
.fill(.white.opacity(0.2))
.frame(width: 160, height: 28)
RoundedRectangle(cornerRadius: 8)
.fill(.white.opacity(0.2))
.frame(width: 100, height: 72)
}
.padding(.top, 40)
.redacted(reason: .placeholder)
.shimmer() // ← We'll add this modifier below
}
Add a simple shimmer effect modifier. Create Views/ShimmerModifier.swift:
import SwiftUI
struct ShimmerModifier: ViewModifier {
@State private var phase: CGFloat = 0
func body(content: Content) -> some View {
content
.overlay(
LinearGradient(
gradient: Gradient(stops: [
.init(color: .clear, location: phase - 0.3),
.init(color: .white.opacity(0.4), location: phase),
.init(color: .clear, location: phase + 0.3),
]),
startPoint: .leading,
endPoint: .trailing
)
.mask(content)
)
.onAppear {
withAnimation(.linear(duration: 1.4).repeatForever(autoreverses: false)) {
phase = 1.3
}
}
}
}
extension View {
func shimmer() -> some View {
modifier(ShimmerModifier())
}
}
Tip: The
.redacted(reason: .placeholder)modifier replaces text with grey bars automatically. Combining it with the shimmer effect gives the app a skeleton-loading feel similar to the iOS App Store — users immediately understand that content is loading rather than missing.
Finally, consider what happens when the user has no internet connection. The WeatherError.networkFailure case already
captures this. Make sure your error messages are human-friendly by updating the errorDescription for that case in
WeatherService.swift:
case .networkFailure(let error):
// Check for common connectivity errors and surface a friendlier message
let nsError = error as NSError
if nsError.code == NSURLErrorNotConnectedToInternet {
return "It looks like you're offline. Check your connection and try again."
}
return "Network request failed: \(error.localizedDescription)"
Checkpoint: To test the error state, switch your simulator to airplane mode (Settings > enable Airplane Mode, or use the Hardware menu in Simulator), then tap “Try Again” on any city. You should see the offline-friendly message appear. Disable airplane mode, tap “Try Again” again, and the weather should reload successfully. The shimmer placeholder should be visible for a brief moment before the data arrives.
Where to Go From Here?
Congratulations! You’ve built Pixar Weather Station — a fully functional SwiftUI weather app that fetches live data,
decodes JSON with Codable, displays dynamic gradients based on temperature, and handles errors gracefully.
Here’s what you learned:
- How to model a real JSON API response using nested
Codablestructs with explicitCodingKeys - How to call
URLSessionasynchronously withasync throwsand surface typed errors to the UI - How to drive UI state from an
@Observableview model marked@MainActor - How to use
NavigationSplitViewfor adaptive sidebar-detail layouts - How to build a shimmer loading state with a custom
ViewModifier - How to map WMO weather codes to SF Symbols and temperature values to dynamic gradients
Ideas for extending this project:
- Add real GPS location using
CoreLocationandCLLocationManagerto show weather for the user’s actual position, not just Pixar cities. - Local notifications for weather alerts — schedule a
UNUserNotificationCenternotification when wind speed exceeds a threshold (great for alerting Buzz Lightyear about turbulence on his flight path). - Hourly forecast — Open-Meteo supports hourly data with the
hourlyquery parameter. Add a horizontal scroll view below the current conditions showing temperatures and icons for the next 12 hours. - Celsius/Fahrenheit toggle — store the preference in
UserDefaultsand update the display across all views reactively. - Widgets — expose the current temperature for the selected city as a
WidgetKitwidget using the sameWeatherServiceactor.