Project 3 · Milestone 2 — the view model, load states, and the detailswift-6.4/ios-26
Lesson 3 / 5
Unit 23 · Skycast

Project 3 · Milestone 2 — the view model, load states, and the detail

Note

Unit 23 · Project 3 · Milestone 2. You build the view model with a LoadState enum and the full city-detail screen: current header, hourly strip, a Swift Charts temperature graph, the 10-day forecast, and the detail modules — with loading and error rendered on purpose.

Network data has four lives: nothing yet, loading, loaded, failed. Model that as an enum so the view can't forget a case — the compiler makes you handle each.

Step 1 — the load state and view model

import Observation

enum LoadState {
    case idle
    case loading
    case loaded(Forecast)
    case failed(String)
}

@MainActor
@Observable
final class ForecastViewModel {
    private(set) var state: LoadState = .idle
    private let client: APIClient

    init(client: APIClient = APIClient()) {
        self.client = client
    }

    func load(latitude: Double, longitude: Double) async {
        state = .loading
        do {
            let forecast = try await client.forecast(latitude: latitude, longitude: longitude)
            state = .loaded(forecast)
        } catch {
            state = .failed(message(for: error))
        }
    }

    private func message(for error: Error) -> String {
        switch error {
        case APIError.transport:       return "No internet connection. Check your network and try again."
        case APIError.invalidResponse: return "The weather service is unavailable right now."
        case APIError.decoding:        return "Received unexpected data. Please try again."
        default:                       return "Something went wrong."
        }
    }
}

@MainActor puts every mutation of state on the main actor — the view reads it safely. The load method hops to the network inside client.forecast, then comes back to set state. And message(for:) turns your typed APIError into a specific human sentence per failure.

Step 2 — the detail screen skeleton

The whole screen is one switch over state. Loading and error are real views, not afterthoughts; the success case composes the detail sections:

struct WeatherView: View {
    let city: City
    @State private var model = ForecastViewModel()

    var body: some View {
        ScrollView {
            switch model.state {
            case .idle, .loading:
                ProgressView("Loading forecast…").padding(.top, 120)
            case .failed(let message):
                ContentUnavailableView {
                    Label("Couldn't load weather", systemImage: "cloud.slash")
                } description: {
                    Text(message)
                } actions: {
                    Button("Retry") {
                        Task { await model.load(latitude: city.latitude, longitude: city.longitude) }
                    }
                }
                .padding(.top, 80)
            case .loaded(let forecast):
                content(forecast)
            }
        }
        .navigationTitle(city.name)
        .navigationBarTitleDisplayMode(.inline)
        .task { await model.load(latitude: city.latitude, longitude: city.longitude) }
    }

    private func content(_ forecast: Forecast) -> some View {
        VStack(spacing: 16) {
            CurrentHeader(city: city, current: forecast.current, daily: forecast.daily)
            HourlyStrip(hourly: forecast.hourly)
            TemperatureGraph(hourly: forecast.hourly)
            DailyList(daily: forecast.daily)
            DetailTiles(current: forecast.current, daily: forecast.daily)
        }
        .padding()
    }
}

Step 3 — the header and hourly strip

The header is the big number; the strip zips the hourly arrays into a horizontal scroller, an icon and a temperature per hour:

struct CurrentHeader: View {
    let city: City
    let current: Forecast.Current
    let daily: Forecast.Daily

    var body: some View {
        VStack(spacing: 2) {
            Text(city.name).font(.largeTitle)
            Text("\(Int(current.temperature.rounded()))°").font(.system(size: 84, weight: .thin))
            Text(WeatherCode.label(current.weatherCode)).font(.headline).foregroundStyle(.secondary)
            if let hi = daily.tempMax.first, let lo = daily.tempMin.first {
                Text("H:\(Int(hi))°   L:\(Int(lo))°").font(.subheadline).foregroundStyle(.secondary)
            }
        }
        .padding(.vertical, 8)
    }
}

struct HourlyStrip: View {
    let hourly: Forecast.Hourly

    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Label("Hourly Forecast", systemImage: "clock").font(.caption).foregroundStyle(.secondary)
            Divider()
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(spacing: 22) {
                    ForEach(0..<min(12, hourly.time.count), id: \.self) { i in
                        VStack(spacing: 8) {
                            Text(i == 0 ? "Now" : hourly.time[i].formatted(.dateTime.hour()))
                                .font(.caption).foregroundStyle(.secondary)
                            Image(systemName: WeatherCode.symbol(hourly.weatherCode[i]))
                                .symbolRenderingMode(.multicolor).font(.title3)
                            Text("\(Int(hourly.temperature[i].rounded()))°").font(.callout.bold())
                        }
                    }
                }
            }
        }
        .padding()
        .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 16))
    }
}

Step 4 — the temperature graph with Swift Charts

Plot the next 24 hours as a smoothed line over a translucent area — the one place a real gradient is warranted (a chart fill), not decoration:

import Charts

struct TemperatureGraph: View {
    let hourly: Forecast.Hourly

    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Label("Temperature", systemImage: "chart.xyaxis.line").font(.caption).foregroundStyle(.secondary)
            Chart(0..<min(24, hourly.time.count), id: \.self) { i in
                LineMark(x: .value("Time", hourly.time[i]), y: .value("Temp", hourly.temperature[i]))
                    .interpolationMethod(.catmullRom)
                AreaMark(x: .value("Time", hourly.time[i]), y: .value("Temp", hourly.temperature[i]))
                    .interpolationMethod(.catmullRom)
                    .foregroundStyle(.linearGradient(colors: [.orange.opacity(0.3), .clear],
                                                     startPoint: .top, endPoint: .bottom))
            }
            .chartXAxis { AxisMarks(values: .stride(by: .hour, count: 6)) }
            .frame(height: 150)
        }
        .padding()
        .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 16))
    }
}

Step 5 — the 10-day forecast and detail modules

The daily list is a row per day with a high/low range bar — a custom GeometryReader dataviz that positions each day's span inside the week's overall range. The detail modules are a 2-column grid of labelled tiles (feels-like, humidity, wind, UV, sunrise, sunset). Both read straight off the decoded daily/current; build them as DailyList and DetailTiles, matched to the finished screenshots in the spec.

Tip

The range bar is the interesting bit: normalize each day's (min, max) against the week's overall (min, max) to get two x-offsets, then draw a capsule between them. It's the same idea as a chart, hand-rolled — good practice for when Swift Charts isn't the right shape.

Checkpoint

Pointed at a city, the detail screen should:

  • Show a proper loading spinner, then the forecast.
  • Render the header, the hourly strip with icons, the temperature graph, the 10-day list, and the modules.
  • Show a retryable error on airplane mode (throttle the sim's network or fail the request).

Knowledge check

Q: Why is state a single enum instead of separate isLoading, error, and forecast properties? Because those separate flags allow impossible combinations (loading and an error and a stale forecast). One enum makes the states mutually exclusive, and the switch forces the view to handle every case — you can't render a spinner over a failure by accident.

Q: Where does the AreaMark's gradient sit relative to the design contract? It's the sanctioned exception: a chart's area-fill is data, not decoration. The rest of the app stays flat; the gradient earns its place by encoding the temperature magnitude.