Explorar o código

Remove pre-iOS 18 Live Activity code paths (#711)

* Remove pre-iOS 18 Live Activity code paths

With the deployment target now at iOS 18, delete the legacy fallbacks and
availability guards that only ran on older systems:

- LiveActivityManager: remove startIfNeededLegacy and attemptLegacyRenewal
  (the Activity.request() start/renewal paths), unwrap the iOS 17.2
  push-to-start guards, and drop the now-redundant @available attributes.
- LoopFollowLiveActivity: collapse the dual ActivityConfiguration into the
  family-adaptive layout and inline contentMargins.
- StorageCurrentGlucoseStateProvider: the renewal overlay is driven solely
  by laRenewalFailed.
- EKEventStore: request full calendar access directly.
- RestartLiveActivityIntent: drop the redundant iOS 16.4 availability
  attributes.

Also delete the orphaned duplicate LoopFollow/LiveActivity/RestartLiveActivityIntent.swift,
which was not referenced by any target and shadowed the maintained root copy.

* Tighten Live Activity comments to describe end state
Jonas Björkert hai 1 semana
pai
achega
05bd6bd06a

+ 2 - 8
LoopFollow/Extensions/EKEventStore+Extensions.swift

@@ -6,14 +6,8 @@ import Foundation
 
 extension EKEventStore {
     func requestCalendarAccess(completion: @escaping (Bool, Error?) -> Void) {
-        if #available(iOS 17, *) {
-            requestFullAccessToEvents { granted, error in
-                completion(granted, error)
-            }
-        } else {
-            requestAccess(to: .event) { granted, error in
-                completion(granted, error)
-            }
+        requestFullAccessToEvents { granted, error in
+            completion(granted, error)
         }
     }
 }

+ 51 - 235
LoopFollow/LiveActivity/LiveActivityManager.swift

@@ -10,19 +10,8 @@ import os
 import UIKit
 import UserNotifications
 
-// Live Activity manager for LoopFollow.
-//
-// iOS 17.2+:        every LA creation (initial start, renewal, forced
-//                   restart) goes through APNs push-to-start. Updates
-//                   ride the same APNs transport. One transport, one
-//                   credential failure mode that surfaces in settings.
-//
-// iOS 16.6 – 17.1:  legacy Activity.request() for everything;
-//                   renewal-failed notification when backgrounded.
-//                   The entry-point `if #available(iOS 17.2, *)` checks
-//                   isolate every iOS 17.2 code path, so the legacy
-//                   helpers can be deleted in one commit when the
-//                   deployment target reaches 17.2.
+// Live Activity manager for LoopFollow. Every LA creation (start, renewal,
+// restart) and update goes through APNs push-to-start.
 
 final class LiveActivityManager {
     static let shared = LiveActivityManager()
@@ -55,50 +44,42 @@ final class LiveActivityManager {
         startActivityUpdatesObservation()
     }
 
-    // MARK: - Push-to-start observation (iOS 17.2+)
+    // MARK: - Push-to-start observation
 
-    /// Observes the type-level push-to-start token (iOS 17.2+) and persists it.
+    /// Observes the type-level push-to-start token and persists it.
     /// The token survives app relaunches but is reissued by iOS periodically or when
     /// the user toggles LA permissions — each new delivery overwrites the stored value.
     private func startPushToStartTokenObservation() {
-        if #available(iOS 17.2, *) {
-            pushToStartObservationTask?.cancel()
-            LogManager.shared.log(
-                category: .general,
-                message: "[LA] pushToStartTokenUpdates observation starting (iOS 17.2+)"
-            )
-            pushToStartObservationTask = Task {
-                var deliveries = 0
-                for await tokenData in Activity<GlucoseLiveActivityAttributes>.pushToStartTokenUpdates {
-                    deliveries += 1
-                    let token = tokenData.map { String(format: "%02x", $0) }.joined()
-                    let previousTail = Storage.shared.laPushToStartToken.value.isEmpty
-                        ? "nil"
-                        : String(Storage.shared.laPushToStartToken.value.suffix(8))
-                    let tail = String(token.suffix(8))
-                    let changed = tail != previousTail
-                    Storage.shared.laPushToStartToken.value = token
-                    LogManager.shared.log(
-                        category: .general,
-                        message: "[LA] push-to-start token received #\(deliveries) token=…\(tail) (prev=…\(previousTail))\(changed ? " CHANGED" : " same")"
-                    )
-                }
+        pushToStartObservationTask?.cancel()
+        LogManager.shared.log(
+            category: .general,
+            message: "[LA] pushToStartTokenUpdates observation starting"
+        )
+        pushToStartObservationTask = Task {
+            var deliveries = 0
+            for await tokenData in Activity<GlucoseLiveActivityAttributes>.pushToStartTokenUpdates {
+                deliveries += 1
+                let token = tokenData.map { String(format: "%02x", $0) }.joined()
+                let previousTail = Storage.shared.laPushToStartToken.value.isEmpty
+                    ? "nil"
+                    : String(Storage.shared.laPushToStartToken.value.suffix(8))
+                let tail = String(token.suffix(8))
+                let changed = tail != previousTail
+                Storage.shared.laPushToStartToken.value = token
                 LogManager.shared.log(
                     category: .general,
-                    message: "[LA] pushToStartTokenUpdates stream ended after \(deliveries) deliveries — no further tokens will arrive"
+                    message: "[LA] push-to-start token received #\(deliveries) token=…\(tail) (prev=…\(previousTail))\(changed ? " CHANGED" : " same")"
                 )
             }
-        } else {
             LogManager.shared.log(
                 category: .general,
-                message: "[LA] pushToStartTokenUpdates unavailable (iOS <17.2) — push-to-start will never fire"
+                message: "[LA] pushToStartTokenUpdates stream ended after \(deliveries) deliveries — no further tokens will arrive"
             )
         }
     }
 
-    /// Observes new Activity creations. When an activity is started by
-    /// push-to-start (iOS 17.2+), the app discovers it through this stream and
-    /// adopts it via the same bind/update path as an app-initiated start.
+    /// Observes new Activity creations so push-to-start activities are adopted
+    /// via the same bind/update path as an app-initiated start.
     private func startActivityUpdatesObservation() {
         activityUpdatesObservationTask?.cancel()
         LogManager.shared.log(
@@ -471,7 +452,7 @@ final class LiveActivityManager {
     /// The actual end+restart is run from handleDidBecomeActive() because
     /// Activity.request() returns `visibility` during willEnterForeground.
     private var pendingForegroundRestart = false
-    /// Observes `pushToStartTokenUpdates` (iOS 17.2+) and persists the token.
+    /// Observes `pushToStartTokenUpdates` and persists the token.
     /// Long-lived — started once at init and never cancelled.
     private var pushToStartObservationTask: Task<Void, Never>?
     /// Observes `Activity<>.activityUpdates` so activities started out-of-band
@@ -515,118 +496,28 @@ final class LiveActivityManager {
         let startReason = nextStartReasonOverride ?? "user-start"
         nextStartReasonOverride = nil
 
-        if #available(iOS 17.2, *) {
-            // iOS 17.2+ uses push-to-start for every creation path. If an
-            // activity is already running and not stale we adopt/reuse it
-            // (covers warm starts where the LA survived a relaunch); only
-            // truly new starts dispatch APNs.
-            if let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
-                let renewBy = Storage.shared.laRenewBy.value
-                let now = Date().timeIntervalSince1970
-                let staleDatePassed = existing.content.staleDate.map { $0 <= Date() } ?? false
-                let inRenewalWindow = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
-                let needsRestart = Storage.shared.laRenewalFailed.value || inRenewalWindow || staleDatePassed
-                if !needsRestart {
-                    bind(to: existing, logReason: "reuse")
-                    Storage.shared.laRenewalFailed.value = false
-                    return
-                }
-                LogManager.shared.log(
-                    category: .general,
-                    message: "[LA] existing activity is stale on startIfNeeded (iOS 17.2+) — push-to-start replace (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))"
-                )
-                attemptPushToStartCreate(reason: startReason, oldActivity: existing)
-                return
-            }
-            attemptPushToStartCreate(reason: startReason, oldActivity: nil)
-        } else {
-            startIfNeededLegacy()
-        }
-    }
-
-    /// Pre-17.2 path (iOS 16.6 – 17.1). Identical to dev's `startIfNeeded` —
-    /// Activity.request() for everything. Removable when the deployment target
-    /// reaches 17.2.
-    @MainActor
-    private func startIfNeededLegacy() {
+        // Push-to-start is used for every creation path. If an activity is
+        // already running and not stale we adopt/reuse it (covers warm starts
+        // where the LA survived a relaunch); only truly new starts dispatch APNs.
         if let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
-            // Before reusing, check whether this activity needs a restart. This covers cold
-            // starts (app was killed while the overlay was showing — willEnterForeground is
-            // never sent, so handleForeground never runs) and any other path that lands here
-            // without first going through handleForeground.
             let renewBy = Storage.shared.laRenewBy.value
             let now = Date().timeIntervalSince1970
             let staleDatePassed = existing.content.staleDate.map { $0 <= Date() } ?? false
             let inRenewalWindow = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
             let needsRestart = Storage.shared.laRenewalFailed.value || inRenewalWindow || staleDatePassed
-
-            if needsRestart {
-                LogManager.shared.log(
-                    category: .general,
-                    message: "[LA] existing activity is stale on startIfNeeded — ending and restarting (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))"
-                )
-
-                endingForRestart = true
-                dismissedByUser = false
-
-                Storage.shared.laRenewBy.value = 0
+            if !needsRestart {
+                bind(to: existing, logReason: "reuse")
                 Storage.shared.laRenewalFailed.value = false
-                cancelRenewalFailedNotification()
-
-                Task {
-                    await existing.end(nil, dismissalPolicy: .immediate)
-                    await MainActor.run { self.startIfNeededLegacy() }
-                }
                 return
             }
-
-            bind(to: existing, logReason: "reuse")
-            Storage.shared.laRenewalFailed.value = false
-            return
-        }
-
-        do {
-            let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow")
-
-            let provider = StorageCurrentGlucoseStateProvider()
-            let seedSnapshot = GlucoseSnapshotBuilder.build(from: provider)
-                ?? GlucoseSnapshotStore.shared.load()
-                ?? GlucoseSnapshot(
-                    glucose: 0,
-                    delta: 0,
-                    trend: .unknown,
-                    updatedAt: Date(),
-                    iob: nil,
-                    cob: nil,
-                    projected: nil,
-                    unit: .mgdl,
-                    isNotLooping: false,
-                )
-
-            let initialState = GlucoseLiveActivityAttributes.ContentState(
-                snapshot: seedSnapshot,
-                seq: 0,
-                reason: "start",
-                producedAt: Date(),
-            )
-
-            let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)
-            let content = ActivityContent(state: initialState, staleDate: renewDeadline)
-            LALivenessStore.clear()
-            let activity = try Activity.request(attributes: attributes, content: content, pushType: .token)
-
-            bind(to: activity, logReason: "start-new")
-            Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970
-            Storage.shared.laRenewalFailed.value = false
-            LogManager.shared.log(category: .general, message: "Live Activity started id=\(activity.id)")
-        } catch {
-            let ns = error as NSError
-            let scene = isAppVisibleForLiveActivityStart()
             LogManager.shared.log(
                 category: .general,
-                message: "Live Activity failed to start: \(error) domain=\(ns.domain) code=\(ns.code) — authorized=\(ActivityAuthorizationInfo().areActivitiesEnabled), sceneActive=\(scene), activities=\(Activity<GlucoseLiveActivityAttributes>.activities.count)"
+                message: "[LA] existing activity is stale on startIfNeeded — push-to-start replace (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))"
             )
+            attemptPushToStartCreate(reason: startReason, oldActivity: existing)
+            return
         }
+        attemptPushToStartCreate(reason: startReason, oldActivity: nil)
     }
 
     /// Called from applicationWillTerminate. Ends the LA synchronously (blocking
@@ -771,102 +662,31 @@ final class LiveActivityManager {
         let overdueBy = Date().timeIntervalSince1970 - renewBy
         LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed by \(Int(overdueBy))s, requesting new LA")
 
-        if #available(iOS 17.2, *) {
-            // iOS 17.2+: renewal goes through push-to-start. The dispatch hops
-            // to MainActor and returns immediately; adoption (or failure) lands
-            // in the observer. Return true so performRefresh stops processing
-            // this tick.
-            Task { @MainActor [weak self] in
-                self?.attemptPushToStartCreate(reason: "renew", oldActivity: oldActivity, snapshot: snapshot)
-            }
-            return true
-        } else {
-            return attemptLegacyRenewal(snapshot: snapshot, oldActivity: oldActivity)
-        }
-    }
-
-    /// Pre-17.2 renewal (iOS 16.6 – 17.1): foreground Activity.request, mark
-    /// renewal-failed if it throws. Removable when the deployment target
-    /// reaches 17.2.
-    private func attemptLegacyRenewal(
-        snapshot: GlucoseSnapshot,
-        oldActivity: Activity<GlucoseLiveActivityAttributes>
-    ) -> Bool {
-        let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)
-        let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow")
-
-        // Build the fresh snapshot with showRenewalOverlay: false — the new LA has a
-        // fresh deadline so no overlay is needed from the first frame. We pass the
-        // deadline as staleDate to ActivityContent below, not to Storage yet; Storage
-        // is only updated after Activity.request succeeds so a crash between the two
-        // can't leave the deadline permanently stuck in the future.
-        let freshSnapshot = snapshot.withRenewalOverlay(false)
-
-        let state = GlucoseLiveActivityAttributes.ContentState(
-            snapshot: freshSnapshot,
-            seq: seq,
-            reason: "renew",
-            producedAt: Date(),
-        )
-        let content = ActivityContent(state: state, staleDate: renewDeadline)
-
-        do {
-            let newActivity = try Activity.request(attributes: attributes, content: content, pushType: .token)
-
-            Task {
-                await oldActivity.end(nil, dismissalPolicy: .immediate)
-            }
-
-            updateTask?.cancel()
-            updateTask = nil
-            tokenObservationTask?.cancel()
-            tokenObservationTask = nil
-            stateObserverTask?.cancel()
-            stateObserverTask = nil
-            pushToken = nil
-
-            // Write deadline only on success — avoids a stuck future deadline if we crash
-            // between the write and the Activity.request call.
-            Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970
-            bind(to: newActivity, logReason: "renew")
-            Storage.shared.laRenewalFailed.value = false
-            cancelRenewalFailedNotification()
-            GlucoseSnapshotStore.shared.save(freshSnapshot)
-            LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully id=\(newActivity.id)")
-            return true
-        } catch {
-            // Renewal failed — deadline was never written, so no rollback needed.
-            let isFirstFailure = !Storage.shared.laRenewalFailed.value
-            Storage.shared.laRenewalFailed.value = true
-            let ns = error as NSError
-            LogManager.shared.log(
-                category: .general,
-                message: "[LA] renewal failed, keeping existing LA: \(error) domain=\(ns.domain) code=\(ns.code) — authorized=\(ActivityAuthorizationInfo().areActivitiesEnabled), activities=\(Activity<GlucoseLiveActivityAttributes>.activities.count)"
-            )
-            if isFirstFailure {
-                scheduleRenewalFailedNotification()
-            }
-            return false
+        // Renewal goes through push-to-start. The dispatch hops to MainActor
+        // and returns immediately; adoption (or failure) lands in the observer.
+        // Return true so performRefresh stops processing this tick.
+        Task { @MainActor [weak self] in
+            self?.attemptPushToStartCreate(reason: "renew", oldActivity: oldActivity, snapshot: snapshot)
         }
+        return true
     }
 
-    // MARK: - Push-to-start (iOS 17.2+)
+    // MARK: - Push-to-start
 
-    /// Single creation path for iOS 17.2+. Handles initial start, renewal, and
-    /// forced restart. Verifies token + APNs credentials, applies backoff, then
-    /// dispatches the APNs push-to-start call. The old activity is only ended
-    /// after a confirmed successful send, preserving it if the send fails.
-    /// Adoption is delivered via the `activityUpdates` observer —
-    /// `handlePushToStartResult` only updates backoff/state.
-    @available(iOS 17.2, *)
+    /// Single creation path. Handles initial start, renewal, and forced restart.
+    /// Verifies token + APNs credentials, applies backoff, then dispatches the
+    /// APNs push-to-start call. The old activity is only ended after a confirmed
+    /// successful send, preserving it if the send fails. Adoption is delivered
+    /// via the `activityUpdates` observer — `handlePushToStartResult` only
+    /// updates backoff/state.
     @MainActor
     private func attemptPushToStartCreate(
         reason: String,
         oldActivity: Activity<GlucoseLiveActivityAttributes>?,
         snapshot: GlucoseSnapshot? = nil
     ) {
-        // Validate APNs credentials up-front — push-to-start is the ONLY transport
-        // on iOS 17.2+, so missing/invalid creds mean the LA will never display.
+        // Validate APNs credentials up-front — push-to-start is the only
+        // transport, so missing/invalid creds mean the LA will never display.
         let keyId = Storage.shared.lfKeyId.value
         let apnsKey = Storage.shared.lfApnsKey.value
         guard APNsCredentialValidator.isFullyConfigured(keyId: keyId, apnsKey: apnsKey) else {
@@ -919,7 +739,6 @@ final class LiveActivityManager {
         }
     }
 
-    @available(iOS 17.2, *)
     private func dispatchPushToStart(
         reason: String,
         oldActivity: Activity<GlucoseLiveActivityAttributes>?,
@@ -1019,7 +838,6 @@ final class LiveActivityManager {
         }
     }
 
-    @available(iOS 17.2, *)
     @MainActor
     private func handlePushToStartResult(
         _ result: APNSClient.PushToStartResult,
@@ -1306,10 +1124,8 @@ final class LiveActivityManager {
         // bind-existing path rebinds to the just-ended activity — clearing
         // endingForRestart and turning the eventual iOS dismissal into a misclassified
         // user swipe. Drive the restart synchronously instead.
-        if #available(iOS 17.2, *) {
-            Task { @MainActor [weak self] in
-                self?.attemptPushToStartCreate(reason: "expired-token", oldActivity: nil)
-            }
+        Task { @MainActor [weak self] in
+            self?.attemptPushToStartCreate(reason: "expired-token", oldActivity: nil)
         }
     }
 

+ 0 - 39
LoopFollow/LiveActivity/RestartLiveActivityIntent.swift

@@ -1,39 +0,0 @@
-// LoopFollow
-// RestartLiveActivityIntent.swift
-
-import AppIntents
-import UIKit
-
-struct RestartLiveActivityIntent: AppIntent {
-    static var title: LocalizedStringResource = "Restart Live Activity"
-    static var description = IntentDescription("Starts or restarts the LoopFollow Live Activity.")
-
-    func perform() async throws -> some IntentResult & ProvidesDialog {
-        Storage.shared.laEnabled.value = true
-
-        let keyId = Storage.shared.lfKeyId.value
-        let apnsKey = Storage.shared.lfApnsKey.value
-
-        if keyId.isEmpty || apnsKey.isEmpty {
-            if let url = URL(string: "\(AppGroupID.urlScheme)://settings/live-activity") {
-                await MainActor.run { UIApplication.shared.open(url) }
-            }
-            return .result(dialog: "Please enter your APNs credentials in LoopFollow settings to use the Live Activity.")
-        }
-
-        await MainActor.run { LiveActivityManager.shared.forceRestart() }
-
-        return .result(dialog: "Live Activity restarted.")
-    }
-}
-
-struct LoopFollowAppShortcuts: AppShortcutsProvider {
-    static var appShortcuts: [AppShortcut] {
-        AppShortcut(
-            intent: RestartLiveActivityIntent(),
-            phrases: ["Restart Live Activity in \(.applicationName)"],
-            shortTitle: "Restart Live Activity",
-            systemImageName: "dot.radiowaves.left.and.right",
-        )
-    }
-}

+ 4 - 13
LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift

@@ -134,19 +134,10 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding {
         #if targetEnvironment(macCatalyst)
             return false
         #else
-            // iOS 17.2+ renews silently via push-to-start at the deadline, so the
-            // pre-emptive 30-minute "tap to update" overlay would be misleading
-            // during normal operation. Only show it once renewal has actually
-            // failed (no token, bad creds, rate-limited) — that is genuinely
-            // user-actionable. iOS 16.x keeps the time-based warning because
-            // renewal there requires the user to foreground the app.
-            if #available(iOS 17.2, *) {
-                return Storage.shared.laRenewalFailed.value
-            } else {
-                let renewBy = Storage.shared.laRenewBy.value
-                let now = Date().timeIntervalSince1970
-                return renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
-            }
+            // Push-to-start renews silently at the deadline, so only show the
+            // "tap to update" overlay once renewal has actually failed (no token,
+            // bad creds, rate-limited) — that is genuinely user-actionable.
+            return Storage.shared.laRenewalFailed.value
         #endif
     }
 }

+ 17 - 52
LoopFollowLAExtension/LoopFollowLiveActivity.swift

@@ -44,60 +44,27 @@ private func makeDynamicIsland(context: ActivityViewContext<GlucoseLiveActivityA
 
 // MARK: - Live Activity widget
 
-/// Single widget for all supported OS versions.
-/// - iOS 18+: enables supplemental `.small` family and routes via `LockScreenFamilyAdaptiveView`.
-/// - iOS 16.1–17.x: uses the regular lock screen view.
-@available(iOSApplicationExtension 16.1, *)
+/// Single widget for the Live Activity. Enables the supplemental `.small` family
+/// (CarPlay Dashboard / Watch Smart Stack) and routes the lock screen layout via
+/// `LockScreenFamilyAdaptiveView`.
 struct LoopFollowLiveActivityWidget: Widget {
     var body: some WidgetConfiguration {
-        if #available(iOSApplicationExtension 18.0, *) {
-            return ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in
-                LockScreenFamilyAdaptiveView(state: context.state)
-                    .id(context.state.seq)
-                    .background(
-                        LALivenessMarker(
-                            seq: context.state.seq,
-                            producedAt: context.state.producedAt
-                        )
+        ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in
+            LockScreenFamilyAdaptiveView(state: context.state)
+                .id(context.state.seq)
+                .background(
+                    LALivenessMarker(
+                        seq: context.state.seq,
+                        producedAt: context.state.producedAt
                     )
-                    .activitySystemActionForegroundColor(.white)
-                    .applyActivityContentMarginsFixIfAvailable()
-                    .widgetURL(URL(string: "\(AppGroupID.urlScheme)://la-tap")!)
-            } dynamicIsland: { context in
-                makeDynamicIsland(context: context)
-            }
-            .supplementalActivityFamilies([.small])
-        } else {
-            return ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in
-                LockScreenLiveActivityView(state: context.state)
-                    .id(context.state.seq)
-                    .background(
-                        LALivenessMarker(
-                            seq: context.state.seq,
-                            producedAt: context.state.producedAt
-                        )
-                    )
-                    .activitySystemActionForegroundColor(.white)
-                    .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot))
-                    .applyActivityContentMarginsFixIfAvailable()
-                    .widgetURL(URL(string: "\(AppGroupID.urlScheme)://la-tap")!)
-            } dynamicIsland: { context in
-                makeDynamicIsland(context: context)
-            }
-        }
-    }
-}
-
-// MARK: - Live Activity content margins helper
-
-private extension View {
-    @ViewBuilder
-    func applyActivityContentMarginsFixIfAvailable() -> some View {
-        if #available(iOS 17.0, *) {
-            contentMargins(Edge.Set.all, 0)
-        } else {
-            self
+                )
+                .activitySystemActionForegroundColor(.white)
+                .contentMargins(.all, 0)
+                .widgetURL(URL(string: "\(AppGroupID.urlScheme)://la-tap")!)
+        } dynamicIsland: { context in
+            makeDynamicIsland(context: context)
         }
+        .supplementalActivityFamilies([.small])
     }
 }
 
@@ -106,7 +73,6 @@ private extension View {
 /// Reads the activityFamily environment value and routes to the appropriate layout.
 /// - `.small` → CarPlay Dashboard & Watch Smart Stack
 /// - everything else → full lock screen layout
-@available(iOS 18.0, *)
 private struct LockScreenFamilyAdaptiveView: View {
     let state: GlucoseLiveActivityAttributes.ContentState
 
@@ -125,7 +91,6 @@ private struct LockScreenFamilyAdaptiveView: View {
 
 // MARK: - Small family view (CarPlay Dashboard + Watch Smart Stack)
 
-@available(iOS 18.0, *)
 private struct SmallFamilyView: View {
     let snapshot: GlucoseSnapshot
 

+ 0 - 2
RestartLiveActivityIntent.swift

@@ -5,7 +5,6 @@
     import AppIntents
     import UIKit
 
-    @available(iOS 16.4, *)
     struct RestartLiveActivityIntent: AppIntent, ForegroundContinuableIntent {
         static var title: LocalizedStringResource = "Restart Live Activity"
         static var description = IntentDescription("Starts or restarts the LoopFollow Live Activity.")
@@ -33,7 +32,6 @@
         }
     }
 
-    @available(iOS 16.4, *)
     struct LoopFollowAppShortcuts: AppShortcutsProvider {
         static var appShortcuts: [AppShortcut] {
             AppShortcut(