Skip to content

Commit 6268dbb

Browse files
authored
Live activity (#537)
Co-author: @MtlPhil
1 parent b5b5139 commit 6268dbb

56 files changed

Lines changed: 4544 additions & 485 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,11 @@ fastlane/test_output
8080
fastlane/FastlaneRunner
8181

8282
LoopFollowConfigOverride.xcconfig
83-
.history
83+
.history*.xcuserstate
84+
docs/PR_configurable_slots.md
85+
docs/LiveActivityTestPlan.md
86+
87+
# Claude
88+
CLAUDE.md
89+
90+
node_modules/

LoopFollow.xcodeproj/project.pbxproj

Lines changed: 292 additions & 51 deletions
Large diffs are not rendered by default.

LoopFollow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 0 additions & 96 deletions
This file was deleted.

LoopFollow/Application/AppDelegate.swift

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import EventKit
66
import UIKit
77
import UserNotifications
88

9-
@UIApplicationMain
9+
@main
1010
class AppDelegate: UIResponder, UIApplicationDelegate {
1111
var window: UIWindow?
1212
let notificationCenter = UNUserNotificationCenter.current()
@@ -32,7 +32,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
3232
}
3333

3434
let action = UNNotificationAction(identifier: "OPEN_APP_ACTION", title: "Open App", options: .foreground)
35-
let category = UNNotificationCategory(identifier: "loopfollow.background.alert", actions: [action], intentIdentifiers: [], options: [])
35+
let category = UNNotificationCategory(identifier: BackgroundAlertIdentifier.categoryIdentifier, actions: [action], intentIdentifiers: [], options: [])
3636
UNUserNotificationCenter.current().setNotificationCategories([category])
3737

3838
UNUserNotificationCenter.current().delegate = self
@@ -45,50 +45,64 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
4545
DispatchQueue.main.async {
4646
UIApplication.shared.registerForRemoteNotifications()
4747
}
48+
49+
BackgroundRefreshManager.shared.register()
50+
51+
// Detect Before-First-Unlock launch. If protected data is unavailable here,
52+
// StorageValues were cached from encrypted UserDefaults and need a reload
53+
// on the first foreground after the user unlocks.
54+
let bfu = !UIApplication.shared.isProtectedDataAvailable
55+
Storage.shared.needsBFUReload = bfu
56+
LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(!bfu), needsBFUReload=\(bfu)")
57+
4858
return true
4959
}
5060

51-
func applicationWillTerminate(_: UIApplication) {}
61+
func applicationWillTerminate(_: UIApplication) {
62+
#if !targetEnvironment(macCatalyst)
63+
LiveActivityManager.shared.endOnTerminate()
64+
#endif
65+
}
5266

5367
// MARK: - Remote Notifications
5468

55-
// Called when successfully registered for remote notifications
69+
/// Called when successfully registered for remote notifications
5670
func application(_: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
5771
let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
5872

5973
Observable.shared.loopFollowDeviceToken.value = tokenString
6074

61-
LogManager.shared.log(category: .general, message: "Successfully registered for remote notifications with token: \(tokenString)")
75+
LogManager.shared.log(category: .apns, message: "Successfully registered for remote notifications with token: \(tokenString)")
6276
}
6377

64-
// Called when failed to register for remote notifications
78+
/// Called when failed to register for remote notifications
6579
func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
66-
LogManager.shared.log(category: .general, message: "Failed to register for remote notifications: \(error.localizedDescription)")
80+
LogManager.shared.log(category: .apns, message: "Failed to register for remote notifications: \(error.localizedDescription)")
6781
}
6882

69-
// Called when a remote notification is received
83+
/// Called when a remote notification is received
7084
func application(_: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
71-
LogManager.shared.log(category: .general, message: "Received remote notification: \(userInfo)")
85+
LogManager.shared.log(category: .apns, message: "Received remote notification: \(userInfo)")
7286

7387
// Check if this is a response notification from Loop or Trio
7488
if let aps = userInfo["aps"] as? [String: Any] {
7589
// Handle visible notification (alert, sound, badge)
7690
if let alert = aps["alert"] as? [String: Any] {
7791
let title = alert["title"] as? String ?? ""
7892
let body = alert["body"] as? String ?? ""
79-
LogManager.shared.log(category: .general, message: "Notification - Title: \(title), Body: \(body)")
93+
LogManager.shared.log(category: .apns, message: "Notification - Title: \(title), Body: \(body)")
8094
}
8195

8296
// Handle silent notification (content-available)
8397
if let contentAvailable = aps["content-available"] as? Int, contentAvailable == 1 {
8498
// This is a silent push, nothing implemented but logging for now
8599

86100
if let commandStatus = userInfo["command_status"] as? String {
87-
LogManager.shared.log(category: .general, message: "Command status: \(commandStatus)")
101+
LogManager.shared.log(category: .apns, message: "Command status: \(commandStatus)")
88102
}
89103

90104
if let commandType = userInfo["command_type"] as? String {
91-
LogManager.shared.log(category: .general, message: "Command type: \(commandType)")
105+
LogManager.shared.log(category: .apns, message: "Command type: \(commandType)")
92106
}
93107
}
94108
}
@@ -97,6 +111,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
97111
completionHandler(.newData)
98112
}
99113

114+
// MARK: - URL handling
115+
116+
// Note: with scene-based lifecycle (iOS 13+), URLs are delivered to
117+
// SceneDelegate.scene(_:openURLContexts:) — not here. The scene delegate
118+
// handles <urlScheme>://la-tap for Live Activity tap navigation.
119+
100120
// MARK: UISceneSession Lifecycle
101121

102122
func application(_: UIApplication, willFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
@@ -110,7 +130,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
110130
func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration {
111131
// Called when a new scene session is being created.
112132
// Use this method to select a configuration to create the new scene with.
113-
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
133+
UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
114134
}
115135

116136
func application(_: UIApplication, didDiscardSceneSessions _: Set<UISceneSession>) {
@@ -166,7 +186,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
166186

167187
func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
168188
if response.actionIdentifier == "OPEN_APP_ACTION" {
169-
if let window = window {
189+
if let window {
170190
window.rootViewController?.dismiss(animated: true, completion: nil)
171191
window.rootViewController?.present(MainViewController(), animated: true, completion: nil)
172192
}

LoopFollow/Application/SceneDelegate.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,16 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
3434
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
3535
}
3636

37+
func scene(_: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
38+
guard URLContexts.contains(where: { $0.url.scheme == AppGroupID.urlScheme && $0.url.host == "la-tap" }) else { return }
39+
// scene(_:openURLContexts:) fires after sceneDidBecomeActive when the app
40+
// foregrounds from background. Post on the next run loop so the view
41+
// hierarchy (including any presented modals) is fully settled.
42+
DispatchQueue.main.async {
43+
NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil)
44+
}
45+
}
46+
3747
func sceneWillResignActive(_: UIScene) {
3848
// Called when the scene will move from an active state to an inactive state.
3949
// This may occur due to temporary interruptions (ex. an incoming phone call).
@@ -53,7 +63,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
5363
(UIApplication.shared.delegate as? AppDelegate)?.saveContext()
5464
}
5565

56-
// Handle the UIApplicationShortcutItem when the user taps on the Home Screen Quick Action. This function toggles the "Speak BG" setting in UserDefaultsRepository, speaks the current state (on/off) using AVSpeechSynthesizer, and updates the Quick Action appearance.
66+
/// Handle the UIApplicationShortcutItem when the user taps on the Home Screen Quick Action. This function toggles the "Speak BG" setting in UserDefaultsRepository, speaks the current state (on/off) using AVSpeechSynthesizer, and updates the Quick Action appearance.
5767
func handleShortcutItem(_ shortcutItem: UIApplicationShortcutItem) {
5868
if let bundleIdentifier = Bundle.main.bundleIdentifier {
5969
let expectedType = bundleIdentifier + ".toggleSpeakBG"
@@ -66,7 +76,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
6676
}
6777
}
6878

69-
// The following method is called when the user taps on the Home Screen Quick Action
79+
/// The following method is called when the user taps on the Home Screen Quick Action
7080
func windowScene(_: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler _: @escaping (Bool) -> Void) {
7181
handleShortcutItem(shortcutItem)
7282
}

LoopFollow/Controllers/BackgroundAlertManager.swift

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,24 @@ enum BackgroundAlertDuration: TimeInterval, CaseIterable {
1111
case eighteenMinutes = 1080 // 18 minutes in seconds
1212
}
1313

14-
/// Enum representing unique identifiers for each background alert.
15-
enum BackgroundAlertIdentifier: String, CaseIterable {
16-
case sixMin = "loopfollow.background.alert.6min"
17-
case twelveMin = "loopfollow.background.alert.12min"
18-
case eighteenMin = "loopfollow.background.alert.18min"
14+
/// Unique identifiers for each background alert, scoped to the current bundle
15+
/// so multiple LoopFollow instances don't interfere with each other's notifications.
16+
enum BackgroundAlertIdentifier: CaseIterable {
17+
case sixMin
18+
case twelveMin
19+
case eighteenMin
20+
21+
private static let prefix = Bundle.main.bundleIdentifier ?? "loopfollow"
22+
23+
var rawValue: String {
24+
switch self {
25+
case .sixMin: "\(Self.prefix).background.alert.6min"
26+
case .twelveMin: "\(Self.prefix).background.alert.12min"
27+
case .eighteenMin: "\(Self.prefix).background.alert.18min"
28+
}
29+
}
30+
31+
static let categoryIdentifier = "\(prefix).background.alert"
1932
}
2033

2134
class BackgroundAlertManager {
@@ -118,7 +131,7 @@ class BackgroundAlertManager {
118131
content.title = title
119132
content.body = body
120133
content.sound = .defaultCritical
121-
content.categoryIdentifier = "loopfollow.background.alert"
134+
content.categoryIdentifier = BackgroundAlertIdentifier.categoryIdentifier
122135
return content
123136
}
124137

LoopFollow/Controllers/Nightscout/BGData.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,9 +260,19 @@ extension MainViewController {
260260
Observable.shared.deltaText.value = "+" + Localizer.toDisplayUnits(String(deltaBG))
261261
}
262262

263+
// Live Activity storage
264+
Storage.shared.lastBgReadingTimeSeconds.value = lastBGTime
265+
Storage.shared.lastDeltaMgdl.value = Double(deltaBG)
266+
Storage.shared.lastTrendCode.value = entries[latestEntryIndex].direction
267+
263268
// Mark BG data as loaded for initial loading state
264269
self.markDataLoaded("bg")
265270

271+
// Live Activity update
272+
#if !targetEnvironment(macCatalyst)
273+
LiveActivityManager.shared.refreshFromCurrentState(reason: "bg")
274+
#endif
275+
266276
// Update contact
267277
if Storage.shared.contactEnabled.value {
268278
self.contactImageUpdater

LoopFollow/Controllers/Nightscout/DeviceStatus.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ extension MainViewController {
4646

4747
if IsNightscoutEnabled(), (now - lastLoopTime) >= nonLoopingTimeThreshold, lastLoopTime > 0 {
4848
IsNotLooping = true
49+
Observable.shared.isNotLooping.value = true
4950
statusStackView.distribution = .fill
5051

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

5963
} else {
6064
IsNotLooping = false
65+
Observable.shared.isNotLooping.value = false
6166
statusStackView.distribution = .fillEqually
6267
PredictionLabel.isHidden = false
6368

@@ -72,6 +77,9 @@ extension MainViewController {
7277
case .system:
7378
LoopStatusLabel.textColor = UIColor.label
7479
}
80+
#if !targetEnvironment(macCatalyst)
81+
LiveActivityManager.shared.refreshFromCurrentState(reason: "loopingResumed")
82+
#endif
7583
}
7684
}
7785

@@ -120,14 +128,17 @@ extension MainViewController {
120128
let storedTime = Observable.shared.alertLastLoopTime.value ?? 0
121129
if lastPumpTime > storedTime {
122130
Observable.shared.alertLastLoopTime.value = lastPumpTime
131+
Storage.shared.lastLoopTime.value = lastPumpTime
123132
}
124133

125134
if let reservoirData = lastPumpRecord["reservoir"] as? Double {
126135
latestPumpVolume = reservoirData
127136
infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U")
137+
Storage.shared.lastPumpReservoirU.value = reservoirData
128138
} else {
129139
latestPumpVolume = 50.0
130140
infoManager.updateInfoData(type: .pump, value: "50+U")
141+
Storage.shared.lastPumpReservoirU.value = nil
131142
}
132143
}
133144

0 commit comments

Comments
 (0)