Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 20 additions & 9 deletions LoopFollow/LiveActivity/LiveActivityManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,15 +136,18 @@ final class LiveActivityManager {
stateObserverTask = nil
pushToken = nil

// Signal the state observer that the upcoming .dismissed event is our own
// end() call, not a user swipe. This must be set synchronously before end()
// is awaited so the observer sees it regardless of MainActor scheduling order.
endingForRestart = true

Task {
// Await end so the activity is removed from Activity.activities before
// startIfNeeded() runs — otherwise it hits the reuse path and skips
// writing a new laRenewBy deadline.
await activity.end(nil, dismissalPolicy: .immediate)
await MainActor.run {
// Reset dismissedByUser in case the state observer fired .dismissed during
// our own end() call (before its Task cancellation took effect) and
// incorrectly set it to true — startFromCurrentState guards on this flag.
self.endingForRestart = false
self.dismissedByUser = false
// startFromCurrentState rebuilds the snapshot (showRenewalOverlay = false
// since laRenewBy is 0), saves it to the store, then calls startIfNeeded()
Expand Down Expand Up @@ -181,6 +184,11 @@ final class LiveActivityManager {
/// In-memory only — resets to false on app relaunch, so a kill + relaunch
/// starts fresh as expected.
private var dismissedByUser = false
/// Set to true immediately before we call activity.end() as part of a planned restart.
/// Cleared after the restart completes. The state observer checks this flag so that
/// a .dismissed delivery triggered by our own end() call is never misclassified as a
/// user swipe — regardless of the order in which the MainActor executes the two writes.
private var endingForRestart = false
/// Set by handleForeground() when it takes ownership of the restart sequence.
/// Prevents handleDidBecomeActive() from racing with an in-flight end+restart.
private var skipNextDidBecomeActive = false
Expand Down Expand Up @@ -640,13 +648,16 @@ final class LiveActivityManager {
}
if state == .dismissed {
// Distinguish system-initiated dismissal from a user swipe.
// iOS dismisses the activity when (a) the renewal limit was reached
// with a failed renewal, or (b) the staleDate passed and iOS decided
// to remove the activity. In both cases auto-restart is appropriate.
// Only a true user swipe (activity still fresh) should block restart.
// (a) endingForRestart: we called end() ourselves as part of a restart
// — must be checked first since handleForeground() clears
// laRenewalFailed before calling end(), so renewalFailed would
// read false even though we initiated the dismissal.
// (b) laRenewalFailed: iOS force-dismissed after 8-hour limit.
// (c) staleDatePassed: iOS removed the activity after staleDate.
// Only a true user swipe (none of the above) should block auto-restart.
let staleDatePassed = activity.content.staleDate.map { $0 <= Date() } ?? false
if Storage.shared.laRenewalFailed.value || staleDatePassed {
LogManager.shared.log(category: .general, message: "Live Activity dismissed by iOS (renewalFailed=\(Storage.shared.laRenewalFailed.value), staleDatePassed=\(staleDatePassed)) — auto-restart enabled")
if endingForRestart || Storage.shared.laRenewalFailed.value || staleDatePassed {
LogManager.shared.log(category: .general, message: "Live Activity dismissed by iOS (endingForRestart=\(endingForRestart), renewalFailed=\(Storage.shared.laRenewalFailed.value), staleDatePassed=\(staleDatePassed)) — auto-restart enabled")
} else {
// User manually swiped away the LA. Block auto-restart until
// the user explicitly restarts via button or App Intent.
Expand Down
54 changes: 25 additions & 29 deletions LoopFollowLAExtension/LoopFollowLiveActivity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -399,26 +399,19 @@ private struct DynamicIslandLeadingView: View {
.minimumScaleFactor(0.7)
} else {
VStack(alignment: .leading, spacing: 2) {
Text(LAFormat.glucose(snapshot))
.font(.system(size: 28, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.white)

HStack(spacing: 5) {
Text(LAFormat.trendArrow(snapshot))
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundStyle(.white.opacity(0.9))

Text(LAFormat.delta(snapshot))
.font(.system(size: 13, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.white.opacity(0.9))

Text("Proj: \(LAFormat.projected(snapshot))")
.font(.system(size: 13, weight: .semibold, design: .rounded))
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(LAFormat.glucose(snapshot))
.font(.system(size: 28, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.white.opacity(0.9))
.foregroundStyle(LAColors.keyline(for: snapshot))
Text(LAFormat.trendArrow(snapshot))
.font(.system(size: 22, weight: .semibold, design: .rounded))
.foregroundStyle(LAColors.keyline(for: snapshot))
}
Text("\(LAFormat.delta(snapshot)) \(snapshot.unit.displayName)")
.font(.system(size: 13, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.white.opacity(0.85))
}
}
}
Expand All @@ -431,18 +424,21 @@ private struct DynamicIslandTrailingView: View {
if snapshot.isNotLooping {
EmptyView()
} else {
VStack(alignment: .trailing, spacing: 3) {
Text("IOB \(LAFormat.iob(snapshot))")
.font(.system(size: 13, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.white.opacity(0.95))

Text("COB \(LAFormat.cob(snapshot))")
.font(.system(size: 13, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.white.opacity(0.95))
let slot = LAAppGroupSettings.smallWidgetSlot()
if slot != .none {
VStack(alignment: .trailing, spacing: 2) {
Text(slot.gridLabel)
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundStyle(.white.opacity(0.65))
Text(slotFormattedValue(option: slot, snapshot: snapshot))
.font(.system(size: 18, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.padding(.trailing, 6)
}
.padding(.trailing, 6)
}
}
}
Expand Down