Add an Apple Watch face complication that opens the app

A static WidgetKit accessory widget (launcher only — no data sharing,
App Group, or entitlements). Tapping any accessory widget opens its
containing app, so this is enough to place a Workouts button on the
watch face. Supports the circular, corner, inline, and rectangular
accessory families.

New 'Workouts Watch Widget' app-extension target embedded in the watch
app via project.yml.
This commit is contained in:
2026-06-20 22:15:26 -04:00
parent 8f69497b24
commit 192aa6f95a
5 changed files with 135 additions and 0 deletions
+2
View File
@@ -1,5 +1,7 @@
**June 2026**
Add a Workouts complication to your Apple Watch face — tap it to open the app straight from any watch face.
Prop your iPhone up during an Apple Watch workout and it now runs the same live flow side by side — Ready → work/rest → Finish with running timers — and you can drive from either device: swipe ahead, finish a set, or add one on whichever is closer, and the other follows along. Automatic moves, like a rest timer running out, advance both devices on their own.
Editing an exercise or split on iPhone now steps the Apple Watch out of that workout, showing it as "Editing on iPhone" until you're done — so the watch never keeps running an exercise whose plan you're changing.
+3
View File
@@ -29,6 +29,9 @@ your own iCloud Drive.
bidirectional: drive from either device — swipe ahead, finish a set, add one — and the
other follows. Only *human* transitions are sent; automatic ones (a rest timer ending)
advance both devices independently off shared start times, so they never fight.
- **Watch face complication** — a launcher complication you can place on any Apple
Watch face; tap it to open the app. Available in the circular, corner, inline, and
rectangular accessory slots.
- **iCloud Drive sync** — your data lives as human-readable JSON in your iCloud
Drive, synced across devices and visible in the Files app. iCloud is required.
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Workouts</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,79 @@
import SwiftUI
import WidgetKit
// A launcher complication: a static button on the watch face that opens the
// Workouts app. It carries no data, so the timeline is a single entry that
// never refreshes. Tapping any accessory widget launches its containing app,
// so no deep link or App Group is needed.
private struct LauncherEntry: TimelineEntry {
let date: Date
}
private struct LauncherProvider: TimelineProvider {
func placeholder(in context: Context) -> LauncherEntry {
LauncherEntry(date: .now)
}
func getSnapshot(in context: Context, completion: @escaping (LauncherEntry) -> Void) {
completion(LauncherEntry(date: .now))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<LauncherEntry>) -> Void) {
// Nothing ever changes one entry, never reload.
completion(Timeline(entries: [LauncherEntry(date: .now)], policy: .never))
}
}
private struct LauncherView: View {
@Environment(\.widgetFamily) private var family
private let glyph = "dumbbell.fill"
var body: some View {
switch family {
case .accessoryInline:
Label("Workouts", systemImage: glyph)
case .accessoryRectangular:
Label("Workouts", systemImage: glyph)
.font(.headline)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
case .accessoryCorner:
Image(systemName: glyph)
.font(.title2)
.widgetLabel("Workouts")
default: // .accessoryCircular and any future families
ZStack {
AccessoryWidgetBackground()
Image(systemName: glyph)
.font(.title3)
}
}
}
}
struct WorkoutsLauncherComplication: Widget {
private let kind = "WorkoutsLauncher"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: LauncherProvider()) { _ in
LauncherView()
.containerBackground(.clear, for: .widget)
}
.configurationDisplayName("Open Workouts")
.description("Tap to open the Workouts app.")
.supportedFamilies([
.accessoryCircular,
.accessoryCorner,
.accessoryInline,
.accessoryRectangular,
])
}
}
@main
struct WorkoutsWatchWidgetBundle: WidgetBundle {
var body: some Widget {
WorkoutsLauncherComplication()
}
}
+20
View File
@@ -78,6 +78,9 @@ targets:
excludes:
- "Resources/Info-*.plist"
- "Resources/*.entitlements"
dependencies:
- target: Workouts Watch Widget
embed: true
postBuildScripts:
- script: '"${SRCROOT}/Scripts/update_build_number.sh"'
name: Update Build Number
@@ -98,3 +101,20 @@ targets:
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
TARGETED_DEVICE_FAMILY: "4"
DEVELOPMENT_ASSET_PATHS: "\"Workouts Watch App/Preview Content\""
# ---- watchOS widget extension (a launcher complication for the watch face) --
Workouts Watch Widget:
type: app-extension
platform: watchOS
sources:
- path: Workouts Watch Widget
excludes:
- "Resources/Info-*.plist"
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Workouts.watchkitapp.widget
INFOPLIST_FILE: "Workouts Watch Widget/Resources/Info-WatchWidget.plist"
GENERATE_INFOPLIST_FILE: false
SWIFT_STRICT_CONCURRENCY: complete
WATCHOS_DEPLOYMENT_TARGET: "26.0"
TARGETED_DEVICE_FAMILY: "4"