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
- In Apple Developer portal, edit your App ID → enable WeatherKit.
- In Xcode project → Signing & Capabilities → “+ Capability” → WeatherKit.
- Verify
WeatherKit.entitlementsis 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
- Signing & Capabilities → + iCloud → check CloudKit + Key-value storage (off — we don’t use it).
- Add a container
iCloud.com.yourorg.skywatch. - Open CloudKit Dashboard → create the
SavedLocationrecord 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
- Both app and widget targets → Signing & Capabilities → + App Groups →
group.com.yourorg.skywatch. WeatherCachewrites its disk file toFileManager.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