SkyWatch — Implementation Guide

This is a step-by-step build walkthrough. Each step has a checkpoint: “if you see X, you’re on track.” Total estimated time: 40–60 hours spread across two weeks.

Day 1 — Project setup, capabilities, first WeatherKit call

Step 1. Create the project

mkdir SkyWatch && cd SkyWatch
# In Xcode: File → New → Project → iOS → App
# Product Name: SkyWatch, Interface: SwiftUI, Storage: None (we'll add CloudKit later)

Step 2. Enable WeatherKit

  1. In Apple Developer portal, edit your App ID → enable WeatherKit.
  2. In Xcode project → Signing & Capabilities → “+ Capability” → WeatherKit.
  3. Verify WeatherKit.entitlements is generated and committed.

Checkpoint: Building produces no signing errors. If you see com.apple.developer.weatherkit not allowed, the App ID isn’t right.

Step 3. First WeatherKit call

import WeatherKit
import CoreLocation

@MainActor
@Observable
final class HomeViewModel {
    var current: CurrentWeather?
    var errorMessage: String?

    func load() async {
        do {
            let loc = CLLocation(latitude: 37.7749, longitude: -122.4194)
            let weather = try await WeatherService.shared.weather(for: loc)
            self.current = weather.currentWeather
        } catch {
            self.errorMessage = error.localizedDescription
        }
    }
}

Run it. You should see a CurrentWeather populated. Checkpoint: print weather.currentWeather.temperature — should be a Measurement<UnitTemperature> with a sane value for San Francisco.

Step 4. WeatherKit attribution

Apple requires displaying the attribution badge and a link to legal text. Add to HomeView:

HStack {
    Image("weatherkit-badge")  // download from Apple's brand assets
    Link("Apple Weather Data Sources", destination: URL(string: "https://weatherkit.apple.com/legal-attribution.html")!)
}
.font(.caption2)

Do this before writing more features. Apple Review fails apps missing the attribution.

Day 2–3 — SPM modules + cache layer

Step 5. Extract SkyWatchCore

# In Xcode: File → New → Package → "SkyWatchCore"
# Add it as a local package, then make the main app depend on it.

Move your Weather, Location, error types here. No SwiftUI imports.

Step 6. Build WeatherService module

Create the WeatherService package with the cache from architecture.md. Expose a protocol so callers don’t depend on WeatherKit directly:

public protocol WeatherProviding {
    func weather(for location: CLLocation) async throws -> Weather
}

public final class CachedWeatherService: WeatherProviding {
    private let cache: WeatherCache
    private let upstream: WeatherService

    public init(cache: WeatherCache = WeatherCache(),
                upstream: WeatherService = .shared) {
        self.cache = cache
        self.upstream = upstream
    }

    public func weather(for location: CLLocation) async throws -> Weather {
        let key = WeatherCache.Key(coordinate: location.coordinate, kinds: [.current, .hourly, .daily])
        if let cached = await cache.get(key) { return cached }
        let fresh = try await upstream.weather(for: location)
        await cache.set(key, weather: fresh)
        return fresh
    }
}

Checkpoint: write a unit test using a fake WeatherProviding to confirm HomeViewModel calls through and caches subsequent loads.

Day 4 — Saved locations + CloudKit

Step 7. Enable CloudKit

  1. Signing & Capabilities → + iCloud → check CloudKit + Key-value storage (off — we don’t use it).
  2. Add a container iCloud.com.yourorg.skywatch.
  3. Open CloudKit Dashboard → create the SavedLocation record type in Development.

Checkpoint: launch the app, sign into iCloud in the simulator. CKContainer.default().accountStatus should return .available.

Step 8. Build LocationStore

public actor LocationStore {
    private let db: CKDatabase
    public init(container: CKContainer = .default()) {
        self.db = container.privateCloudDatabase
    }

    public func fetchAll() async throws -> [SavedLocation] {
        let query = CKQuery(recordType: "SavedLocation", predicate: NSPredicate(value: true))
        let (results, _) = try await db.records(matching: query)
        return results.compactMap { _, result in
            guard case .success(let record) = result else { return nil }
            return SavedLocation(record: record)
        }
    }

    public func save(_ location: SavedLocation) async throws { /* ... */ }
    public func delete(_ id: CKRecord.ID) async throws { /* ... */ }
    public func subscribeToChanges() async throws { /* CKQuerySubscription */ }
}

Step 9. CloudKit subscription for sync

let sub = CKQuerySubscription(
    recordType: "SavedLocation",
    predicate: NSPredicate(value: true),
    options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
)
let info = CKSubscription.NotificationInfo()
info.shouldSendContentAvailable = true  // silent push
sub.notificationInfo = info
try await db.save(sub)

Handle the silent push in application(_:didReceiveRemoteNotification:fetchCompletionHandler:). Reload LocationStore.

Checkpoint: edit a saved location on the simulator, see it appear on a real device signed into the same iCloud account within 30 seconds.

Day 5–6 — Map view

Step 10. MapKit with overlays

Use the iOS 17+ Map { ... } SwiftUI API:

Map(initialPosition: .region(region)) {
    ForEach(savedLocations) { loc in
        Marker(loc.name, coordinate: loc.coordinate)
    }
}

Step 11. Precipitation overlay

WeatherKit’s minuteForecast gives 60 minutes of 1-minute precipitation amounts at a single coordinate. To build a “map” overlay, you sample multiple coordinates in a grid around the user’s center, batch-call WeatherKit, and draw a Canvas overlay tinted by intensity.

Heuristic budget: a 5×5 grid is 25 calls per map open. Cache aggressively (5-minute TTL on minuteForecast) and require a manual refresh.

Step 12. Time slider

A Slider from 0 to 60 (minutes ahead) drives which minute of the minute-forecast we render. Tint the overlay accordingly.

Checkpoint: open the map over San Francisco, scrub the slider, see overlay intensity change.

Day 7–8 — Widgets

Step 13. Add the widget extension

File → New → Target → Widget Extension → name SkyWatchWidget. Embed in app.

Step 14. App Group for shared cache

  1. Both app and widget targets → Signing & Capabilities → + App Groups → group.com.yourorg.skywatch.
  2. WeatherCache writes its disk file to FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:).

Step 15. TimelineProvider

struct Provider: AppIntentTimelineProvider {
    func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> WeatherEntry { /* ... */ }

    func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<WeatherEntry> {
        let cached = await SharedCache.read(for: configuration.locationID) ?? .placeholder
        let entries = (0..<4).map { i in
            WeatherEntry(date: .now.addingTimeInterval(Double(i) * 900), weather: cached)
        }
        return Timeline(entries: entries, policy: .after(.now.addingTimeInterval(900)))
    }
}

Step 16. Widget views

Implement accessoryCircular (a precipitation icon + percentage), accessoryRectangular (next 4 hours sparkline), systemSmall (current temp + icon), systemMedium (current + next 12 hours).

Checkpoint: long-press home screen → Add Widget → SkyWatch — your widgets appear. Drop one on screen and watch it populate from the cached data.

Day 9 — Background refresh

Step 17. Register BGAppRefreshTask

BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.yourorg.skywatch.refresh", using: nil) { task in
    Task {
        await self.refreshAllLocations()
        task.setTaskCompleted(success: true)
    }
}

Schedule the next one after each successful refresh.

Step 18. Severe-weather notifications

After refresh, compare the new alerts list against the persisted set; for new alerts, schedule a UNNotificationRequest keyed by alert.id so re-runs don’t duplicate.

Checkpoint: install a build, leave the app in the background overnight, wake up to confirm widget content advanced (you can also force a refresh from Xcode’s debug menu).

Day 10 — App Intents

Step 19. “Get weather for location” intent

struct GetWeatherIntent: AppIntent {
    static var title: LocalizedStringResource = "Get Weather"
    @Parameter(title: "Location") var location: LocationEntity

    func perform() async throws -> some IntentResult & ProvidesDialog {
        let weather = try await CachedWeatherService.shared.weather(for: location.clLocation)
        let temp = weather.currentWeather.temperature
        return .result(dialog: "It is \(temp.formatted()) in \(location.name).")
    }
}

Register LocationEntity as an AppEntity conforming to IndexedEntity so saved locations appear in Shortcuts.

Checkpoint: open Shortcuts, search “SkyWatch” — your intent appears. Run it. Siri reads the result.

Day 11 — Fastlane + TestFlight

Step 20. Fastfile

See Phase 10 Lab 10.1 for the template. Add a lane:

lane :beta do
  match(type: "appstore", readonly: true)
  gym(scheme: "SkyWatch", export_method: "app-store")
  pilot(skip_waiting_for_build_processing: true)
end

Step 21. First TestFlight upload

bundle exec fastlane beta

Add 5 external testers via App Store Connect. Wait for build processing (~5 min) + tester approval (~1 hour for first build).

Checkpoint: at least one external tester installs the build via TestFlight and reports the home screen loads.

Day 12–14 — Polish, hardening, screenshots

  • Walk through hardening-checklist.md.
  • Take screenshots in the simulator at all required sizes using Fastlane snapshot.
  • Write the README on GitHub.
  • Record a 60-second screen capture for your portfolio.

Next: Hardening checklist