diff --git a/Workouts Widget/Resources/Info-Widget.plist b/Workouts Widget/Resources/Info-Widget.plist new file mode 100644 index 0000000..d50bcac --- /dev/null +++ b/Workouts Widget/Resources/Info-Widget.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Workouts + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/Workouts Widget/WorkoutActivityAttributes.swift b/Workouts Widget/WorkoutActivityAttributes.swift new file mode 100644 index 0000000..54b2df4 --- /dev/null +++ b/Workouts Widget/WorkoutActivityAttributes.swift @@ -0,0 +1,82 @@ +// +// WorkoutActivityAttributes.swift +// Workouts (shared: iOS app + Workouts Widget) +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import ActivityKit +import Foundation + +/// The ActivityKit attributes backing the active-workout Live Activity (lock screen + +/// Dynamic Island). This file is compiled into **both** the iOS app (which starts / updates / +/// ends the activity via `WorkoutLiveActivityController`) and the `Workouts Widget` extension +/// (which renders it). It intentionally depends on nothing but ActivityKit + Foundation, so it +/// can live in the widget target without pulling in the app's model/sync layer. +/// +/// The whole design is **push-free**: every content state carries wall-clock anchors +/// (`phaseStart` / `phaseEnd`), so the widget renders live countdowns with +/// `Text(timerInterval:)` / `ProgressView(timerInterval:)` and never needs a server or a +/// background update to keep ticking — the same wall-clock-anchored trick the in-app run flow +/// and the watch mirror already use. +struct WorkoutActivityAttributes: ActivityAttributes { + /// The dynamic part of the activity — mirrors one page of the Ready → Work → Rest → Finish + /// run flow. Refreshed by the app on every phase / set transition while it's foregrounded. + struct ContentState: Codable, Hashable, Sendable { + /// Which page of the run flow the driving device is on. + enum Phase: String, Codable, Hashable, Sendable { + case ready, work, rest, done + } + + var exerciseName: String + var phase: Phase + + /// 0-based set this frame pertains to, and the planned total. + var setIndex: Int + var setCount: Int + + /// Footer line under the timer, e.g. "8 reps" or "30 sec". + var detail: String + + /// Wall-clock anchor for the phase's timer. A count-up work set counts up from here; a + /// count-down phase (rest / timed work / auto-Done) runs from here to `phaseEnd`. + var phaseStart: Date + + /// End anchor for a count-down phase; `nil` for a rep-based work set (which counts up) + /// or the Ready lead-in (which shows no timer). + var phaseEnd: Date? + + // MARK: Derived (shared by every widget presentation) + + /// 1-based set number for display. + var setNumber: Int { setIndex + 1 } + + /// A running count-down is present (rest, timed work, or the auto-Done deadline). + var isCountingDown: Bool { phaseEnd != nil } + + /// A rep-based work set that counts up (no end anchor). + var isCountingUp: Bool { phase == .work && phaseEnd == nil } + + /// Short phase word for the compact/expanded labels. + var phaseTitle: String { + switch phase { + case .ready: return "Ready" + case .work: return "Work" + case .rest: return "Rest" + case .done: return "Done" + } + } + + /// One-line status under the exercise name, e.g. "Rest · Set 2 of 4" or + /// "Set 2 of 4 · 8 reps". + var statusLine: String { + let sets = "Set \(setNumber) of \(setCount)" + switch phase { + case .ready: return "Ready · \(sets)" + case .work: return detail.isEmpty ? sets : "\(sets) · \(detail)" + case .rest: return "Rest · \(sets)" + case .done: return "Complete" + } + } + } +} diff --git a/Workouts Widget/WorkoutLiveActivity.swift b/Workouts Widget/WorkoutLiveActivity.swift new file mode 100644 index 0000000..d65d790 --- /dev/null +++ b/Workouts Widget/WorkoutLiveActivity.swift @@ -0,0 +1,180 @@ +// +// WorkoutLiveActivity.swift +// Workouts Widget +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import ActivityKit +import SwiftUI +import WidgetKit + +/// The active-workout Live Activity: a lock-screen / banner presentation plus the Dynamic +/// Island (compact, minimal, expanded). Every timer renders straight off the content state's +/// wall-clock anchors via `Text(timerInterval:)` / `ProgressView(timerInterval:)`, so the +/// countdowns stay live while the phone is locked with **no push updates**. +/// +/// Backgrounds are left to the system (lock-screen material / Dynamic Island black), so light +/// and dark both look right without hardcoding a fill. Phase tints echo the in-app run flow — +/// brand purple for work, teal for rest. +struct WorkoutLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: WorkoutActivityAttributes.self) { context in + LockScreenView(state: context.state) + .activitySystemActionForegroundColor(.primary) + } dynamicIsland: { context in + let state = context.state + return DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Label(state.phaseTitle, systemImage: Glyph.exercise) + .font(.caption.weight(.semibold)) + .foregroundStyle(state.phase.tint) + .padding(.leading, 4) + } + DynamicIslandExpandedRegion(.trailing) { + TimerLabel(state: state) + .font(.system(.title3, design: .rounded, weight: .semibold)) + .foregroundStyle(state.phase.tint) + .frame(maxWidth: 96, alignment: .trailing) + .padding(.trailing, 4) + } + DynamicIslandExpandedRegion(.bottom) { + VStack(alignment: .leading, spacing: 4) { + Text(state.exerciseName) + .font(.headline) + .lineLimit(1) + Text(state.statusLine) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + if let end = state.phaseEnd { + ProgressView(timerInterval: state.phaseStart...end, countsDown: true) { + EmptyView() + } currentValueLabel: { + EmptyView() + } + .tint(state.phase.tint) + } + } + .padding(.top, 2) + } + } compactLeading: { + Image(systemName: Glyph.exercise) + .foregroundStyle(state.phase.tint) + } compactTrailing: { + if let end = state.phaseEnd { + CountdownText(start: state.phaseStart, end: end) + .foregroundStyle(state.phase.tint) + .frame(maxWidth: 52) + } else { + Text("\(state.setNumber)/\(state.setCount)") + .monospacedDigit() + } + } minimal: { + if let end = state.phaseEnd { + CountdownText(start: state.phaseStart, end: end) + .foregroundStyle(state.phase.tint) + .frame(maxWidth: 52) + } else { + Image(systemName: Glyph.exercise) + .foregroundStyle(state.phase.tint) + } + } + .keylineTint(state.phase.tint) + } + } +} + +// MARK: - Lock screen / banner + +private struct LockScreenView: View { + let state: WorkoutActivityAttributes.ContentState + + var body: some View { + HStack(alignment: .center, spacing: 14) { + Image(systemName: Glyph.exercise) + .font(.title2) + .foregroundStyle(state.phase.tint) + .frame(width: 30) + + VStack(alignment: .leading, spacing: 4) { + Text(state.exerciseName) + .font(.headline) + .lineLimit(1) + Text(state.statusLine) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + if let end = state.phaseEnd { + ProgressView(timerInterval: state.phaseStart...end, countsDown: true) { + EmptyView() + } currentValueLabel: { + EmptyView() + } + .tint(state.phase.tint) + .padding(.top, 1) + } + } + + Spacer(minLength: 8) + + TimerLabel(state: state) + .font(.system(.title, design: .rounded, weight: .semibold)) + .foregroundStyle(state.phase.tint) + } + .padding() + } +} + +// MARK: - Timer helpers + +/// Picks the right live timer for the phase: a count-down for rest / timed work / auto-Done, a +/// stopwatch count-up for a rep-based work set, a checkmark for the terminal Done state, and +/// nothing for the Ready lead-in. +private struct TimerLabel: View { + let state: WorkoutActivityAttributes.ContentState + + var body: some View { + if let end = state.phaseEnd { + CountdownText(start: state.phaseStart, end: end) + } else if state.phase == .work { + Text(state.phaseStart, style: .timer) + .monospacedDigit() + } else if state.phase == .done { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } + } +} + +/// A self-updating count-down rendered off wall-clock anchors — no push needed. +private struct CountdownText: View { + let start: Date + let end: Date + + var body: some View { + Text(timerInterval: start...end, countsDown: true) + .monospacedDigit() + .multilineTextAlignment(.trailing) + } +} + +// MARK: - Styling + +private enum Glyph { + static let exercise = "figure.strengthtraining.traditional" +} + +private extension WorkoutActivityAttributes.ContentState.Phase { + /// Phase tint echoing the in-app run flow (brand purple for work, teal for rest). Kept + /// local to the widget so the extension stays self-contained; values mirror + /// `Color.workTint` / `Color.restTint` in `Shared/Utils/Color+Extensions.swift`. + var tint: Color { + switch self { + case .ready: return Color(red: 0.51, green: 0.22, blue: 0.84) + case .work: return Color(red: 0.51, green: 0.22, blue: 0.84) + case .rest: return Color(red: 0.44, green: 0.85, blue: 0.84) + case .done: return .green + } + } +} diff --git a/Workouts Widget/WorkoutsWidgetBundle.swift b/Workouts Widget/WorkoutsWidgetBundle.swift new file mode 100644 index 0000000..6f8ea45 --- /dev/null +++ b/Workouts Widget/WorkoutsWidgetBundle.swift @@ -0,0 +1,18 @@ +// +// WorkoutsWidgetBundle.swift +// Workouts Widget +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI +import WidgetKit + +/// The iOS widget extension's entry point. It currently hosts only the active-workout Live +/// Activity; ordinary home/lock-screen widgets can be added to the bundle later. +@main +struct WorkoutsWidgetBundle: WidgetBundle { + var body: some Widget { + WorkoutLiveActivity() + } +} diff --git a/Workouts/AppServices.swift b/Workouts/AppServices.swift index d41512c..b3fa85e 100644 --- a/Workouts/AppServices.swift +++ b/Workouts/AppServices.swift @@ -14,6 +14,10 @@ final class AppServices { let workoutLauncher = WorkoutLauncher() let workoutHealthDeleter = WorkoutHealthDeleter() + /// Owns the active-workout Live Activity (lock screen + Dynamic Island). Driven by the run + /// flow's `LiveProgress` frames, so locking the phone mid-set doesn't lose the timer. + let liveActivity = WorkoutLiveActivityController() + /// Ephemeral live-run state fed by the watch, observed by the mirror UI. Not persisted. let liveRunState: LiveRunState @@ -37,6 +41,8 @@ final class AppServices { if let bootstrapTask { await bootstrapTask.value; return } let task = Task { @MainActor [weak self] in guard let self else { return } + // Clear any Live Activity stranded by a previous launch (app killed mid-run). + await self.liveActivity.endStaleActivities() await self.syncEngine.connect() self.watchBridge.activate() // Past the iCloud gate: request the workout-share scope so the user can still diff --git a/Workouts/LiveActivity/WorkoutLiveActivityController.swift b/Workouts/LiveActivity/WorkoutLiveActivityController.swift new file mode 100644 index 0000000..4d298b5 --- /dev/null +++ b/Workouts/LiveActivity/WorkoutLiveActivityController.swift @@ -0,0 +1,174 @@ +// +// WorkoutLiveActivityController.swift +// Workouts +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import ActivityKit +import Foundation +import os + +/// Owns the lifecycle of the active-workout Live Activity (lock screen + Dynamic Island). +/// +/// It is driven by the **same** run-flow frames the phone↔watch mirror uses (`LiveProgress`): +/// `ExerciseProgressView` hands us one on every phase / set settle, we translate it to a +/// `WorkoutActivityAttributes.ContentState`, and start or update the activity. Because the +/// content carries wall-clock anchors, the widget's countdowns keep ticking while the phone is +/// locked without any push update from here. +/// +/// Lifecycle: +/// • **Start** — the first non-Ready frame of a run spins the activity up. +/// • **Update** — every subsequent frame refreshes it (idempotent). +/// • **End** — leaving the run flow tears it down; a completed run lingers briefly on a +/// terminal "Complete" state before auto-dismissing. +/// +/// Degrades silently when Live Activities are disabled (`areActivitiesEnabled`). +/// +/// Concurrency: the public entry points are **synchronous**, so `currentLogID` / `lastState` +/// (both `Sendable`) mutate in call order on the main actor. The async ActivityKit I/O is fired +/// off to detached tasks that resolve the live activity from the `nonisolated` +/// `Activity.activities` static — a *disconnected* value that crosses into the task cleanly, +/// unlike a non-`Sendable` `Activity` stashed in a main-actor-isolated property. The run flow is +/// per-exercise, so there's only ever one of our activities live at a time. +@MainActor +final class WorkoutLiveActivityController { + private static let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "live-activity") + + private typealias State = WorkoutActivityAttributes.ContentState + private typealias Content = ActivityContent + + /// The run the live activity belongs to, so a frame for a *different* run replaces it, and a + /// `nil` means nothing is showing. + private var currentLogID: String? + /// The most recent content state, so `end()` can render a terminal "Complete" card without + /// reaching back into the (non-`Sendable`) activity handle. + private var lastState: State? + + private var isEnabled: Bool { ActivityAuthorizationInfo().areActivitiesEnabled } + + // MARK: - Update + + /// Reflect the given run frame on the Live Activity — starting one if needed. Fired on every + /// settle (human swipe, auto-advance, or a followed watch frame), so it must be idempotent. + func update(_ frame: LiveProgress) { + guard isEnabled else { return } + + // A frame for a run other than the one we're showing — drop the stale activity first. + if let current = currentLogID, current != frame.logID { + reset() + } + + // Swiping back to the Ready page resets the exercise — nothing to show anymore. + guard frame.phase != .ready else { + if currentLogID != nil { reset() } + return + } + + let state = Self.contentState(from: frame) + lastState = state + let content = Content(state: state, staleDate: nil) + + if currentLogID == nil { + // Don't spin one up just for the Ready lead-in; this is the first real frame. + currentLogID = frame.logID + start(content) + } else { + pushUpdate(content) + } + } + + private func start(_ content: Content) { + do { + _ = try Activity.request( + attributes: WorkoutActivityAttributes(), + content: content, + pushType: nil + ) + } catch { + Self.log.error("Live Activity start failed: \(error, privacy: .public)") + currentLogID = nil + lastState = nil + } + } + + // MARK: - End + + /// Leave the run flow. If the run reached its terminal Done state, linger briefly on a + /// "Complete" card before dismissing; otherwise dismiss at once. + func end() { + guard let state = lastState else { return } + currentLogID = nil + lastState = nil + + if state.phase == .done { + var terminal = state + terminal.phaseEnd = nil // stop the auto-Done countdown; show the finished state + endAll(final: Content(state: terminal, staleDate: nil), + dismissal: .after(Date().addingTimeInterval(4))) + } else { + endAll(final: nil, dismissal: .immediate) + } + } + + /// Immediately tear down whatever is showing (a run switch or a Ready reset). + private func reset() { + currentLogID = nil + lastState = nil + endAll(final: nil, dismissal: .immediate) + } + + /// Dismiss any activity still on screen from a previous launch (e.g. the app was killed + /// mid-run). Called defensively at bootstrap. + func endStaleActivities() async { + currentLogID = nil + lastState = nil + for stale in Activity.activities { + await stale.end(nil, dismissalPolicy: .immediate) + } + } + + // MARK: - Fire-and-forget ActivityKit I/O + + /// Push a new content state to the live activity. `content` is `Sendable`; the activity is + /// resolved inside the task from the nonisolated `activities` list, so nothing non-`Sendable` + /// crosses the main-actor boundary. + private nonisolated func pushUpdate(_ content: Content) { + Task { + for activity in Activity.activities { + await activity.update(content) + } + } + } + + /// End every live activity of our type, optionally showing a terminal `final` state first. + private nonisolated func endAll(final content: Content?, dismissal: ActivityUIDismissalPolicy) { + Task { + for activity in Activity.activities { + await activity.end(content, dismissalPolicy: dismissal) + } + } + } + + // MARK: - Helpers + + /// Translate a run-flow frame into a widget content state (mapping `.finish` → `.done`). + private static func contentState(from frame: LiveProgress) -> State { + let phase: State.Phase + switch frame.phase { + case .ready: phase = .ready + case .work: phase = .work + case .rest: phase = .rest + case .finish: phase = .done + } + return State( + exerciseName: frame.exerciseName, + phase: phase, + setIndex: frame.setIndex, + setCount: frame.setCount, + detail: frame.detail, + phaseStart: frame.phaseStart, + phaseEnd: frame.phaseEnd + ) + } +} diff --git a/Workouts/Resources/Info-iOS.plist b/Workouts/Resources/Info-iOS.plist index 0d5e061..b6bf3bd 100644 --- a/Workouts/Resources/Info-iOS.plist +++ b/Workouts/Resources/Info-iOS.plist @@ -22,6 +22,8 @@ $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption + NSSupportsLiveActivities + LSRequiresIPhoneOS NSHealthShareUsageDescription diff --git a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift index 357c53d..db7bea6 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift @@ -45,6 +45,15 @@ struct ExerciseProgressView: View { let onLive: (LiveProgress) -> Void let onLiveEnded: () -> Void + /// Reflects the run's current position onto the iOS Live Activity (lock screen + Dynamic + /// Island). Unlike `onLive` (watch, *human* transitions only), this fires on **every** page + /// settle — human, auto-advance, or a followed watch frame — plus the initial page, since the + /// Live Activity must track the current phase and countdown no matter who drove it. + /// `onActivityEnded` fires when the flow is left, so the activity can be dismissed. Both + /// default to no-ops, so the in-list driver only wires them where an activity is wanted. + let onActivity: (LiveProgress) -> Void + let onActivityEnded: () -> Void + /// The latest live-run frame the *watch* sent for this run, to follow when it drives a /// transition (ephemeral; nil when the watch isn't driving). Applying it jumps our page /// without re-broadcasting or re-recording — the originating device owns the durable write. @@ -96,12 +105,14 @@ struct ExerciseProgressView: View { /// `startsCompleted`. @State private var startsSkipped: Bool - init(doc: Binding, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil) { + init(doc: Binding, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, onActivity: @escaping (LiveProgress) -> Void = { _ in }, onActivityEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil) { self._doc = doc self.logID = logID self.onChange = onChange self.onLive = onLive self.onLiveEnded = onLiveEnded + self.onActivity = onActivity + self.onActivityEnded = onActivityEnded self.incomingFrame = incomingFrame let log = doc.wrappedValue.logs.first { $0.id == logID } @@ -266,6 +277,9 @@ struct ExerciseProgressView: View { } broadcastLive(for: newPage) } + // The Live Activity tracks every settle (all causes), not just human ones — a rest + // that auto-advances or a page the watch drove must still refresh the lock screen. + emitActivity(for: newPage) } .onChange(of: incomingFrame) { _, frame in if let frame { applyIncoming(frame) } @@ -288,8 +302,10 @@ struct ExerciseProgressView: View { } } .onDisappear { - // Leaving the flow (back / done) — stop the watch from following. + // Leaving the flow (back / done) — stop the watch from following and dismiss the + // Live Activity (it lingers briefly on a completed run before auto-dismissing). onLiveEnded() + onActivityEnded() } } @@ -314,6 +330,21 @@ struct ExerciseProgressView: View { } else { broadcastLive(for: currentPage) } + // Seed the Live Activity with the opening page (a resumed run starts it here; a + // not-started run sits on Ready, which the controller ignores until the run begins). + emitActivity(for: currentPage) + } + + /// Reflect the current flow position onto the Live Activity. Built from the same + /// `liveSnapshot` the watch broadcast uses, but honoring the remote anchors when the page was + /// reached by a watch frame, so the lock-screen countdown lines up with the mirror. + private func emitActivity(for page: Int) { + guard var snapshot = liveSnapshot(for: page) else { return } + if page == remoteAnchorPage { + if let start = remoteAnchorStart { snapshot.phaseStart = start } + snapshot.phaseEnd = remoteAnchorEnd + } + onActivity(snapshot) } /// Push the current flow position to the watch. The anchor is stamped *now* — the page diff --git a/Workouts/Views/WorkoutLogs/LiveRunCoverView.swift b/Workouts/Views/WorkoutLogs/LiveRunCoverView.swift index 725f8b7..2417995 100644 --- a/Workouts/Views/WorkoutLogs/LiveRunCoverView.swift +++ b/Workouts/Views/WorkoutLogs/LiveRunCoverView.swift @@ -63,6 +63,8 @@ struct LiveRunCoverView: View { onLiveEnded: { services.watchBridge.sendLiveEnded(workoutID: frame.workoutID, logID: frame.logID) }, + onActivity: { services.liveActivity.update($0) }, + onActivityEnded: { services.liveActivity.end() }, incomingFrame: incomingFrame ) } else { diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift index bb6dce8..d6318fa 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift @@ -246,6 +246,8 @@ struct WorkoutLogListView: View { onChange: { save() }, onLive: { services.watchBridge.sendLiveProgress($0) }, onLiveEnded: { services.watchBridge.sendLiveEnded(workoutID: doc.id, logID: logID) }, + onActivity: { services.liveActivity.update($0) }, + onActivityEnded: { services.liveActivity.end() }, incomingFrame: liveRun.current.flatMap { $0.logID == logID ? $0 : nil } ) .onAppear { liveRun.navigatedRunID = logID } diff --git a/project.yml b/project.yml index c51e74f..1d808e7 100644 --- a/project.yml +++ b/project.yml @@ -23,6 +23,19 @@ packages: url: https://git.rzen.dev/rzen/indie-sync.git from: "0.3.0" +# Shared post-build phase — stamps CFBundleVersion (git commit count), BuildDate, and +# BuildHash into each target's (and dSYM's) Info.plist. Aliased into every code-bearing +# target below so the app, watch app, and both widget extensions stamp identical metadata. +scriptAnchors: + updateBuildInfo: &updateBuildInfo + - script: '"${SRCROOT}/../indie-skills/skills/app-versioning/scripts/update_build_info.sh"' + name: Update Build Info + shell: /bin/sh + basedOnDependencyAnalysis: false + inputFiles: + - $(TARGET_BUILD_DIR)/$(INFOPLIST_PATH) + - $(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Info.plist + targets: # ---- iOS app (owns iCloud Drive sync; embeds the watch app) ---------------- Workouts: @@ -43,18 +56,16 @@ targets: - path: LICENSE.md buildPhase: resources type: file + # The Live Activity attributes are shared with the widget extension (which owns the + # folder); the app compiles just this one file so it can start / update / end the activity. + - path: "Workouts Widget/WorkoutActivityAttributes.swift" dependencies: - package: IndieAbout - package: IndieSync - target: Workouts Watch App - postBuildScripts: - - script: '"${SRCROOT}/../indie-skills/skills/app-versioning/scripts/update_build_info.sh"' - name: Update Build Info - shell: /bin/sh - basedOnDependencyAnalysis: false - inputFiles: - - $(TARGET_BUILD_DIR)/$(INFOPLIST_PATH) - - $(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Info.plist + - target: Workouts Widget + embed: true + postBuildScripts: *updateBuildInfo settings: base: PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Workouts @@ -105,14 +116,7 @@ targets: - package: IndieSync - target: Workouts Watch Widget embed: true - postBuildScripts: - - script: '"${SRCROOT}/../indie-skills/skills/app-versioning/scripts/update_build_info.sh"' - name: Update Build Info - shell: /bin/sh - basedOnDependencyAnalysis: false - inputFiles: - - $(TARGET_BUILD_DIR)/$(INFOPLIST_PATH) - - $(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Info.plist + postBuildScripts: *updateBuildInfo settings: base: PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Workouts.watchkitapp @@ -153,6 +157,7 @@ targets: - path: Workouts Watch Widget excludes: - "Resources/Info-*.plist" + postBuildScripts: *updateBuildInfo settings: base: PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Workouts.watchkitapp.widget @@ -161,3 +166,21 @@ targets: SWIFT_STRICT_CONCURRENCY: complete WATCHOS_DEPLOYMENT_TARGET: "26.0" TARGETED_DEVICE_FAMILY: "4" + + # ---- iOS widget extension (the active-workout Live Activity: lock screen + Dynamic Island) -- + Workouts Widget: + type: app-extension + platform: iOS + sources: + - path: Workouts Widget + excludes: + - "Resources/Info-*.plist" + postBuildScripts: *updateBuildInfo + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Workouts.widget + INFOPLIST_FILE: "Workouts Widget/Resources/Info-Widget.plist" + GENERATE_INFOPLIST_FILE: false + SWIFT_STRICT_CONCURRENCY: complete + IPHONEOS_DEPLOYMENT_TARGET: "26.0" + TARGETED_DEVICE_FAMILY: "1"