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

Contents

Getting Started

Open Xcode and create a new project:

  1. Select File > New > Project, then choose the App template.
  2. Set the Product Name to PixarWeather.
  3. Ensure Interface is set to SwiftUI and Language is Swift.
  4. Set your Deployment Target to iOS 18.0.
  5. 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:

  • WeatherService is declared as an actor. 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, using actor from the start is a good habit for service types.
  • The Open-Meteo URL is built with URLComponents rather 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 throws signature lets callers use try await without 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 a CodingKeys mismatch, double-check that every rawValue string 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 CodingKeys you 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 NavigationSplitView drives city selection via $selectedCity in ContentView, the cleanest approach on a real project is to lift the selected city into an @Observable app-level model and pass it through the environment. For this tutorial, keeping it as a @State in ContentView is 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 Codable structs with explicit CodingKeys
  • How to call URLSession asynchronously with async throws and surface typed errors to the UI
  • How to drive UI state from an @Observable view model marked @MainActor
  • How to use NavigationSplitView for 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 CoreLocation and CLLocationManager to show weather for the user’s actual position, not just Pixar cities.
  • Local notifications for weather alerts — schedule a UNUserNotificationCenter notification 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 hourly query 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 UserDefaults and update the display across all views reactively.
  • Widgets — expose the current temperature for the selected city as a WidgetKit widget using the same WeatherService actor.