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 existingWKApplicationDelegateAdaptor, 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 inFieldNotesWatchWidgets, next to the existingFieldTripRelevanceWidget.
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 |
|---|---|---|
|
|
The iPhone side of |
|
|
|
|
|
|
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>
See Apple Developer Documentation — Setting up a watchOS project and Apple Developer Documentation — WKBackgroundModes.
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 ( |
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 ( |
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)
}
}
}
}
See Apple Developer Documentation — WKApplicationDelegate and Apple Developer Documentation — WKWatchConnectivityRefreshBackgroundTask.
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)
}
}
See Apple Developer Documentation — verticalPage and Apple Developer Documentation -- containerBackground(_:for:).
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
}
}
}
See Apple Developer Documentation -- digitalCrownRotation(_:from:through:by:sensitivity:isContinuous:isHapticFeedbackEnabled:) and Apple Developer Documentation -- handGestureShortcut(_:isEnabled:).
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
}
}
See Apple Developer Documentation — Designing your app for the Always On state and Apple Developer Documentation — isLuminanceReduced.
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()
}
}
}
See Apple Developer Documentation — WKNotificationScene and Apple Developer Documentation — WKUserNotificationHostingController.
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 |
|---|---|---|---|
|
Immediately, only while the counterpart is reachable; a message from the watch wakes the iPhone app in the background. Fails with an error otherwise. |
|
|
|
Queued; every dictionary is delivered, in order, even if the counterpart isn’t running |
|
|
|
Queued; only the latest dictionary is kept, older ones are overwritten |
|
|
|
Queued in the background, for files and a small metadata dictionary |
|
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
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
See Apple Developer Documentation — WCSession and Apple Developer Documentation — WCSessionDelegate.
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
}
}
}
See Apple Developer Documentation -- sendMessage(_:replyHandler:errorHandler:) and Apple Developer Documentation -- transferUserInfo(_:).
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 }
}
}
See Apple Developer Documentation -- transferFile(_:metadata:) and Apple Developer Documentation — WCSessionFile.
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
See Apple Developer Documentation -- startMirroringToCompanionDevice(completion:), Apple Developer Documentation — workoutSessionMirroringStartHandler and Apple Developer Documentation — Building a multidevice workout app.
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
See Apple Developer Documentation — WorkoutScheduler and Apple Developer Documentation — WorkoutKit.
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 ( |
At a time near the requested date, a few times an hour at most, with a small budget |
|
WatchConnectivity background delivery |
When iPhone sends a context, user info or file while the app is suspended |
The |
|
For as long as a self care, mindfulness, physical therapy or alarm session lasts |
|
|
For the whole workout |
|
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)") }
}
See Apple Developer Documentation — scheduleBackgroundRefresh(withPreferredDate:userInfo:scheduledCompletion:) and Apple Developer Documentation — WKExtendedRuntimeSession.
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
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
See Apple Developer Documentation — TimelineProvider, Apple Developer Documentation — TimelineEntryRelevance and Apple Developer Documentation — accessoryCorner.
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 }
}
See Apple Developer Documentation — Migrating ClockKit complications to WidgetKit and Apple Developer Documentation — CLKComplicationWidgetMigrator.
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
See Apple Developer Documentation — PrivateCloudComputeLanguageModel and Apple Developer Documentation — Adding server-side intelligence with Private Cloud Compute.
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 |
|---|---|---|---|---|---|
|
Not available |
Not available |
Not available |
Yes |
Not available |
|
Not available |
Not available |
Not available |
Deprecated (targets of 9.2 and later) |
Not available |
|
Not available |
Not available |
Not available |
Yes |
Not available |
|
Not available |
Not available |
Not available |
Yes |
Not available |
|
Yes (API only) |
Yes (API only) |
Yes (API only) |
Yes |
Not available |
Always On ( |
Yes (environment value only) |
Yes (environment value only) |
Yes (environment value only) |
Yes |
Yes (environment value only) |
WatchConnectivity ( |
Yes (iPhone) |
Not available ( |
Not available |
Yes |
Not available ( |
|
New in 26 |
New in 26 |
Not available |
Yes |
Not available |
|
Not available |
Not available |
Not available |
Yes |
Not available |
|
Yes |
Yes |
Not available |
Yes |
Not available |
WorkoutKit |
Yes |
Yes |
Yes |
Yes |
Not available |
|
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) |
|
Not available |
Not available |
Not available |
Yes |
Not available |
|
Not available |
Not available |
Not available |
New in 26 |
Not available |
Controls ( |
Yes (18) |
Yes (18) |
New in 26 |
New in 26 |
Not available |
Live Activity in the Smart Stack ( |
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 |
|
New in 27 |
New in 27 |
New in 27 |
New in 27 |
New in 27 |
|
New in 26 |
New in 26 |
New in 26 |
Not available |
New in 26 |
Vision |
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) withiCloud.com.example.fieldnotes. Both need a paid developer account. -
Workouts need the HealthKit capability, the Health usage descriptions and the
workout-processingbackground mode; the Quiet Observation timer needs themindfulnessextended runtime mode. -
WatchConnectivity, workout mirroring and WorkoutKit scheduling need a physical iPhone and a paired Apple Watch.
-
WatchTitleSuggesterneeds 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.
See Apple Developer Documentation — Creating a watchOS app and Apple Developer Documentation — watchOS apps.
The next platform page, visionOS: Windows, Volumes, Immersive Spaces and RealityKit, takes FieldNotes to the opposite end of the size range.
References
-
Apple Developer Documentation — Setting up a watchOS project
-
Apple Developer Documentation — Creating independent watchOS apps
-
Apple Developer Documentation — Designing your app for the Always On state
-
Apple Developer Documentation — WKApplicationDelegateAdaptor
-
Apple Developer Documentation — WKWatchConnectivityRefreshBackgroundTask
-
Apple Developer Documentation -- containerBackground(_:for:)
-
Apple Developer Documentation -- handGestureShortcut(_:isEnabled:)
-
Apple Developer Documentation — WKUserNotificationHostingController
-
Apple Developer Documentation — Transferring data with Watch Connectivity
-
Apple Developer Documentation -- sendMessage(_:replyHandler:errorHandler:)
-
Apple Developer Documentation -- updateApplicationContext(_:)
-
Apple Developer Documentation -- session(_:didReceiveMessage:replyHandler:)
-
Apple Developer Documentation -- transferCurrentComplicationUserInfo(_:)
-
Apple Developer Documentation — startMirroringToCompanionDevice(completion:)
-
Apple Developer Documentation — workoutSessionMirroringStartHandler
-
Apple Developer Documentation — Building a multidevice workout app
-
Apple Developer Documentation — Creating accessory widgets and watch complications
-
Apple Developer Documentation — Increasing the visibility of widgets in Smart Stacks
-
Apple Developer Documentation — Migrating ClockKit complications to WidgetKit
-
Apple Developer Documentation — CLKComplicationWidgetMigrator
-
Apple Developer Documentation — PrivateCloudComputeLanguageModel
-
Apple Developer Documentation — Adding server-side intelligence with Private Cloud Compute