Project 3 · Rubric and defend your code
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.
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
Why is the view model
@MainActor? It mutatesstate, which SwiftUI observes to render the UI, and observed UI state must change on the main actor. Marking the class@MainActormakes every mutation main-thread by construction. Explain why theawaitinsideloadstill doesn't block the UI (the network work runs off-main insideURLSession; only the state assignments are main-actor).How do you avoid a spinner that never ends?
LoadState.loadingis transient andload'sdo/catchalways transitions it to.loadedor.failed— there's no path that stays in.loading. And.failedships a Retry, so a failure is recoverable, not terminal.Why an enum for load state instead of separate
isLoading/error/dataproperties? Because those can express impossible combinations (loading and an error and stale data at once). A single enum makes the states mutually exclusive and theswitchexhaustive — you can't render a contradictory or forgotten state.Why is
ForecastSendable, and where does it matter? It crosses from the client into the@MainActorview model, an actor boundary onlySendablevalues may cross. It's free because it's an immutable struct ofSendableparts.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.taskwould run against a coordinate you don't have.Your
CLLocationManagerDelegatemethods arenonisolated. What did you have to be careful about, and why does Swift 6 enforce it? They run off the main actor, so they read theSendablevalue (authorizationStatus,.coordinate) first, then hop to@MainActorto store it — never capturing the non-SendableCLLocationManagerorCLLocationinto 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.