watchOS: App Structure, Connectivity, Workouts and the Smart Stack

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Apple Watch is the device people glance at for two seconds, with a raised wrist, often while doing something else. FieldNotes has had a watch app since Getting Started with Apple Platforms: the FieldNotesWatch target with FieldNotesWatchApp and WatchNoteList, and a widget extension, FieldNotesWatchWidgets, that puts pinned notes on the watch face. This page finishes it. It targets the section’s baseline of iOS, iPadOS, macOS, watchOS and visionOS 27, built with Xcode 27 and Swift 6.4, and keeps a deployment minimum of 26, so anything that exists only in the 27 releases is labelled "new in watchOS 27" and guarded with #available.

The page adds four things to FieldNotes:

  • WatchAppDelegate, the delegate behind the existing WKApplicationDelegateAdaptor, now handling WatchConnectivity background tasks as well as refresh tasks;

  • WatchConnectivityBridge, one class compiled into both the iPhone app and the watch app, which carries quick notes from the watch ("quickNote" and "noteTransfer") and the pinned list from iPhone ("pinnedNoteIDs");

  • mirroring for page 32’s FieldTripWorkoutSession, so a hike started on the wrist also shows on iPhone, and WorkoutKit plans for planned field trips;

  • NextFieldTripComplication, a complication and Smart Stack widget in FieldNotesWatchWidgets, next to the existing FieldTripRelevanceWidget.

Note data itself still reaches the watch through iCloud, as Persisting Models with SwiftData explains: the watch app opens its own copy of the store and CloudKit keeps it in step with iPhone. WatchConnectivity is only for what can’t wait for CloudKit: a note dictated on the wrist, the current pinned list, and a photo thumbnail.

App Structure

One target, one SwiftUI app

A watch app is a single target: one bundle with its own @main App, written in SwiftUI. Older watch apps had two parts, a WatchKit app with a storyboard and a WatchKit extension with the code; current Xcode templates create only the app target, and the extension split is history. FieldNotesWatch was added to the existing project with File › New › Target › watchOS › App and the "Watch App for Existing iOS App" option, as One Codebase for iOS, iPadOS, macOS, watchOS and visionOS shows. Its widget extension, FieldNotesWatchWidgets, is embedded in it.

Target Bundle identifier Contains

FieldNotes (iPhone app, embeds the watch app)

com.example.fieldnotes

The iPhone side of WatchConnectivityBridge

FieldNotesWatch

com.example.fieldnotes.watchkitapp

FieldNotesWatchApp, WatchAppDelegate, WatchRootView, WatchNoteList, the watch side of the bridge

FieldNotesWatchWidgets

com.example.fieldnotes.watchkitapp.widgets

FieldNotesWatchWidgetBundle: PinnedNotesWidget, FieldTripRelevanceWidget, NextFieldTripComplication, QuickCaptureControl

The target’s identity lives in a few Info.plist keys. Xcode writes them when it creates the target; the watch app only needs to be told who its companion is:

<!-- FieldNotesWatch/Info.plist (excerpt) -->
<key>WKApplication</key>
<true/>
<key>WKCompanionAppBundleIdentifier</key>
<string>com.example.fieldnotes</string>
<key>WKRunsIndependentlyOfCompanionApp</key>
<true/>
<key>WKBackgroundModes</key>
<array>
    <string>workout-processing</string>
    <string>mindfulness</string>
</array>

WKApplication marks a single-target SwiftUI app. WKBackgroundModes is written by the Background Modes capability: workout-processing for FieldTripWorkoutSession, and mindfulness for the extended runtime session of ObservationTimer from Background Execution. An app may enable only one extended runtime mode (self care, mindfulness, physical therapy or alarm), but it can combine it with workout-processing.

Dependent or independent

A watch app with an iPhone companion is either dependent (it needs the iPhone app installed first) or independent (people can install it from the App Store on the watch and use it without the iPhone app). A third kind, the watch-only app, has no iPhone app at all; Xcode still creates a stub iOS target for it, which carries the root bundle identifier and packages it for the App Store, but installs nothing on iPhone.

Kind Installation What it must do on its own

Dependent

Installs with the iPhone app; can’t launch until the iPhone app is installed

Nothing extra; it may rely on the iPhone app for data

Independent (WKRunsIndependentlyOfCompanionApp)

Installs from the watch’s App Store or with the iPhone app

Sign in, ask for permissions and download data itself; WatchConnectivity can’t be its main data source

Watch-only (WKWatchOnly)

Installs on the watch only

Everything

FieldNotes is independent: its notes come from CloudKit, which works without the phone, and its permission prompts (HealthKit, notifications) appear on the watch. That choice shapes the bridge below: when the iPhone app isn’t installed, a note dictated on the watch is saved on the watch.

WKApplicationDelegateAdaptor and WatchAppDelegate

The watch app’s App is the one from App Structure and Life Cycle (SwiftUI, UIKit, AppKit), with three changes: it takes its store from AppServices.shared, like the iPhone app (see Background Execution), so the delegate and the views share one instance; its root view is WatchRootView; and it declares a notification scene for long looks.

// FieldNotesWatch/FieldNotesWatchApp.swift (updated)
import SwiftUI
import WatchKit
import FieldNotesServices

@main
struct FieldNotesWatchApp: App {
    @WKApplicationDelegateAdaptor private var delegate: WatchAppDelegate
    // The same instance that WatchAppDelegate and WatchConnectivityBridge reach through AppServices.shared.
    @State private var store = AppServices.shared.store

    var body: some Scene {
        WindowGroup {
            WatchRootView()
                .environment(store)
                .environment(WatchConnectivityBridge.shared)
                .task { await store.load() }
        }

        WKNotificationScene(controller: NoteReminderNotificationController.self,
                            category: NoteReminderScheduler.categoryIdentifier)
    }
}

AppServices.swift and SharedNoteAccess.swift join the FieldNotesWatch target for this. On the watch, SharedNoteAccess.persistence() opens the store in the watch’s own App Group container, and because the watch app is not an app extension, ModelContainer.fieldNotes() turns on CloudKit mirroring for it. Needs the App Groups and iCloud (CloudKit) capabilities on the watch app, with the container iCloud.com.example.fieldnotes, and a paid developer account.

WatchAppDelegate keeps its role from the life-cycle page and gains one: WatchConnectivity can launch the app in the background to deliver data, and that launch arrives as a WKWatchConnectivityRefreshBackgroundTask. Every task must be completed, or it eats the app’s background budget and the app is eventually terminated. The delegate hands those tasks to the bridge, which completes them once everything pending has been delivered:

// FieldNotesWatch/WatchAppDelegate.swift (updated: the delegate from App Structure and Life Cycle)
import WatchKit
import OSLog
import FieldNotesServices

final class WatchAppDelegate: NSObject, WKApplicationDelegate {
    private let log = Logger(subsystem: "com.example.fieldnotes", category: "sync")

    func applicationDidFinishLaunching() {
        // First: a background launch may exist only to deliver WatchConnectivity data.
        WatchConnectivityBridge.shared.activate()
        NoteReminderScheduler().registerCategories()   // the long look shows the Done and Snooze buttons
        RefreshScheduler.scheduleNext()
    }

    func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) {
        for task in backgroundTasks {
            switch task {
            case let connectivity as WKWatchConnectivityRefreshBackgroundTask:
                // Completed by the bridge when WCSession has nothing more to deliver.
                WatchConnectivityBridge.shared.hold(connectivity)
            case let refresh as WKApplicationRefreshBackgroundTask:
                RefreshScheduler.scheduleNext()   // keep the chain going even if this run fails
                Task {
                    do {
                        try await AppServices.shared.store.refresh()
                    } catch {
                        log.error("Background refresh failed: \(error.localizedDescription)")
                    }
                    refresh.setTaskCompletedWithSnapshot(false)
                }
            default:
                task.setTaskCompletedWithSnapshot(false)
            }
        }
    }
}

WKApplicationDelegate is declared @MainActor, so handle(_:) runs on the main actor and the Task it starts inherits that isolation; the refresh task never leaves the main actor. The older WKExtensionDelegate and WKExtension belong to the two-part app model. They are deprecated for apps whose deployment target is watchOS 9.2 or later, and the watchOS 27 release notes repeat it. Use WKApplicationDelegate and WKApplication instead.

Navigation and Input

A watch screen holds a title, three or four rows, or one number. SwiftUI’s watch navigation reflects that: pages that people move through vertically with the Digital Crown, stacks that push one level at a time, and toolbar buttons in the corners.

Vertical pages and NavigationStack

WatchRootView is the root of the watch app: a TabView with the .verticalPage style, so turning the Digital Crown moves between full-screen pages. The first page is the existing WatchNoteList inside a NavigationStack; the others are the capture page, page 32’s FieldTripView (already compiled for watchOS) and the Quiet Observation timer. containerBackground(_:for:) tints each page’s background, which the system cross-fades while scrolling:

// FieldNotesWatch/WatchRootView.swift
import SwiftUI

struct WatchRootView: View {
    enum Page: Hashable { case notes, capture, fieldTrip, observe }
    @State private var page: Page = .notes

    var body: some View {
        TabView(selection: $page) {
            NavigationStack {
                WatchNoteList()
            }
            .tag(Page.notes)

            WatchCapturePage()
                .containerBackground(.blue.gradient, for: .tabView)
                .tag(Page.capture)

            FieldTripView()              // from Health, Motion and Accessories
                .containerBackground(.green.gradient, for: .tabView)
                .tag(Page.fieldTrip)

            QuietObservationPage()
                .containerBackground(.teal.gradient, for: .tabView)
                .tag(Page.observe)
        }
        .tabViewStyle(.verticalPage)
    }
}

The views in the tab view read @Environment(NoteStore.self) and @Environment(WatchConnectivityBridge.self), which FieldNotesWatchApp injects above. WatchNoteList pushes a note with a value-based link; its declaration gains a destination:

// FieldNotesWatch/WatchNoteList.swift (updated excerpt)
List(store.notes.sorted { $0.isPinned && !$1.isPinned }) { note in
    NavigationLink(value: note.id) {
        Label(note.title, systemImage: note.isPinned ? "pin.fill" : "note.text")
    }
}
.navigationTitle("FieldNotes")
.navigationDestination(for: Note.ID.self) { noteID in
    WatchNoteDetail(noteID: noteID)
}

The list identifies rows by Note.id, a stable identity, and pushes the ID rather than the note, so a note that changes through CloudKit while it is on screen shows its new contents. Navigation on the other platforms is on Navigation and Presentation.

The Digital Crown and double tap

Lists and scroll views already scroll with the Digital Crown. A custom control binds the crown to a value with digitalCrownRotation, which only receives crown input while its view has focus, hence focusable(). The note detail uses it to pick a reminder delay, and marks the reminder button as the target of double tap (pinching the index finger and thumb twice on supported models) with handGestureShortcut(.primaryAction):

// FieldNotesWatch/WatchNoteDetail.swift
import SwiftUI
import WatchKit
import FieldNotesModel
import FieldNotesServices

struct WatchNoteDetail: View {
    @Environment(NoteStore.self) private var store
    let noteID: Note.ID
    @State private var minutes = 30.0
    @State private var status: String?

    var body: some View {
        if let note = store.notes.first(where: { $0.id == noteID }) {
            ScrollView {
                VStack(alignment: .leading, spacing: 8) {
                    WatchNotePhoto(noteID: note.id)
                    Text(note.body)
                        .privacySensitive()            // blurred in Always On, see below
                    Divider()
                    Text("Remind me in \(Int(minutes)) min")
                        .focusable()
                        .digitalCrownRotation($minutes, from: 5, through: 120, by: 5,
                                              sensitivity: .low, isContinuous: false,
                                              isHapticFeedbackEnabled: true)
                    Button("Remind Me", systemImage: "bell") {
                        Task { await remind(note) }
                    }
                    .handGestureShortcut(.primaryAction)
                    if let status {
                        Text(status).font(.footnote).foregroundStyle(.secondary)
                    }
                }
            }
            .navigationTitle(note.title)
        } else {
            ContentUnavailableView("Note Removed", systemImage: "note.text")
        }
    }

    private func remind(_ note: Note) async {
        do {
            try await NoteReminderScheduler().scheduleReminder(for: note, at: .now.addingTimeInterval(minutes * 60))
            status = "Reminder set"
            WKInterfaceDevice.current().play(.success)
        } catch {
            status = error.localizedDescription
        }
    }
}

NoteReminderScheduler is the reminder service from Notifications, APNs and Live Activities; it lives in FieldNotesServices, so the watch schedules reminders with the same code as iPhone. WatchNotePhoto shows the thumbnail that iPhone sends with a file transfer, further down. Give only one control per screen the .primaryAction shortcut: double tap runs it without the person touching the screen, and the system highlights it so people know what will happen.

Always On

On watches with an Always On display, an app stays visible after the wrist drops: dimmed, updated rarely, and still interactive when touched. The system updates a Text that uses a date style or Text(timerInterval:) by itself, so a countdown keeps running without waking the app. For everything else, read isLuminanceReduced and drop sub-second detail, and hide private content with privacySensitive(), as WatchNoteDetail does. The Quiet Observation page shows all three, around the ObservationTimer from Background Execution:

// FieldNotesWatch/QuietObservationPage.swift
import SwiftUI
import WatchKit

struct QuietObservationPage: View {
    @Environment(\.isLuminanceReduced) private var isLuminanceReduced
    @State private var timer = ObservationTimer()
    @State private var interval: ClosedRange<Date>?

    var body: some View {
        VStack(spacing: 8) {
            if let interval {
                Text(timerInterval: interval, showsHours: false)   // counts down in Always On too
                    .font(isLuminanceReduced ? .title2 : .system(.largeTitle, design: .rounded))
                    .foregroundStyle(isLuminanceReduced ? .secondary : .primary)
                    .monospacedDigit()
                Button("Stop", systemImage: "stop.fill", role: .destructive) { stop() }
            } else {
                Button { start() } label: {
                    ViewThatFits(in: .horizontal) {          // a full label on large cases, the icon on small ones
                        Label("Quiet Observation", systemImage: "leaf")
                        Image(systemName: "leaf")
                    }
                }
            }
        }
        .task(id: interval) {
            guard let interval else { return }
            do {
                try await Task.sleep(until: .now + .seconds(interval.upperBound.timeIntervalSinceNow))
            } catch {
                return   // cancelled because the person stopped early
            }
            stop()
            WKInterfaceDevice.current().play(.success)
        }
    }

    private func start() {
        timer.start()                                   // WKExtendedRuntimeSession, mindfulness type
        interval = Date.now...Date.now.addingTimeInterval(10 * 60)
    }

    private func stop() {
        timer.stop()
        interval = nil
    }
}

Apps built for watchOS 8 and later take part in Always On by default; WKSupportsAlwaysOnDisplay set to false opts out. An app stays frontmost after the wrist drops for about two minutes by default (people can raise it to an hour), while an app running a workout or extended runtime session stays on screen for as long as the session runs. Always On isn’t available on Apple Watch SE or on Series 4 and earlier; there, the screen turns off. Testing the dimmed state needs a physical watch that supports it; the simulator can lower the wrist from its Device menu.

Screen sizes

Apple Watch comes in several case sizes, from the smallest Apple Watch SE to Apple Watch Ultra, with corner radii and safe areas that differ. Lay out with stacks, List, system fonts and Dynamic Type, never with fixed frames, and use ViewThatFits (above) where a label may not fit. Xcode Previews can show the same view on several watch simulators side by side, which is the quickest way to check the smallest and largest cases.

Long-look notifications

Notifications from iPhone are forwarded to the watch when the phone is locked. The watch first shows a short look (the app icon and title) and, when the wrist stays raised, a long look. Watch apps can’t use the Notification Content extensions from the notifications page. Instead, a WKNotificationScene pairs a notification category with a WKUserNotificationHostingController whose SwiftUI body draws the long look. FieldNotes' reminders use the NOTE_REMINDER category, and the Done and Snooze buttons appear under the custom body automatically:

// FieldNotesWatch/NoteReminderNotificationController.swift
import SwiftUI
import UserNotifications
import WatchKit

final class NoteReminderNotificationController: WKUserNotificationHostingController<NoteReminderLongLook> {
    private var title = ""
    private var bodyText = ""

    override var body: NoteReminderLongLook {
        NoteReminderLongLook(title: title, bodyText: bodyText)
    }

    override func didReceive(_ notification: UNNotification) {
        title = notification.request.content.title
        bodyText = notification.request.content.body
    }
}

struct NoteReminderLongLook: View {
    let title: String
    let bodyText: String

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Label(title, systemImage: "note.text").font(.headline)
            Text(bodyText).font(.body).privacySensitive()
        }
    }
}

The scene was declared in FieldNotesWatchApp above. Scheduling, categories and actions are on Notifications, APNs and Live Activities.

WatchConnectivity

WatchConnectivity is the direct link between an iPhone app and its watch app, over Bluetooth or Wi-Fi, with no server. Both sides use the singleton WCSession.default, give it a delegate, and activate it early. After that, there are four ways to send data, and choosing the right one is most of the work.

API Delivery Received in FieldNotes uses it for

sendMessage(_:replyHandler:errorHandler:)

Immediately, only while the counterpart is reachable; a message from the watch wakes the iPhone app in the background. Fails with an error otherwise.

session(_:didReceiveMessage:replyHandler:)

"quickNote": a note dictated on the watch while iPhone is nearby, with a reply that confirms it

transferUserInfo(_:)

Queued; every dictionary is delivered, in order, even if the counterpart isn’t running

session(_:didReceiveUserInfo:)

"noteTransfer": the same note when iPhone isn’t reachable, or the message failed

updateApplicationContext(_:)

Queued; only the latest dictionary is kept, older ones are overwritten

session(_:didReceiveApplicationContext:) and receivedApplicationContext

"pinnedNoteIDs" and "nextFieldTrip": the current state, which only matters in its newest version

transferFile(_:metadata:)

Queued in the background, for files and a small metadata dictionary

session(_:didReceive:)

A photo thumbnail for a pinned note

The dictionaries may contain only property-list types: strings, numbers, dates, data, arrays and dictionaries of those. FieldNotes encodes its own values as Data with PropertyListEncoder or JSONEncoder.

Figure: the four transfer types

flowchart LR subgraph Watch["FieldNotesWatch on Apple Watch"] W1["capture(title:body:)
iPhone reachable"] W2["capture(title:body:)
iPhone not reachable,
or the message failed"] WR["apply pins and next trip,
reload complications"] WF["move the photo into
the watch's App Group"] end subgraph Phone["FieldNotes on iPhone"] PM["save the note in NoteStore
(may be woken in the background)"] PC["publishContext()"] PF["sendPhoto(_:for:)"] end W1 -->|"sendMessage: interactive, now or error
key quickNote, with a reply"| PM W2 -->|"transferUserInfo: queued, every item, in order
key noteTransfer"| PM PC -->|"updateApplicationContext: latest state only
keys pinnedNoteIDs, nextFieldTrip"| WR PF -->|"transferFile: queued file + metadata
metadata key noteID"| WF

WatchConnectivityBridge: the session and its state

WatchConnectivityBridge is one file with target membership in FieldNotes and FieldNotesWatch. It is @Observable, the macro that has superseded the older ObservableObject/@Published protocol for new state containers (see Apple Developer Documentation — Observation), so views can show whether iPhone is reachable and which quick notes are still on their way, and @MainActor because it touches NoteStore. WCSessionDelegate is not main-actor isolated and the session calls it on a background queue, so every delegate method is nonisolated: it reads the dictionaries where they arrive, turns them into Sendable values, and only then hops to the main actor. The app targets use main-actor default isolation, so the small value types the delegate methods use are declared nonisolated too.

// Shared/WatchConnectivityBridge.swift (target membership: FieldNotes, FieldNotesWatch)
#if os(iOS) || os(watchOS)
import Foundation
import Observation
import OSLog
import WatchConnectivity
import FieldNotesModel
import FieldNotesServices
#if os(watchOS)
import WatchKit
import WidgetKit
#endif

/// A note on its way from the watch to iPhone.
nonisolated struct WatchNotePayload: Codable, Identifiable, Sendable {
    var id = UUID()
    var title: String
    var body: String
    var createdAt = Date.now

    var note: Note { Note(id: id, title: title, body: body, createdAt: createdAt, tags: ["watch"]) }
}

@Observable
@MainActor
final class WatchConnectivityBridge: NSObject, WCSessionDelegate {
    static let shared = WatchConnectivityBridge()

    /// The keys of the dictionaries FieldNotes exchanges.
    nonisolated enum Key {
        static let quickNote = "quickNote"            // sendMessage
        static let noteTransfer = "noteTransfer"      // transferUserInfo
        static let pinnedNoteIDs = "pinnedNoteIDs"    // updateApplicationContext
        static let nextFieldTrip = "nextFieldTrip"    // updateApplicationContext, same dictionary
        static let noteID = "noteID"                  // transferFile metadata
    }

    private(set) var isActivated = false
    private(set) var isReachable = false
    /// Watch: quick notes that iPhone hasn't confirmed yet.
    private(set) var pendingCaptures: [WatchNotePayload] = []
    /// Watch: bumped when a new photo arrives, so photo views reload.
    private(set) var photoRevision = 0

    @ObservationIgnored private var unsentCaptures: [WatchNotePayload] = []
    #if os(watchOS)
    @ObservationIgnored private var backgroundTasks: [WKWatchConnectivityRefreshBackgroundTask] = []
    @ObservationIgnored private var pendingContentObservation: NSKeyValueObservation?
    @ObservationIgnored private var activationFinished = false
    @ObservationIgnored private var updatesInFlight = 0
    #endif

    nonisolated static let log = Logger(subsystem: "com.example.fieldnotes", category: "sync")

    /// Call once, as early as possible in every launch, including background launches.
    func activate() {
        guard WCSession.isSupported() else { return }   // false on iPad: there is no paired watch
        WCSession.default.delegate = self
        WCSession.default.activate()
    }

    nonisolated func session(_ session: WCSession,
                             activationDidCompleteWith activationState: WCSessionActivationState,
                             error: (any Error)?) {
        if let error {
            Self.log.error("WCSession activation failed: \(error.localizedDescription)")
        }
        let activated = activationState == .activated
        #if os(watchOS)
        // A context that arrived while the app wasn't running is waiting here.
        let context = Self.decodeContext(session.receivedApplicationContext)
        #endif
        Task { @MainActor in
            self.isActivated = activated
            #if os(watchOS)
            self.activationFinished = true
            self.flushUnsentCaptures()                  // captures made while activation was pending
            if activated, let context { await self.apply(context) }
            self.completeBackgroundTasksIfIdle()
            #else
            if activated { self.publishContext() }
            #endif
        }
    }

    nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
        let reachable = session.isReachable
        Task { @MainActor in self.isReachable = reachable }
    }

    #if os(iOS)
    nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}

    nonisolated func sessionDidDeactivate(_ session: WCSession) {
        // The person switched to another Apple Watch: activate again to talk to the new one.
        WCSession.default.activate()
    }
    #endif
}
#endif

On iPhone the delegate must also implement sessionDidBecomeInactive(_:) and sessionDidDeactivate(_:), because one iPhone can be paired with several watches; after a switch, the session deactivates and the app activates it again. Neither exists on watchOS. WCSession.isSupported() is false on iPad, so the same FieldNotes binary skips WatchConnectivity there. The listings below continue the same file, as extensions of the class.

Starting early on iPhone

A message from the watch can launch the iPhone app in the background, with no scene and no SwiftUI view. The session therefore has to be activated in the app delegate, before application(_:didFinishLaunchingWithOptions:) returns, not in a view’s .task:

// FieldNotes/iOS/FieldNotesAppDelegate.swift (updated excerpt: the delegate from Notifications and Live Activities)
func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
    UNUserNotificationCenter.current().delegate = notificationDelegate
    NoteReminderScheduler().registerCategories()
    Task { try? await NoteReminderScheduler().requestAuthorization(provisional: true) }
    application.registerForRemoteNotifications()
    #if os(iOS)
    WatchConnectivityBridge.shared.activate()                  // messages and transfers from the watch
    FieldTripWorkoutSession.companion.listenForMirroredSessions()  // hikes started on the watch (below)
    #endif
    return true
}

FieldNotesAppDelegate also serves the visionOS build, hence the #if os(iOS) around the two new lines. The watch side activates in WatchAppDelegate.applicationDidFinishLaunching(), shown earlier.

Quick capture: sendMessage with a transferUserInfo fallback

A note dictated on the wrist should appear on iPhone at once, update the running field trip there, and reach CloudKit from the device that is usually online. So the watch doesn’t save it; it sends it, and iPhone is the only writer. That rule matters: CloudKit identifies a record by its own record name, not by Note.id, so if both devices inserted the same note, each insert would become its own record and the note would appear twice. When the iPhone app isn’t installed (the independent case), the watch is the only device and saves the note itself. The session’s properties, isCompanionAppInstalled included, are only meaningful once activation has completed, so a note captured before then waits in unsentCaptures and is routed from activationDidCompleteWith.

// Shared/WatchConnectivityBridge.swift (continued)
#if os(watchOS)
extension WatchConnectivityBridge {
    func capture(title: String, body: String = "") {
        let payload = WatchNotePayload(title: title, body: body)
        guard WCSession.isSupported() else {
            AppServices.shared.store.add(payload.note)   // no WatchConnectivity: the watch is the writer
            return
        }
        guard WCSession.default.activationState == .activated else {
            // isCompanionAppInstalled reads false until activation completes, so don't decide yet.
            unsentCaptures.append(payload)               // routed from activationDidCompleteWith
            return
        }
        route(payload)
    }

    /// Called from activationDidCompleteWith, whatever the outcome.
    fileprivate func flushUnsentCaptures() {
        let captures = unsentCaptures
        unsentCaptures.removeAll()
        captures.forEach(route)
    }

    /// Decides who writes the note, once the session state is known.
    private func route(_ payload: WatchNotePayload) {
        let session = WCSession.default
        guard session.activationState == .activated, session.isCompanionAppInstalled else {
            AppServices.shared.store.add(payload.note)   // no iPhone app (or no session): the watch is the writer
            return
        }
        pendingCaptures.append(payload)
        send(payload)
    }

    private func send(_ payload: WatchNotePayload) {
        let data: Data
        do {
            data = try PropertyListEncoder().encode(payload)
        } catch {
            Self.log.error("Could not encode a quick note: \(error.localizedDescription)")
            return
        }
        let session = WCSession.default
        guard session.isReachable else {
            session.transferUserInfo([Key.noteTransfer: data])   // queued until iPhone can take it
            return
        }
        session.sendMessage([Key.quickNote: data], replyHandler: { @Sendable reply in
            let received = reply["received"] as? Bool ?? false
            Task { @MainActor in
                if received { self.confirm(payload.id) }
            }
        }, errorHandler: { @Sendable error in
            // Not delivered in time (for example, iPhone went out of range): queue it instead.
            Self.log.notice("Quick note message failed, queuing it: \(error.localizedDescription)")
            WCSession.default.transferUserInfo([Key.noteTransfer: data])
        })
    }

    fileprivate func confirm(_ id: WatchNotePayload.ID) {
        pendingCaptures.removeAll { $0.id == id }
    }
}
#endif

extension WatchConnectivityBridge {
    nonisolated func session(_ session: WCSession, didFinish userInfoTransfer: WCSessionUserInfoTransfer,
                             error: (any Error)?) {
        if let error {
            Self.log.error("A queued transfer failed: \(error.localizedDescription)")
            return
        }
        #if os(watchOS)
        guard let payload = Self.decodePayload(userInfoTransfer.userInfo[Key.noteTransfer]) else { return }
        Task { @MainActor in self.confirm(payload.id) }   // delivered to iPhone
        #endif
    }

    nonisolated static func decodePayload(_ value: Any?) -> WatchNotePayload? {
        guard let data = value as? Data else { return nil }
        do {
            return try PropertyListDecoder().decode(WatchNotePayload.self, from: data)
        } catch {
            log.error("Could not decode a quick note: \(error.localizedDescription)")
            return nil
        }
    }
}

The reply and error handlers run on a background thread. They are written as explicit @Sendable closures so the Swift 6 compiler doesn’t infer main-actor isolation for them from the surrounding code, which would crash at run time when WatchConnectivity calls them off the main thread. They capture only Sendable values: the payload, its Data, and self, which is a main-actor class.

On iPhone, the delegate replies at once (the watch is waiting, and the reply handler must return quickly) and then saves on the main actor. A message and its fallback can both arrive if a message timed out after it was delivered, so the save checks the ID first; SwiftDataNotePersistence.save(_:) also updates an existing record rather than inserting a second one.

// Shared/WatchConnectivityBridge.swift (continued)
extension WatchConnectivityBridge {
    nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any],
                             replyHandler: @escaping ([String: Any]) -> Void) {
        let payload = Self.decodePayload(message[Key.quickNote])
        replyHandler(["received": payload != nil])
        #if os(iOS)
        if let payload { Task { @MainActor in await self.save(payload) } }
        #endif
    }

    nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
        #if os(iOS)
        guard let payload = Self.decodePayload(userInfo[Key.noteTransfer]) else { return }
        Task { @MainActor in await self.save(payload) }
        #endif
    }

    #if os(iOS)
    private func save(_ payload: WatchNotePayload) async {
        let store = AppServices.shared.store
        if store.notes.isEmpty {
            await store.load()          // a cold background launch: read the library first
        }
        guard !store.notes.contains(where: { $0.id == payload.id }) else { return }
        store.add(payload.note)
        await store.waitForPendingWrites()
    }
    #endif
}

The write is small and finishes within the time the system gives a background launch for WatchConnectivity. Once it lands, CloudKit carries the note back to the watch’s own store, where WatchNoteList shows it; until then, the capture page lists it under Sending to iPhone:

// FieldNotesWatch/WatchCapturePage.swift
import SwiftUI

struct WatchCapturePage: View {
    @Environment(WatchConnectivityBridge.self) private var bridge
    @State private var text = ""

    var body: some View {
        List {
            TextField("Dictate a note", text: $text)     // dictation, Scribble or the keyboard
                .onSubmit { Task { await capture() } }
            if !bridge.pendingCaptures.isEmpty {
                Section("Sending to iPhone") {
                    ForEach(bridge.pendingCaptures) { payload in
                        Label(payload.title, systemImage: "clock")
                    }
                }
            }
        }
    }

    private func capture() async {
        let dictation = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !dictation.isEmpty else { return }
        text = ""
        var title = String(dictation.prefix(40))
        #if canImport(FoundationModels)
        if #available(watchOS 27, *), let suggested = await WatchTitleSuggester.title(for: dictation) {
            title = suggested                            // new in watchOS 27, see the last section
        }
        #endif
        bridge.capture(title: title, body: dictation)
    }
}

The pinned list: updateApplicationContext

The watch doesn’t need every past pinned list, only the current one. That is exactly what the application context is for: each update replaces the previous dictionary, and the watch receives the newest one when it next runs, or reads it from receivedApplicationContext at activation. Because the whole dictionary is replaced, every call must send every key; FieldNotes sends the pinned note IDs and the next planned field trip together:

// Shared/WatchConnectivityBridge.swift (continued)
extension WatchConnectivityBridge {
    #if os(iOS)
    /// iPhone: sends the current pinned list and the next field trip. Cheap to call on every change.
    func publishContext() {
        let session = WCSession.default
        guard session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { return }
        let pinned = AppServices.shared.store.notes.filter(\.isPinned).map(\.id.uuidString)
        var context: [String: Any] = [Key.pinnedNoteIDs: pinned]
        do {
            if let trip = NextFieldTripSnapshot.load() {
                context[Key.nextFieldTrip] = try JSONEncoder().encode(trip)
            }
            try session.updateApplicationContext(context)
        } catch {
            Self.log.error("Could not update the watch's context: \(error.localizedDescription)")
        }
    }
    #endif

    nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
        #if os(watchOS)
        guard let context = Self.decodeContext(applicationContext) else { return }
        Task { @MainActor in await self.apply(context) }
        #endif
    }

    #if os(watchOS)
    nonisolated struct WatchContext: Sendable {
        var pinnedIDs: Set<Note.ID>
        var nextTrip: NextFieldTripSnapshot?
    }

    nonisolated static func decodeContext(_ dictionary: [String: Any]) -> WatchContext? {
        guard let raw = dictionary[Key.pinnedNoteIDs] as? [String] else { return nil }   // empty before the first update
        var nextTrip: NextFieldTripSnapshot?
        if let data = dictionary[Key.nextFieldTrip] as? Data {
            do {
                nextTrip = try JSONDecoder().decode(NextFieldTripSnapshot.self, from: data)
            } catch {
                log.error("Could not decode the next field trip: \(error.localizedDescription)")
            }
        }
        return WatchContext(pinnedIDs: Set(raw.compactMap(UUID.init(uuidString:))), nextTrip: nextTrip)
    }

    /// Watch: applies the pins to the local store, stores the next trip for the complication, reloads widgets.
    fileprivate func apply(_ context: WatchContext) async {
        updatesInFlight += 1
        defer {
            updatesInFlight -= 1
            completeBackgroundTasksIfIdle()
        }
        if let trip = context.nextTrip { trip.save() } else { NextFieldTripSnapshot.clear() }

        let store = AppServices.shared.store
        if store.notes.isEmpty { await store.load() }
        for note in store.notes where note.isPinned != context.pinnedIDs.contains(note.id) {
            store.togglePin(note)       // notes not synced yet arrive later from CloudKit, already pinned
        }
        await store.waitForPendingWrites()
        WidgetCenter.shared.reloadTimelines(ofKind: SharedNoteAccess.pinnedNotesWidgetKind)
        WidgetCenter.shared.reloadTimelines(ofKind: NextFieldTripSnapshot.widgetKind)
    }
    #endif
}

The iPhone app calls publishContext() from its scene whenever the set of pinned IDs changes, and FieldTripPlanner (below) calls it after scheduling a trip:

// FieldNotes/FieldNotesApp.swift (excerpt: the iOS main window)
WindowGroup {
    ContentView()
        .environment(store)
        #if os(iOS)
        .onChange(of: store.notes.filter(\.isPinned).map(\.id)) {
            WatchConnectivityBridge.shared.publishContext()
        }
        #endif
}

Pins changed inside the widget extension (by TogglePinIntent) don’t pass through this scene; the next launch or change publishes them, and CloudKit carries them in the meantime. Writing the same pin on both devices is harmless: both write the same value.

Photos: transferFile

Photo bytes live in CloudKit as external assets, which a watch downloads slowly and stores in little space. For a pinned note, iPhone instead sends a small thumbnail as a file. The received file is deleted as soon as the delegate method returns, so the watch moves it into its App Group container right there, on the delegate’s queue:

// Shared/WatchConnectivityBridge.swift (continued)
extension WatchConnectivityBridge {
    #if os(iOS)
    /// iPhone: sends a JPEG thumbnail (a few hundred pixels wide) for a pinned note.
    func sendPhoto(_ fileURL: URL, for noteID: Note.ID) {
        guard WCSession.default.activationState == .activated else { return }
        WCSession.default.transferFile(fileURL, metadata: [Key.noteID: noteID.uuidString])
    }
    #endif

    nonisolated static func photoURL(for noteID: Note.ID) -> URL {
        let group = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.example.fieldnotes")
            ?? URL.cachesDirectory
        return group.appending(path: "WatchPhotos/\(noteID.uuidString).jpg")
    }

    nonisolated func session(_ session: WCSession, didReceive file: WCSessionFile) {
        guard let raw = file.metadata?[Key.noteID] as? String, let noteID = UUID(uuidString: raw) else { return }
        let destination = Self.photoURL(for: noteID)
        let files = FileManager.default
        do {
            try files.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
            if files.fileExists(atPath: destination.path(percentEncoded: false)) {
                try files.removeItem(at: destination)
            }
            try files.moveItem(at: file.fileURL, to: destination)   // before returning: the system deletes it
        } catch {
            Self.log.error("Could not keep a received photo: \(error.localizedDescription)")
            return
        }
        Task { @MainActor in self.photoRevision += 1 }
    }
}

WatchNotePhoto(noteID:), used by WatchNoteDetail, is a small view that loads the file at WatchConnectivityBridge.photoURL(for:) and reloads when photoRevision changes. The file sits in the App Group container, so FieldNotesWatchWidgets could draw it too.

Completing WatchConnectivity background tasks

The last piece is the one Apple’s sample warns about: the WKWatchConnectivityRefreshBackgroundTask objects that WatchAppDelegate handed over must be completed, but only when nothing is left to deliver. That is when the session is activated and hasContentPending is false, when activation has failed, and when any update being applied has finished:

// Shared/WatchConnectivityBridge.swift (continued)
#if os(watchOS)
extension WatchConnectivityBridge {
    func hold(_ task: WKWatchConnectivityRefreshBackgroundTask) {
        backgroundTasks.append(task)
        if pendingContentObservation == nil {
            pendingContentObservation = WCSession.default.observe(\.hasContentPending) { @Sendable [weak self] _, _ in
                Task { @MainActor in self?.completeBackgroundTasksIfIdle() }
            }
        }
        completeBackgroundTasksIfIdle()
    }

    fileprivate func completeBackgroundTasksIfIdle() {
        let session = WCSession.default
        let delivered = session.activationState == .activated ? !session.hasContentPending : activationFinished
        guard delivered, updatesInFlight == 0, !backgroundTasks.isEmpty else { return }
        backgroundTasks.forEach { $0.setTaskCompletedWithSnapshot(false) }
        backgroundTasks.removeAll()
    }
}
#endif

Testing WatchConnectivity needs a physical iPhone and a paired Apple Watch, both running builds signed by the same team. Run the watch app from its Home Screen rather than from Xcode when testing background delivery: a debugger attached from Xcode keeps the app from suspending, which hides exactly the bugs above.

Workouts

FieldTripWorkoutSession on the wrist

Health, Motion and Accessories built FieldTripWorkoutSession around HKWorkoutSession and HKLiveWorkoutBuilder, and FieldTripView around it, both compiled for iPhone and Apple Watch. On the watch the same code gets more from the hardware: the heart-rate sensor runs at a high rate, GPS records the route, and while the session runs the app stays on screen and keeps running with the wrist down. Running it needs the HealthKit capability on the watch app, the NSHealthShareUsageDescription and NSHealthUpdateUsageDescription keys in the watch app’s Info.plist, the workout-processing background mode shown earlier, and a physical Apple Watch.

Mirroring the session to iPhone

A hike recorded on the watch can appear live on iPhone. The watch calls startMirroringToCompanionDevice() on its running session; iPhone receives an HKWorkoutSession of type .mirrored through the health store’s workoutSessionMirroringStartHandler, launching the app in the background if needed. From then on either side can send small Data values to the other with sendToRemoteWorkoutSession(data:). The handler must be set at every launch, which is why FieldNotesAppDelegate calls listenForMirroredSessions() above.

The additions below go in the same file as FieldTripWorkoutSession, as extensions, so they can use its private session property; two existing methods get one extra call each, shown as labelled excerpts.

// FieldNotes/Health/FieldTripWorkoutSession.swift (updated: mirroring between Apple Watch and iPhone)
#if os(iOS) || os(watchOS)
import HealthKit
import OSLog

/// The live numbers the watch sends to iPhone while a mirrored hike runs.
nonisolated struct MirroredFieldTripStats: Codable, Sendable {
    var distanceMeters: Double
    var activeKilocalories: Double
    var heartRate: Double?
}

extension FieldTripWorkoutSession {
    nonisolated private static let mirrorLog = Logger(subsystem: "com.example.fieldnotes", category: "sync")

    #if os(watchOS)
    /// Watch: called at the end of start(). iPhone receives a mirrored session.
    func mirrorToPhone() async {
        guard let session else { return }
        do {
            try await session.startMirroringToCompanionDevice()
        } catch {
            // No iPhone nearby, or FieldNotes isn't installed there: the hike still records on the watch.
            Self.mirrorLog.notice("Mirroring unavailable: \(error.localizedDescription)")
        }
    }

    /// Watch: sends the latest numbers to the mirrored session.
    func sendStatsToPhone() {
        guard let session, session.type == .primary else { return }
        let stats = MirroredFieldTripStats(distanceMeters: distanceMeters,
                                           activeKilocalories: activeKilocalories, heartRate: heartRate)
        Task {
            do {
                try await session.sendToRemoteWorkoutSession(data: JSONEncoder().encode(stats))
            } catch {
                Self.mirrorLog.notice("Could not send stats to iPhone: \(error.localizedDescription)")
            }
        }
    }
    #endif

    #if os(iOS)
    /// iPhone: the one instance that adopts sessions the watch mirrors. It lives for the whole process.
    static let companion = FieldTripWorkoutSession()

    /// iPhone: set at every launch, before didFinishLaunching returns.
    func listenForMirroredSessions() {
        healthStore.workoutSessionMirroringStartHandler = { [weak self] mirrored in
            Task { @MainActor in self?.adopt(mirrored) }
        }
    }

    private func adopt(_ mirrored: HKWorkoutSession) {
        mirrored.delegate = self
        session = mirrored
        state = .running
    }
    #endif
}

// In start() (updated excerpt), after `try await builder.beginCollection(at: startDate)`:
//     #if os(watchOS)
//     await mirrorToPhone()
//     #endif
//
// In workoutBuilder(_:didCollectDataOf:) (updated excerpt), at the end of its main-actor Task:
//     #if os(watchOS)
//     self.sendStatsToPhone()
//     #endif

// The existing HKWorkoutSessionDelegate conformance from page 32 already covers this type; these two methods
// are added to it here, so the extension below doesn't restate `: HKWorkoutSessionDelegate` (Swift rejects a
// second conformance declaration for the same protocol on the same type).
extension FieldTripWorkoutSession {
    // workoutSession(_:didChangeTo:from:date:) and workoutSession(_:didFailWithError:) as before.

    nonisolated func workoutSession(_ workoutSession: HKWorkoutSession,
                                    didReceiveDataFromRemoteWorkoutSession data: [Data]) {
        guard let latest = data.last else { return }
        let stats: MirroredFieldTripStats
        do {
            stats = try JSONDecoder().decode(MirroredFieldTripStats.self, from: latest)
        } catch {
            Self.mirrorLog.error("Unreadable stats from the watch: \(error.localizedDescription)")
            return
        }
        Task { @MainActor in
            self.distanceMeters = stats.distanceMeters
            self.activeKilocalories = stats.activeKilocalories
            self.heartRate = stats.heartRate
        }
    }

    nonisolated func workoutSession(_ workoutSession: HKWorkoutSession,
                                    didDisconnectFromRemoteDeviceWithError error: (any Error)?) {
        Task { @MainActor in self.state = .ended }
    }
}
#endif

The last extension is not a second conformance: its two methods are added inside page 32’s existing HKWorkoutSessionDelegate extension, which is where Objective-C optional requirements must be implemented to be found. HKWorkoutSession is Sendable, so the mirroring handler (a @Sendable closure called on a HealthKit queue) can pass it to the main actor. On iPhone, FieldTripView keeps its own session for hikes started on the phone, and a small banner view can show FieldTripWorkoutSession.companion while the watch records. Mirroring needs the HealthKit capability on both apps, a physical iPhone and Apple Watch, and the iPhone app installed.

Planned field trips with WorkoutKit

WorkoutKit describes workouts before they happen. An iPhone app can build a WorkoutPlan (a single goal, a custom workout with intervals, a pacer or a multisport plan) and schedule it with WorkoutScheduler; the plan then appears in the Workout app on Apple Watch at the scheduled time. FieldNotes uses it for planned field trips, and records the date for its own complication:

// FieldNotes/Health/FieldTripPlanner.swift
#if os(iOS)
import Foundation
import HealthKit
import WorkoutKit

@MainActor
enum FieldTripPlanner {
    /// Schedules a hike in the Workout app on Apple Watch and tells the FieldNotes watch app about it.
    static func schedule(on date: Date, distanceKilometers: Double) async -> Bool {
        guard WorkoutScheduler.isSupported else { return false }
        let scheduler = WorkoutScheduler.shared
        guard await scheduler.requestAuthorization() == .authorized else { return false }

        let hike = SingleGoalWorkout(activity: .hiking, location: .outdoor,
                                     goal: .distance(distanceKilometers, .kilometers))
        let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: date)
        await scheduler.schedule(WorkoutPlan(.goal(hike)), at: components)

        NextFieldTripSnapshot(date: date, distanceKilometers: distanceKilometers).save()
        WatchConnectivityBridge.shared.publishContext()
        return true
    }
}
#endif

Scheduling asks the person for permission the first time. It needs a paired Apple Watch and a physical iPhone.

Background Execution on the Watch

watchOS suspends an app soon after the wrist drops, and gives it time back only in a few forms. All of them already exist in FieldNotes:

Mechanism When it runs In FieldNotes

Background app refresh (scheduleBackgroundRefresh(withPreferredDate:userInfo:scheduledCompletion:))

At a time near the requested date, a few times an hour at most, with a small budget

RefreshScheduler.scheduleNext() and the WKApplicationRefreshBackgroundTask case in WatchAppDelegate

WatchConnectivity background delivery

When iPhone sends a context, user info or file while the app is suspended

The WKWatchConnectivityRefreshBackgroundTask case, completed by the bridge

WKExtendedRuntimeSession

For as long as a self care, mindfulness, physical therapy or alarm session lasts

ObservationTimer behind QuietObservationPage

HKWorkoutSession

For the whole workout

FieldTripWorkoutSession

RefreshScheduler is the helper from Background Execution; its watchOS branch calls WKApplication.shared().scheduleBackgroundRefresh(withPreferredDate:userInfo:scheduledCompletion:):

// FieldNotes/Background/RefreshScheduler.swift (the watchOS branch, from Background Execution)
WKApplication.shared().scheduleBackgroundRefresh(withPreferredDate: earliest, userInfo: nil) { error in
    if let error { print("Could not schedule refresh: \(error)") }
}

The refresh budget is small, so the best use of it is keeping complications current: a complication on the active watch face counts as always viewed, and WidgetKit gives it more reloads than a widget people rarely see. Pushes to the watch (WKApplication.shared().registerForRemoteNotifications()) and a reload triggered by WatchConnectivity are cheaper than polling.

Complications and the Smart Stack

On Apple Watch, a complication is a WidgetKit widget in an accessory family, placed in a slot of the watch face. The same widgets appear in the Smart Stack, which people open by turning the Digital Crown on the watch face, and where watchOS orders widgets by relevance. Widgets, Complications and Controls (WidgetKit) built PinnedNotesWidget and FieldTripRelevanceWidget; this section adds NextFieldTripComplication, a control and the ClockKit migration.

Figure: the watch app, its widget extension and the App Group

flowchart LR subgraph Watch["Apple Watch"] subgraph AppProcess["FieldNotesWatch process"] Bridge["WatchConnectivityBridge"] Store["AppServices.shared.store
NoteStore"] end subgraph Group["App Group group.com.example.fieldnotes (on the watch)"] DB[("SwiftData store
FieldNotes")] Defaults["UserDefaults.fieldNotesShared
nextFieldTrip"] end subgraph Extension["FieldNotesWatchWidgets process"] Pinned["PinnedNotesWidget"] Next["NextFieldTripComplication"] Nearby["FieldTripRelevanceWidget"] end Bridge --> Store Store --> DB Bridge --> Defaults Bridge -->|"WidgetCenter.reloadTimelines(ofKind:)"| Extension DB --> Pinned DB --> Nearby Defaults --> Next end Phone["FieldNotes on iPhone"] -->|"application context
pinnedNoteIDs, nextFieldTrip"| Bridge iCloud[("iCloud private database
iCloud.com.example.fieldnotes")] <-->|"CloudKit mirroring"| DB Pinned --> Face["Watch face slots
circular, rectangular, inline, corner"] Next --> Face Pinned --> Stack["Smart Stack
ordered by relevance"] Next --> Stack Nearby --> Stack

The widget extension is a separate process with its own sandbox. It shares only what is in the App Group on the watch: the SwiftData store (read through SharedNoteAccess) and the defaults suite. It never talks to iPhone; the watch app does that, writes into the group, and asks WidgetKit to reload. Both targets need the App Groups capability with group.com.example.fieldnotes.

The shared snapshot

The next trip is one date and an optional distance, so it fits in the defaults suite. A small Codable value, compiled into the iPhone app, the watch app and the watch widget extension, reads and writes it, and also names the complication’s kind so the app can reload it without linking the extension:

// Shared/NextFieldTripSnapshot.swift (target membership: FieldNotes, FieldNotesWatch, FieldNotesWatchWidgets)
import Foundation
import OSLog
import FieldNotesServices

nonisolated struct NextFieldTripSnapshot: Codable, Sendable {
    static let widgetKind = "NextFieldTripComplication"
    private static let key = "nextFieldTrip"
    private static let log = Logger(subsystem: "com.example.fieldnotes", category: "ui")

    var date: Date
    var distanceKilometers: Double?

    static func load() -> NextFieldTripSnapshot? {
        guard let data = UserDefaults.fieldNotesShared.data(forKey: key) else { return nil }
        do {
            return try JSONDecoder().decode(Self.self, from: data)
        } catch {
            log.error("Unreadable next field trip: \(error.localizedDescription)")
            return nil
        }
    }

    func save() {
        do {
            UserDefaults.fieldNotesShared.set(try JSONEncoder().encode(self), forKey: Self.key)
        } catch {
            Self.log.error("Could not store the next field trip: \(error.localizedDescription)")
        }
    }

    static func clear() {
        UserDefaults.fieldNotesShared.removeObject(forKey: key)
    }
}

UserDefaults.fieldNotesShared is the App Group suite from Preferences, Files and Document-Based Apps. On iPhone it lives in the phone’s App Group container and on the watch in the watch’s; the application context is what copies the value across.

NextFieldTripComplication

The complication has a plain TimelineProvider: there is nothing to configure. Its timeline has at most two entries: one for now, and one three hours before the trip, when the entry becomes highly relevant so the Smart Stack moves it to the top. The reload policy asks again when the trip starts:

// FieldNotesWatchWidgets/NextFieldTripComplication.swift
#if os(watchOS)
import SwiftUI
import WidgetKit

struct NextFieldTripEntry: TimelineEntry {
    let date: Date
    let trip: NextFieldTripSnapshot?
    var relevance: TimelineEntryRelevance?
}

struct NextFieldTripProvider: TimelineProvider {
    func placeholder(in context: Context) -> NextFieldTripEntry {
        NextFieldTripEntry(date: .now, trip: NextFieldTripSnapshot(date: .now.addingTimeInterval(86_400),
                                                                   distanceKilometers: 8))
    }

    func getSnapshot(in context: Context, completion: @escaping @Sendable (NextFieldTripEntry) -> Void) {
        completion(context.isPreview ? placeholder(in: context)
                                     : NextFieldTripEntry(date: .now, trip: NextFieldTripSnapshot.load()))
    }

    func getTimeline(in context: Context, completion: @escaping @Sendable (Timeline<NextFieldTripEntry>) -> Void) {
        let now = Date.now
        guard let trip = NextFieldTripSnapshot.load(), trip.date > now else {
            // The watch app reloads this kind when a new trip arrives.
            completion(Timeline(entries: [NextFieldTripEntry(date: now, trip: nil)], policy: .never))
            return
        }
        var entries = [NextFieldTripEntry(date: now, trip: trip, relevance: TimelineEntryRelevance(score: 10))]
        let leadTime = trip.date.addingTimeInterval(-3 * 3600)
        if leadTime > now {
            entries.append(NextFieldTripEntry(date: leadTime, trip: trip,
                                              relevance: TimelineEntryRelevance(score: 80, duration: 3 * 3600)))
        }
        completion(Timeline(entries: entries, policy: .after(trip.date)))
    }
}

struct NextFieldTripComplication: Widget {
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: NextFieldTripSnapshot.widgetKind, provider: NextFieldTripProvider()) { entry in
            NextFieldTripView(entry: entry)
                .containerBackground(.fill.tertiary, for: .widget)
        }
        .configurationDisplayName("Next Field Trip")
        .description("When your next planned field trip starts.")
        .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline, .accessoryCorner])
    }
}

struct NextFieldTripView: View {
    @Environment(\.widgetFamily) private var family
    let entry: NextFieldTripEntry

    var body: some View {
        if let trip = entry.trip {
            switch family {
            case .accessoryCircular:
                VStack(spacing: 0) {
                    Image(systemName: "figure.hiking")
                    Text(trip.date, format: .dateTime.weekday(.abbreviated))
                }
            case .accessoryCorner:
                Image(systemName: "figure.hiking")
                    .font(.title2)
                    .widgetLabel { Text(trip.date, style: .relative) }   // curved along the dial
            case .accessoryInline:
                Label { Text(trip.date, style: .relative) } icon: { Image(systemName: "figure.hiking") }
            default:
                VStack(alignment: .leading) {
                    Text("Next field trip").font(.headline).widgetAccentable()
                    Text(trip.date, format: .dateTime.weekday(.wide).hour().minute())
                    if let kilometers = trip.distanceKilometers {
                        Text("\(kilometers, format: .number.precision(.fractionLength(1))) km")
                    }
                }
            }
        } else {
            Image(systemName: "figure.hiking")
                .widgetLabel("No trip planned")
        }
    }
}
#endif

Accessory families are drawn in a tinted or monochrome style chosen by the watch face, so the views use SF Symbols and text, and mark the headline with widgetAccentable() so the face’s accent colour picks it out. Text(_:style:) with .relative keeps counting down without new timeline entries. widgetLabel gives the corner family its curved text; the other families ignore it, which is why the "no trip" view can use it in every family. WidgetKit gives a complication up to 75 reloads a day, more the more it is seen; the timeline above needs at most one.

TimelineEntryRelevance is how a timeline widget scores itself for the Smart Stack. Two other routes exist, both on the widgets page: the provider’s relevance() method, which PinnedNotesWidget uses for the working day, and RelevanceConfiguration (watchOS 26), where a widget exists only while a clue matches, as FieldTripRelevanceWidget does for nearby notes.

Controls on the watch

Since watchOS 26, controls run on Apple Watch too: in Control Center, and on the Action button of Apple Watch Ultra. The watch bundle lists the new complication and the Quick Capture control from the widgets page, whose file guard widens to #if os(iOS) || os(macOS) || os(watchOS) and whose OpenFieldNotesScreenIntent joins the watch app target, so the control opens FieldNotesWatch:

// FieldNotesWatchWidgets/FieldNotesWatchWidgetBundle.swift (updated)
#if os(watchOS)
import WidgetKit
import SwiftUI

@main
struct FieldNotesWatchWidgetBundle: WidgetBundle {
    var body: some Widget {
        PinnedNotesWidget()
        FieldTripRelevanceWidget()
        NextFieldTripComplication()
        QuickCaptureControl()            // controls: watchOS 26+
    }
}
#endif

Testing the Action button placement needs an Apple Watch Ultra.

Live Activities in the Smart Stack

A Live Activity started on iPhone also appears in the Smart Stack of the paired watch, with no code in the watch app. FieldTripLiveActivity declares the .small family so it can draw a denser layout there, and the view switches on activityFamily; both are on Notifications, APNs and Live Activities:

// FieldNotesWidgets/FieldTripLiveActivity.swift (excerpt, from Notifications and Live Activities)
ActivityConfiguration(for: FieldTripAttributes.self) { context in
    FieldTripLockScreenView(context: context)       // switches on activityFamily: .small on the watch
} dynamicIsland: { context in
    FieldTripDynamicIsland.make(context)
}
.supplementalActivityFamilies([.small])

FieldTripDynamicIsland.make(_:) stands for the Dynamic Island builder shown on that page. Keep the small layout to two lines of large type; the watch shows it at a glance, often in Always On.

Migrating from ClockKit

ClockKit was the complication framework before WidgetKit. ClockKit-based complications are deprecated in watchOS 10 and later: once an app ships a WidgetKit extension, the system stops asking its CLKComplicationDataSource for timelines. People who had a ClockKit complication on a face keep it if the data source tells the system which widget replaces it. FieldNotes 1.x had a ClockKit "next trip" complication, so its data source stays for one release to answer that question:

// FieldNotesWatch/Legacy/ComplicationController.swift (kept for one release, for migration only)
@preconcurrency import ClockKit   // ClockKit predates Swift 6 concurrency: its types aren't Sendable

final class ComplicationController: NSObject, CLKComplicationDataSource, CLKComplicationWidgetMigrator {
    var widgetMigrator: any CLKComplicationWidgetMigrator { self }

    func widgetConfiguration(from complicationDescriptor: CLKComplicationDescriptor) async
        -> CLKComplicationWidgetMigrationConfiguration? {
        guard complicationDescriptor.identifier == "nextTrip" else { return nil }
        return CLKComplicationStaticWidgetMigrationConfiguration(
            kind: NextFieldTripSnapshot.widgetKind,
            extensionBundleIdentifier: "com.example.fieldnotes.watchkitapp.widgets")
    }

    // The ClockKit timeline methods are no longer called once the widget extension ships.
    func currentTimelineEntry(for complication: CLKComplication) async -> CLKComplicationTimelineEntry? { nil }
}

The watchOS 27 release notes also fix WCSession.transferCurrentComplicationUserInfo(_:) for complications built with WidgetKit. It lets an iPhone app wake its watch app to update an active complication, up to 50 times a day (check remainingComplicationUserInfoTransfers). FieldNotes doesn’t need it, because the application context above is enough for a trip planned hours ahead.

New in watchOS 27

Foundation Models on the watch

The Foundation Models framework and LanguageModelSession are new on watchOS 27, but Apple’s documentation lists the on-device SystemLanguageModel only for iOS, iPadOS, macOS and visionOS, which is why Apple Intelligence and Machine Learning keeps its on-device code out of the watch build. What the watch can use is PrivateCloudComputeLanguageModel, the server model on Private Cloud Compute, which is listed for watchOS 27. The session initializer that takes any LanguageModel on watchOS is init(model:dynamicInstructions:history:). FieldNotes uses it to turn a long dictation into a short title:

// FieldNotesWatch/WatchTitleSuggester.swift
#if canImport(FoundationModels)
import FoundationModels
import OSLog

@available(watchOS 27, *)
enum WatchTitleSuggester {
    /// A title of a few words for dictated text, or nil when Private Cloud Compute can't answer.
    @MainActor
    static func title(for dictation: String) async -> String? {
        let model = PrivateCloudComputeLanguageModel()
        guard case .available = model.availability, !model.quotaUsage.isLimitReached else { return nil }
        let session = LanguageModelSession(model: model, dynamicInstructions: Instructions {
            "Write a title of at most six words for this field note. Reply with the title only."
        })
        do {
            return try await session.respond(to: Prompt { dictation }).content
        } catch {
            Logger(subsystem: "com.example.fieldnotes", category: "intents")
                .notice("No title suggestion: \(error.localizedDescription)")
            return nil          // offline or over quota: the caller keeps the first words
        }
    }
}
#endif

Needs the com.apple.developer.private-cloud-compute managed entitlement (Apple grants it on request), a watch that supports Apple Intelligence and a network connection; the suggestion is a convenience, so every failure falls back to the first 40 characters.

Vision on the watch

In watchOS 27, Vision’s image requests come to the watch: ClassifyImageRequest, DetectBarcodesRequest and their observations are now listed for watchOS 27. The watch has no camera, but it has the thumbnails iPhone sends. FieldNotes classifies a received thumbnail to give it a meaningful VoiceOver label instead of "Photo":

// FieldNotesWatch/WatchPhotoLabeler.swift
#if canImport(Vision)
import Vision
import OSLog

@available(watchOS 27, *)
enum WatchPhotoLabeler {
    /// The most confident label for a thumbnail, such as "bird", or nil.
    static func label(forPhotoAt url: URL) async -> String? {
        do {
            let observations = try await ClassifyImageRequest().perform(on: url, orientation: nil)
            return observations.filter { $0.confidence > 0.6 }
                .max { $0.confidence < $1.confidence }?
                .identifier
        } catch {
            Logger(subsystem: "com.example.fieldnotes", category: "ui")
                .notice("Could not classify a photo: \(error.localizedDescription)")
            return nil
        }
    }
}
#endif

WatchNotePhoto calls it once per received file, inside if #available(watchOS 27, *), and passes the result to accessibilityLabel(_:). The classifier runs on device, so it needs no entitlement, but it does need a physical watch to measure its memory use and speed.

And the rest

The watchOS 27 release notes also bring HealthKit’s heart-rate and cycling-power zones, which FieldTripWorkoutSession already reads (see Health, Motion and Accessories), the new @State macro implementation shared with the other platforms (see State and Data Flow with Observation), and the WKExtensionDelegate deprecation reminder above.

Availability and Requirements

Platform availability

API or feature iOS iPadOS macOS watchOS visionOS

WKApplicationDelegateAdaptor, WKApplicationDelegate, WKNotificationScene

Not available

Not available

Not available

Yes

Not available

WKExtensionDelegate, WKExtension

Not available

Not available

Not available

Deprecated (targets of 9.2 and later)

Not available

TabView with .verticalPage

Not available

Not available

Not available

Yes

Not available

digitalCrownRotation

Not available

Not available

Not available

Yes

Not available

handGestureShortcut(_:isEnabled:) (double tap)

Yes (API only)

Yes (API only)

Yes (API only)

Yes

Not available

Always On (isLuminanceReduced, WKSupportsAlwaysOnDisplay)

Yes (environment value only)

Yes (environment value only)

Yes (environment value only)

Yes

Yes (environment value only)

WatchConnectivity (WCSession)

Yes (iPhone)

Not available (isSupported() is false)

Not available

Yes

Not available (isSupported() is false)

HKWorkoutSession and HKLiveWorkoutBuilder

New in 26

New in 26

Not available

Yes

Not available

startMirroringToCompanionDevice()

Not available

Not available

Not available

Yes

Not available

workoutSessionMirroringStartHandler

Yes

Yes

Not available

Yes

Not available

WorkoutKit WorkoutScheduler

Yes

Yes

Yes

Yes

Not available

WKExtendedRuntimeSession, scheduleBackgroundRefresh

Not available

Not available

Not available

Yes

Not available

Accessory widget families (circular, rectangular, inline)

Yes

Yes

Not available

Yes

New in 27 (circular, rectangular)

accessoryCorner

Not available

Not available

Not available

Yes

Not available

RelevanceConfiguration

Not available

Not available

Not available

New in 26

Not available

Controls (ControlWidget)

Yes (18)

Yes (18)

New in 26

New in 26

Not available

Live Activity in the Smart Stack (.small family)

Yes (declared on iPhone)

Not available

Not available

Yes (shown in the Smart Stack)

Not available

ClockKit complications

Not available

Not available

Not available

Deprecated (10)

Not available

PrivateCloudComputeLanguageModel

New in 27

New in 27

New in 27

New in 27

New in 27

SystemLanguageModel (on-device model)

New in 26

New in 26

New in 26

Not available

New in 26

Vision ClassifyImageRequest, DetectBarcodesRequest

Yes

Yes

Yes

New in 27

Yes

Requirements

  • The watch app and its widget extension need the App Groups capability with group.com.example.fieldnotes; the watch app also needs iCloud (CloudKit) with iCloud.com.example.fieldnotes. Both need a paid developer account.

  • Workouts need the HealthKit capability, the Health usage descriptions and the workout-processing background mode; the Quiet Observation timer needs the mindfulness extended runtime mode.

  • WatchConnectivity, workout mirroring and WorkoutKit scheduling need a physical iPhone and a paired Apple Watch.

  • WatchTitleSuggester needs the Private Cloud Compute managed entitlement.

  • The Action button placement of a control needs an Apple Watch Ultra.

Learning More

Apple’s SwiftUI tutorial Creating a watchOS app builds a small watch app step by step and is a good first contact with the target layout. The watchOS apps collection gathers the life-cycle, Always On and independence articles used on this page.

The next platform page, visionOS: Windows, Volumes, Immersive Spaces and RealityKit, takes FieldNotes to the opposite end of the size range.

References