Project 3 · Rubric and defend your codeswift-6.4/ios-26
Lesson 5 / 5
Unit 23 · Skycast

Project 3 · Rubric and defend your code

Note

Unit 23 · Project 3 · Rubric. The last Tutor-stage self-review. The defend-your-code prompts target the two ideas this project exists to lock in: why the view model is @MainActor, and how you guarantee a spinner always resolves.

Skycast is graded on states as much as on the happy path. An app that fetches weather but hangs on airplane mode or crashes on denied permission has missed the point. Score honestly, and test each state — don't assume.

Rubric

Area Meets bar Falls short
Networking Async URLSession, typed client, Endpoint values URL(string:)! scattered; completion handlers
Decoding Codable with CodingKeys; decode errors surfaced Force-unwrapped/crashy decode; swallowed errors
Concurrency @MainActor @Observable view model; Sendable models ObservableObject/@Published; off-main mutation
Load states All four LoadState cases rendered "Loading or loaded" only; no error UI
Error UX Named message + retry; verified on airplane mode Raw Error shown, or an endless spinner
Chart Swift Charts trend; empty-data guarded Empty chart renders as broken
Location Permission flow; denied/undetermined states handled Crashes or blanks when denied; no purpose string
Location safety nonisolated delegate reads Sendable values before the actor hop Non-Sendable capture into a @MainActor task (won't build in Swift 6)
Locations & search Saved list + .searchable adds a city; value-based nav to detail Single hard-coded city; no search
Detail completeness Header, hourly, chart, 10-day, and modules all present Bare temperature label; missing sections
Load trigger .task / .onChange(coordinate), cancellation-aware Manual Task in onAppear; fetch before location
Accessibility Chart + controls labeled; states readable Unlabeled chart; icon-only controls
Light/dark Correct in both; no color literals Only checked light
Secrets No key/secret in repo (Open-Meteo needs none) A key committed to source

Every state is graded

Walk each one and confirm the UI on screen:

  • Loading — labeled ProgressView, not a bare spinner.
  • Loaded — current conditions + chart.
  • Failed — named message and a working Retry (test with airplane mode).
  • Permission undetermined — a "requesting" state.
  • Permission denied — a clear message pointing to Settings, no crash.
  • Empty chart — a note, not an empty plot.

Accessibility and light/dark are graded

VoiceOver should read the current temperature, the states, and the chart (via its accessibilityLabel; consider .accessibilityChartDescriptor for the audio graph as a stretch). Toggle dark mode and check the chart's line, the area gradient, and every state screen. You used .accentColor and semantic colors, so both modes should hold — verify.

Tip

The single most valuable test in this project is airplane mode. It exercises the transport error, the message(for:) mapping, the failed state's layout, and the retry path in one go. If that flow is clean, your states are real.

Defend your code

  1. Why is the view model @MainActor? It mutates state, which SwiftUI observes to render the UI, and observed UI state must change on the main actor. Marking the class @MainActor makes every mutation main-thread by construction. Explain why the await inside load still doesn't block the UI (the network work runs off-main inside URLSession; only the state assignments are main-actor).

  2. How do you avoid a spinner that never ends? LoadState.loading is transient and load's do/catch always transitions it to .loaded or .failed — there's no path that stays in .loading. And .failed ships a Retry, so a failure is recoverable, not terminal.

  3. Why an enum for load state instead of separate isLoading / error / data properties? Because those can express impossible combinations (loading and an error and stale data at once). A single enum makes the states mutually exclusive and the switch exhaustive — you can't render a contradictory or forgotten state.

  4. Why is Forecast Sendable, and where does it matter? It crosses from the client into the @MainActor view model, an actor boundary only Sendable values may cross. It's free because it's an immutable struct of Sendable parts.

  5. Why gate the fetch on the location arriving rather than firing it in .task? You don't have a coordinate at launch — permission and a fix take time. .onChange(of: coordinate) fetches once you actually know where "here" is; fetching in .task would run against a coordinate you don't have.

  6. Your CLLocationManagerDelegate methods are nonisolated. What did you have to be careful about, and why does Swift 6 enforce it? They run off the main actor, so they read the Sendable value (authorizationStatus, .coordinate) first, then hop to @MainActor to store it — never capturing the non-Sendable CLLocationManager or CLLocation into the task. Swift 6 rejects that capture as a data race at compile time.

Definition of done

  • Every rubric row is green, or you can explain an amber one.
  • You tested loading, loaded, failed (airplane mode), undetermined, denied, and empty-chart.
  • You answered all six prompts without reopening the milestones.
  • VoiceOver reads the screen; both color schemes are correct; no secret is committed.

That closes the Tutor stage. From Project 4 the AI moves to Pair — and the checkpoints start handing you code with deliberate flaws to find.

Knowledge check

Q: A reviewer says "just use isLoading: Bool and errorMessage: String?, it's simpler." Rebut it. Two booleans/optionals can represent impossible states — loading true and an error set and old data present. A LoadState enum makes the cases mutually exclusive and forces the view to handle each via an exhaustive switch, so contradictory or forgotten states can't compile.

Q: Your forecast loads fine but VoiceOver users get nothing from the chart. Does it pass? No. The chart needs at least an accessibilityLabel (ideally a chart descriptor for the audio graph). A visualization that conveys nothing to non-sighted users fails the accessibility bar, which is graded, not optional.