Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
cf27620
feat: Live Activity — Phase 1 (lock screen + Dynamic Island, APNs sel…
MtlPhil Mar 7, 2026
59f5d2b
fix: trigger Live Activity refresh on not-looping state change; handl…
MtlPhil Mar 8, 2026
c53e17f
Fix PR issues + DST fix and better APNs error checking
MtlPhil Mar 9, 2026
25f30c0
Fix PR issues + DST fix and better APNs error checking
MtlPhil Mar 9, 2026
b833ad9
fix: address remaining hardcoded bundleID
MtlPhil Mar 10, 2026
524b3bb
Replace SwiftJWT with CryptoKit and separate APNs credentials
bjorkert Mar 11, 2026
4dcbd69
Localization refactoring
bjorkert Mar 12, 2026
63326d8
feat: Live Activity auto-renewal to work around 8-hour system limit
MtlPhil Mar 13, 2026
a020c8f
test: reduce LA renewal threshold to 20 min for testing
MtlPhil Mar 13, 2026
bae228d
feat: improve LA renewal robustness and stale indicator
MtlPhil Mar 13, 2026
2785502
feat: renewal warning overlay + restore 7.5h threshold
MtlPhil Mar 13, 2026
0250633
fix: overlay not appearing + foreground restart not working
MtlPhil Mar 13, 2026
4e48c45
test: set renewalThreshold to 20 min for testing
MtlPhil Mar 13, 2026
136dba0
fix: renewal overlay not clearing after LA is refreshed
MtlPhil Mar 13, 2026
32a6dd0
Fix Mac Catalyst build: guard ActivityKit code and exclude widget ext…
bjorkert Mar 13, 2026
921a966
fix: overlay permanently active when warning window equals threshold
MtlPhil Mar 13, 2026
8989103
fix: include showRenewalOverlay in APNs payload and clear laRenewBy s…
MtlPhil Mar 13, 2026
1ab3930
fix: await LA end before restarting on foreground retry to avoid reus…
MtlPhil Mar 13, 2026
cdd4f85
chore: restore production renewal timing (7.5h threshold, 20min warning)
MtlPhil Mar 13, 2026
e737bce
**Live Activity auto-renewal (8-hour limit workaround)** (#539)
MtlPhil Mar 13, 2026
e0a729a
feat: laEnabled toggle, forceRestart(), and RestartLiveActivityIntent
MtlPhil Mar 14, 2026
7588c93
Added RestartLiveActivityIntent to project
MtlPhil Mar 14, 2026
0c21909
fix: resolve two build errors in LiveActivityManager and RestartLiveA…
MtlPhil Mar 14, 2026
9f5ddf2
fix: guard continueInForeground() behind iOS 26 availability check
MtlPhil Mar 14, 2026
c2e4c34
fix: use startFromCurrentState in handleDidBecomeActive instead of fo…
MtlPhil Mar 14, 2026
2869d24
feat: LA foreground tab navigation, button feedback, and toggle sync
MtlPhil Mar 14, 2026
3259dcb
fix: flush LA update on willResignActive to ensure lock screen shows …
MtlPhil Mar 14, 2026
54e3ed9
feat: redesign Dynamic Island compact and expanded views
MtlPhil Mar 14, 2026
6752fb2
fix: match Proj text style to delta; add trailing padding to IOB/COB
MtlPhil Mar 14, 2026
a3a37a0
feat: separate Live Activity and APN settings into distinct menus
MtlPhil Mar 14, 2026
6f43a2c
Added Live Activity menu
MtlPhil Mar 14, 2026
48ddc77
chore: add LiveActivitySettingsView to Xcode project
MtlPhil Mar 14, 2026
fc0bafd
merge: integrate upstream live-activity (Mac Catalyst guards + renewa…
MtlPhil Mar 14, 2026
5939ed9
fix: LA tap navigation, manual dismissal prevention, and toggle start
MtlPhil Mar 14, 2026
ef3f2f5
fix: end Live Activity on app force-quit
MtlPhil Mar 14, 2026
11aeadd
fix: use dismissedByUser flag instead of disabling laEnabled on manua…
MtlPhil Mar 14, 2026
c81911c
fix: dismiss modal (Settings sheet) before tab switch on LA tap
MtlPhil Mar 14, 2026
9ccc806
fix: LA tap navigation timing and LA reappear-after-dismiss
MtlPhil Mar 15, 2026
31a8e97
fix: handle loopfollow://la-tap URL in SceneDelegate, not AppDelegate
MtlPhil Mar 15, 2026
26b244e
Live Activity — UX Improvements and Reliability Fixes (#540)
MtlPhil Mar 15, 2026
ad647e5
feat: configurable LA grid slots + full InfoType snapshot coverage
MtlPhil Mar 15, 2026
0401c48
fix: label delta and footer on lock screen LA card
MtlPhil Mar 15, 2026
f42e502
docs: add PR description for configurable LA grid slots
MtlPhil Mar 15, 2026
b8c19cf
Update PR_configurable_slots.md
MtlPhil Mar 15, 2026
9b36dab
Merge dev into live-activity; replace CryptoSwift with CryptoKit
bjorkert Mar 15, 2026
b925d8a
Merge upstream/live-activity: resolve conflicts, keep extended InfoTy…
MtlPhil Mar 15, 2026
c7c9a59
Merge remote-tracking branch 'origin/live-activity' into live-activity
MtlPhil Mar 15, 2026
b571cad
chore: remove PR notes from tracking, keep docs/LiveActivity.md only
MtlPhil Mar 15, 2026
fec3f79
Configurable Live Activity Grid Slots + Full InfoType Snapshot Covera…
MtlPhil Mar 15, 2026
83ba7c5
Linting
bjorkert Mar 15, 2026
a20f3ec
Fix PRODUCT_BUNDLE_IDENTIFIER for Tests
bjorkert Mar 15, 2026
145744c
fix: include all extended InfoType fields in APNs push payload
MtlPhil Mar 15, 2026
191a1e4
Merge upstream/live-activity: apply linting fixes
MtlPhil Mar 15, 2026
dfe53b3
feat: add small family view for CarPlay Dashboard and Watch Smart Stack
MtlPhil Mar 16, 2026
2f28a1f
fix: include all extended InfoType fields in APNs push payload (#548)
MtlPhil Mar 16, 2026
a98f0a8
fix: guard CarPlay/Watch small family behind iOS 18 availability; inc…
MtlPhil Mar 16, 2026
65e679a
fix: move if #available into Widget.body to avoid WidgetBundleBuilder…
MtlPhil Mar 16, 2026
82e76a4
fix: use two separate single-branch if #available in bundle for CarPl…
MtlPhil Mar 16, 2026
17db9e9
merge: resolve conflicts with upstream/live-activity; keep renewal ov…
MtlPhil Mar 16, 2026
0183a9d
Live Activity: CarPlay Dashboard + Apple Watch Smart Stack support (#…
MtlPhil Mar 16, 2026
98de416
fix: restore two-widget bundle; guard supplementalActivityFamilies an…
MtlPhil Mar 16, 2026
e8dadda
fix: extension version inherits from parent; remove spurious await in…
MtlPhil Mar 16, 2026
9f9229a
Live Activity: fix iOS 18 availability guards, extension version, and…
MtlPhil Mar 16, 2026
83f4ad3
fix: prevent glucose + trend arrow clipping on wide mmol/L values
MtlPhil Mar 16, 2026
426fa3d
Live Activity: fix glucose + trend arrow clipping on wide mmol/L valu…
MtlPhil Mar 17, 2026
8b9fe86
Merge branch 'dev' into live-activity
bjorkert Mar 17, 2026
e20ec46
chore: remove redundant @available(iOS 16.1) guards
MtlPhil Mar 17, 2026
775b83d
Fix Live Activity glucose overflow with flexible layout and tighter g…
MtlPhil Mar 17, 2026
37c1a71
chore: remove redundant @available(iOS 16.1) guards
MtlPhil Mar 17, 2026
d99e778
Fix Live Activity glucose overflow with flexible layout and tighter g…
MtlPhil Mar 17, 2026
68d2a06
fix: restart LA on foreground when renewal overlay is showing
MtlPhil Mar 17, 2026
749264b
fix: recover from audio session failure and alert user via LA overlay
MtlPhil Mar 17, 2026
3769275
Update BackgroundTaskAudio.swift
MtlPhil Mar 18, 2026
27a6efc
Live Activity: foreground restart on overlay, audio session recovery,…
MtlPhil Mar 18, 2026
a26894d
Update BackgroundTaskAudio.swift
MtlPhil Mar 18, 2026
e8ee805
Update BackgroundTaskAudio.swift
MtlPhil Mar 18, 2026
cffc043
Update LiveActivityManager.swift
MtlPhil Mar 18, 2026
61a6035
Update LiveActivityManager.swift
MtlPhil Mar 18, 2026
d4f5c8c
Merge branch 'pr-555' into live-activity
bjorkert Mar 18, 2026
adbec89
Linting
bjorkert Mar 18, 2026
f677b2c
Removed CLAUDE.md
bjorkert Mar 18, 2026
9e16ba9
Merge branch 'dev' into live-activity
bjorkert Mar 19, 2026
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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,6 @@ fastlane/test_output
fastlane/FastlaneRunner

LoopFollowConfigOverride.xcconfig
.history
.history*.xcuserstate
docs/PR_configurable_slots.md
docs/LiveActivityTestPlan.md
346 changes: 286 additions & 60 deletions LoopFollow.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

This file was deleted.

12 changes: 11 additions & 1 deletion LoopFollow/Application/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return true
}

func applicationWillTerminate(_: UIApplication) {}
func applicationWillTerminate(_: UIApplication) {
#if !targetEnvironment(macCatalyst)
LiveActivityManager.shared.endOnTerminate()
#endif
}

// MARK: - Remote Notifications

Expand Down Expand Up @@ -97,6 +101,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
completionHandler(.newData)
}

// MARK: - URL handling

// Note: with scene-based lifecycle (iOS 13+), URLs are delivered to
// SceneDelegate.scene(_:openURLContexts:) — not here. The scene delegate
// handles loopfollow://la-tap for Live Activity tap navigation.

// MARK: UISceneSession Lifecycle

func application(_: UIApplication, willFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
Expand Down
18 changes: 18 additions & 0 deletions LoopFollow/Application/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,24 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func sceneDidBecomeActive(_: UIScene) {
// Called when the scene has moved from an inactive state to an active state.
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
if pendingLATapNavigation {
pendingLATapNavigation = false
NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil)
}
}

/// Set when loopfollow://la-tap arrives before the scene is fully active.
/// Consumed in sceneDidBecomeActive once the view hierarchy is restored.
private var pendingLATapNavigation = false

func scene(_: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
guard URLContexts.contains(where: { $0.url.scheme == "loopfollow" && $0.url.host == "la-tap" }) else { return }
// scene(_:openURLContexts:) fires after sceneDidBecomeActive when the app
// foregrounds from background. Post on the next run loop so the view
// hierarchy (including any presented modals) is fully settled.
DispatchQueue.main.async {
NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil)
}
}

func sceneWillResignActive(_: UIScene) {
Expand Down
10 changes: 10 additions & 0 deletions LoopFollow/Controllers/Nightscout/BGData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -260,9 +260,19 @@ extension MainViewController {
Observable.shared.deltaText.value = "+" + Localizer.toDisplayUnits(String(deltaBG))
}

// Live Activity storage
Storage.shared.lastBgReadingTimeSeconds.value = lastBGTime
Storage.shared.lastDeltaMgdl.value = Double(deltaBG)
Storage.shared.lastTrendCode.value = entries[latestEntryIndex].direction

// Mark BG data as loaded for initial loading state
self.markDataLoaded("bg")

// Live Activity update
#if !targetEnvironment(macCatalyst)
LiveActivityManager.shared.refreshFromCurrentState(reason: "bg")
#endif

// Update contact
if Storage.shared.contactEnabled.value {
self.contactImageUpdater
Expand Down
10 changes: 10 additions & 0 deletions LoopFollow/Controllers/Nightscout/DeviceStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ extension MainViewController {

if IsNightscoutEnabled(), (now - lastLoopTime) >= nonLoopingTimeThreshold, lastLoopTime > 0 {
IsNotLooping = true
Observable.shared.isNotLooping.value = true
statusStackView.distribution = .fill

PredictionLabel.isHidden = true
Expand All @@ -55,9 +56,13 @@ extension MainViewController {
LoopStatusLabel.text = "⚠️ Not Looping!"
LoopStatusLabel.textColor = UIColor.systemYellow
LoopStatusLabel.font = UIFont.boldSystemFont(ofSize: 18)
#if !targetEnvironment(macCatalyst)
LiveActivityManager.shared.refreshFromCurrentState(reason: "notLooping")
#endif

} else {
IsNotLooping = false
Observable.shared.isNotLooping.value = false
statusStackView.distribution = .fillEqually
PredictionLabel.isHidden = false

Expand All @@ -72,6 +77,9 @@ extension MainViewController {
case .system:
LoopStatusLabel.textColor = UIColor.label
}
#if !targetEnvironment(macCatalyst)
LiveActivityManager.shared.refreshFromCurrentState(reason: "loopingResumed")
#endif
}
}

Expand Down Expand Up @@ -124,9 +132,11 @@ extension MainViewController {
if let reservoirData = lastPumpRecord["reservoir"] as? Double {
latestPumpVolume = reservoirData
infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U")
Storage.shared.lastPumpReservoirU.value = reservoirData
} else {
latestPumpVolume = 50.0
infoManager.updateInfoData(type: .pump, value: "50+U")
Storage.shared.lastPumpReservoirU.value = nil
}
}

Expand Down
17 changes: 17 additions & 0 deletions LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,14 @@ extension MainViewController {
let profileISF = profileManager.currentISF()
if let profileISF = profileISF {
infoManager.updateInfoData(type: .isf, value: profileISF)
Storage.shared.lastIsfMgdlPerU.value = profileISF.doubleValue(for: .milligramsPerDeciliter)
}

// Carb Ratio (CR)
let profileCR = profileManager.currentCarbRatio()
if let profileCR = profileCR {
infoManager.updateInfoData(type: .carbRatio, value: profileCR)
Storage.shared.lastCarbRatio.value = profileCR
}

// Target
Expand All @@ -47,6 +49,8 @@ extension MainViewController {
} else if let profileTargetLow = profileTargetLow {
infoManager.updateInfoData(type: .target, value: profileTargetLow)
}
Storage.shared.lastTargetLowMgdl.value = profileTargetLow?.doubleValue(for: .milligramsPerDeciliter)
Storage.shared.lastTargetHighMgdl.value = profileTargetHigh?.doubleValue(for: .milligramsPerDeciliter)

// IOB
if let insulinMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") {
Expand Down Expand Up @@ -87,6 +91,8 @@ extension MainViewController {
let formattedMax = Localizer.toDisplayUnits(String(predMax))
let value = "\(formattedMin)/\(formattedMax)"
infoManager.updateInfoData(type: .minMax, value: value)
Storage.shared.lastMinBgMgdl.value = predMin
Storage.shared.lastMaxBgMgdl.value = predMax
}

updatePredictionGraph()
Expand Down Expand Up @@ -119,6 +125,17 @@ extension MainViewController {
LoopStatusLabel.text = "↻"
latestLoopStatusString = "↻"
}

// Live Activity storage
Storage.shared.lastIOB.value = latestIOB?.value
Storage.shared.lastCOB.value = latestCOB?.value
if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject],
let values = predictdata["values"] as? [Double]
{
Storage.shared.projectedBgMgdl.value = values.last
} else {
Storage.shared.projectedBgMgdl.value = nil
}
}
}
}
20 changes: 20 additions & 0 deletions LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@ extension MainViewController {
}
if let profileISF = profileISF, let enactedISF = enactedISF, profileISF != enactedISF {
infoManager.updateInfoData(type: .isf, firstValue: profileISF, secondValue: enactedISF, separator: .arrow)
Storage.shared.lastIsfMgdlPerU.value = enactedISF.doubleValue(for: .milligramsPerDeciliter)
} else if let profileISF = profileISF {
infoManager.updateInfoData(type: .isf, value: profileISF)
Storage.shared.lastIsfMgdlPerU.value = profileISF.doubleValue(for: .milligramsPerDeciliter)
}

// Carb Ratio (CR)
Expand All @@ -57,8 +59,10 @@ extension MainViewController {

if let profileCR = profileCR, let enactedCR = enactedCR, profileCR != enactedCR {
infoManager.updateInfoData(type: .carbRatio, value: profileCR, enactedValue: enactedCR, separator: .arrow)
Storage.shared.lastCarbRatio.value = enactedCR
} else if let profileCR = profileCR {
infoManager.updateInfoData(type: .carbRatio, value: profileCR)
Storage.shared.lastCarbRatio.value = profileCR
}

// IOB
Expand Down Expand Up @@ -98,6 +102,7 @@ extension MainViewController {
if let sens = enactedOrSuggested["sensitivityRatio"] as? Double {
let formattedSens = String(format: "%.0f", sens * 100.0) + "%"
infoManager.updateInfoData(type: .autosens, value: formattedSens)
Storage.shared.lastAutosens.value = sens
}

// Recommended Bolus
Expand Down Expand Up @@ -136,11 +141,19 @@ extension MainViewController {
} else {
infoManager.updateInfoData(type: .target, value: profileTargetHigh)
}
let effectiveMgdl = enactedTarget.doubleValue(for: .milligramsPerDeciliter)
Storage.shared.lastTargetLowMgdl.value = effectiveMgdl
Storage.shared.lastTargetHighMgdl.value = effectiveMgdl
} else if let profileTargetHigh = profileTargetHigh {
let profileMgdl = profileTargetHigh.doubleValue(for: .milligramsPerDeciliter)
Storage.shared.lastTargetLowMgdl.value = profileMgdl
Storage.shared.lastTargetHighMgdl.value = profileMgdl
}

// TDD
if let tddMetric = InsulinMetric(from: enactedOrSuggested, key: "TDD") {
infoManager.updateInfoData(type: .tdd, value: tddMetric)
Storage.shared.lastTdd.value = tddMetric.value
}

let predBGsData: [String: AnyObject]? = {
Expand Down Expand Up @@ -201,6 +214,8 @@ extension MainViewController {
if minPredBG != Double.infinity, maxPredBG != -Double.infinity {
let value = "\(Localizer.toDisplayUnits(String(minPredBG)))/\(Localizer.toDisplayUnits(String(maxPredBG)))"
infoManager.updateInfoData(type: .minMax, value: value)
Storage.shared.lastMinBgMgdl.value = minPredBG
Storage.shared.lastMaxBgMgdl.value = maxPredBG
} else {
infoManager.updateInfoData(type: .minMax, value: "N/A")
}
Expand All @@ -224,6 +239,11 @@ extension MainViewController {
LoopStatusLabel.text = "↻"
latestLoopStatusString = "↻"
}

// Live Activity storage
Storage.shared.lastIOB.value = latestIOB?.value
Storage.shared.lastCOB.value = latestCOB?.value
Storage.shared.projectedBgMgdl.value = nil
}
}
}
1 change: 1 addition & 0 deletions LoopFollow/Controllers/Nightscout/IAge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ extension MainViewController {
.withColonSeparatorInTime]

if let iageTime = formatter.date(from: (lastIageString as! String))?.timeIntervalSince1970 {
Storage.shared.iageInsertTime.value = iageTime
let now = dateTimeUtils.getNowTimeIntervalUTC()
let secondsAgo = now - iageTime

Expand Down
1 change: 1 addition & 0 deletions LoopFollow/Controllers/Nightscout/Profile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ extension MainViewController {
}
profileManager.loadProfile(from: profileData)
infoManager.updateInfoData(type: .profile, value: profileData.defaultProfile)
Storage.shared.lastProfileName.value = profileData.defaultProfile

// Mark profile data as loaded for initial loading state
markDataLoaded("profile")
Expand Down
1 change: 1 addition & 0 deletions LoopFollow/Controllers/Nightscout/Treatments/Basals.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,5 +147,6 @@ extension MainViewController {
latestBasal = "\(profileBasal) → \(latestBasal)"
}
infoManager.updateInfoData(type: .basal, value: latestBasal)
Storage.shared.lastBasal.value = latestBasal
}
}
1 change: 1 addition & 0 deletions LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,6 @@ extension MainViewController {

let resultString = String(format: "%.0f", totalCarbs)
infoManager.updateInfoData(type: .carbsToday, value: resultString)
Storage.shared.lastCarbsToday.value = totalCarbs
}
}
Loading