Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
96 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
01e2c1b
Removed duplicate code
bjorkert Mar 19, 2026
b3f2436
Live activity - final fixes (#557)
MtlPhil Mar 20, 2026
fa26039
Update to 5.0
bjorkert Mar 20, 2026
db5ddbf
Remove unnecessary @available(iOS 16.4) checks
bjorkert Mar 21, 2026
a62595c
BGAppRefreshTask audio recovery; LA expiry notification; code quality…
MtlPhil Mar 22, 2026
84e1736
Live Activity: CarPlay/Watch Smart Stack widget + BFU crash fix + BGA…
MtlPhil Mar 24, 2026
2576dca
Linting
bjorkert Mar 24, 2026
2a5c1ef
Fix JWT cache thread-safety to prevent TooManyProviderTokenUpdates
bjorkert Mar 24, 2026
39c4ae7
Apple Watch app: complications, data grid, background delivery
MtlPhil Apr 9, 2026
0730113
Merge branch 'dev' into apple-watch
bjorkert Apr 9, 2026
002df85
SwiftFormat
bjorkert Apr 9, 2026
c40be2f
Fix viewUpdateNSBG: re-declare lastBGTime local
bjorkert Apr 9, 2026
88816f7
Merge remote-tracking branch 'origin/dev' into apple-watch
bjorkert Apr 9, 2026
cb286c1
Watch PR review fixes (#598)
bjorkert Apr 10, 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
216 changes: 215 additions & 1 deletion LoopFollow.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

19 changes: 14 additions & 5 deletions LoopFollow/Application/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,28 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// Ensure VolumeButtonHandler is initialized so it can receive alarm notifications
_ = VolumeButtonHandler.shared

WatchConnectivityManager.shared.activate()

// Register for remote notifications
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}

BackgroundRefreshManager.shared.register()

// Detect Before-First-Unlock launch. If protected data is unavailable here,
// StorageValues were cached from encrypted UserDefaults and need a reload
// on the first foreground after the user unlocks.
let bfu = !UIApplication.shared.isProtectedDataAvailable
// Detect Before-First-Unlock launch. isProtectedDataAvailable returns false
// for ANY locked-screen background launch, not only post-reboot. Standard
// UserDefaults use NSFileProtectionCompleteUntilFirstUserAuthentication —
// they stay readable after the first unlock even when the screen is locked.
// True BFU (boot before first unlock) is the only case where UserDefaults
// is actually inaccessible; in that state every StorageValue reads as its
// default — including migrationStep, which is always ≥ 1 for existing users.
// Guard against false positives by checking that migrationStep is still 0
// (its default), meaning the real value couldn't be read from disk.
let protectedDataUnavailable = !UIApplication.shared.isProtectedDataAvailable
let bfu = protectedDataUnavailable && Storage.shared.migrationStep.value == 0
Storage.shared.needsBFUReload = bfu
LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(!bfu), needsBFUReload=\(bfu)")
LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(!protectedDataUnavailable), migrationStep=\(Storage.shared.migrationStep.value), needsBFUReload=\(bfu)")

return true
}
Expand Down
9 changes: 6 additions & 3 deletions LoopFollow/Contact/ContactImageUpdater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,15 +119,18 @@ class ContactImageUpdater {
private func getIncludedFields(for contactType: ContactType) -> [ContactType] {
var included: [ContactType] = []
if Storage.shared.contactTrend.value == .include,
Storage.shared.contactTrendTarget.value == contactType {
Storage.shared.contactTrendTarget.value == contactType
{
included.append(.Trend)
}
if Storage.shared.contactDelta.value == .include,
Storage.shared.contactDeltaTarget.value == contactType {
Storage.shared.contactDeltaTarget.value == contactType
{
included.append(.Delta)
}
if Storage.shared.contactIOB.value == .include,
Storage.shared.contactIOBTarget.value == contactType {
Storage.shared.contactIOBTarget.value == contactType
{
included.append(.IOB)
}
return included
Expand Down
2 changes: 2 additions & 0 deletions LoopFollow/Controllers/Nightscout/BGData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ extension MainViewController {
let latestBG = entries[latestEntryIndex].sgv
let priorBG = entries[latestEntryIndex - 1].sgv
let deltaBG = latestBG - priorBG
let lastBGTime = entries[latestEntryIndex].date

self.updateServerText(with: sourceName)

Expand Down Expand Up @@ -267,6 +268,7 @@ extension MainViewController {

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

Expand Down
1 change: 1 addition & 0 deletions LoopFollow/LiveActivity/AppGroupID.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ enum AppGroupID {
".WidgetExtension",
".Widgets",
".WidgetsExtension",
".watchkitapp",
".Watch",
".WatchExtension",
".CarPlay",
Expand Down
25 changes: 18 additions & 7 deletions LoopFollow/LiveActivity/GlucoseSnapshotStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
// GlucoseSnapshotStore.swift

import Foundation
import os.log

private let storeLog = OSLog(subsystem: Bundle.main.bundleIdentifier ?? "com.loopfollow", category: "GlucoseSnapshotStore")

/// Persists the latest GlucoseSnapshot into the App Group container so that:
/// - the Live Activity extension can read it
Expand All @@ -17,31 +20,39 @@ final class GlucoseSnapshotStore {

// MARK: - Public API

func save(_ snapshot: GlucoseSnapshot) {
func save(_ snapshot: GlucoseSnapshot, completion: (() -> Void)? = nil) {
queue.async {
do {
let url = try self.fileURL()
// GlucoseSnapshot writes `updatedAt` as a Double via its custom
// encoder, so no JSONEncoder date strategy is required.
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(snapshot)
try data.write(to: url, options: [.atomic])
os_log("GlucoseSnapshotStore: saved snapshot g=%d to %{public}@", log: storeLog, type: .debug, Int(snapshot.glucose), url.lastPathComponent)
} catch {
// Intentionally silent (extension-safe, no dependencies).
os_log("GlucoseSnapshotStore: save failed — %{public}@", log: storeLog, type: .error, error.localizedDescription)
}
completion?()
}
}

func load() -> GlucoseSnapshot? {
do {
let url = try fileURL()
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
guard FileManager.default.fileExists(atPath: url.path) else {
os_log("GlucoseSnapshotStore: file not found at %{public}@", log: storeLog, type: .debug, url.lastPathComponent)
return nil
}

let data = try Data(contentsOf: url)
// GlucoseSnapshot reads `updatedAt` as a Double via its custom decoder,
// so no JSONDecoder date strategy is required.
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(GlucoseSnapshot.self, from: data)
let snapshot = try decoder.decode(GlucoseSnapshot.self, from: data)
return snapshot
} catch {
// Intentionally silent (extension-safe, no dependencies).
os_log("GlucoseSnapshotStore: load failed — %{public}@", log: storeLog, type: .error, error.localizedDescription)
return nil
}
}
Expand Down
17 changes: 17 additions & 0 deletions LoopFollow/LiveActivity/LAAppGroupSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ enum LAAppGroupSettings {
static let smallWidgetSlot = "la.smallWidgetSlot"
static let displayName = "la.displayName"
static let showDisplayName = "la.showDisplayName"
static let watchSelectedSlots = "watch.selectedSlots"
}

private static var defaults: UserDefaults? {
Expand Down Expand Up @@ -206,6 +207,22 @@ enum LAAppGroupSettings {
return LiveActivitySlotOption(rawValue: raw) ?? LiveActivitySlotDefaults.smallWidgetSlot
}

// MARK: - Watch selected slots (ordered, variable-length)

/// Persists the user's ordered list of selected Watch data slots.
static func setWatchSelectedSlots(_ slots: [LiveActivitySlotOption]) {
defaults?.set(slots.map(\.rawValue), forKey: Keys.watchSelectedSlots)
}

/// Returns the ordered list of selected Watch data slots.
/// Falls back to a sensible default if nothing is saved.
static func watchSelectedSlots() -> [LiveActivitySlotOption] {
guard let raw = defaults?.stringArray(forKey: Keys.watchSelectedSlots) else {
return [.iob, .cob, .projectedBG, .battery]
}
return raw.compactMap { LiveActivitySlotOption(rawValue: $0) }
}

// MARK: - Display Name

static func setDisplayName(_ name: String, show: Bool) {
Expand Down
12 changes: 3 additions & 9 deletions LoopFollow/LiveActivity/LALivenessMarker.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
//
// LALivenessMarker.swift
// LoopFollow
//
// Created by Philippe Achkar on 2026-04-01.
// Copyright © 2026 Jon Fawcett. All rights reserved.
//

// LoopFollow
// LALivenessMarker.swift

import SwiftUI

Expand All @@ -24,4 +18,4 @@ struct LALivenessMarker: View {
private var markerID: String {
"\(seq)-\(producedAt.timeIntervalSince1970)"
}
}
}
10 changes: 2 additions & 8 deletions LoopFollow/LiveActivity/LALivenessStore.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
//
// LALivenessStore.swift
// LoopFollow
//
// Created by Philippe Achkar on 2026-04-01.
// Copyright © 2026 Jon Fawcett. All rights reserved.
//

// LoopFollow
// LALivenessStore.swift

import Foundation

Expand Down
8 changes: 4 additions & 4 deletions LoopFollow/LiveActivity/LiveActivityManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ final class LiveActivityManager {
message: "[LA] foreground restart: current=nil (old activity not bound locally), ending all existing LAs before restart"
)
current = nil

Task {
for activity in Activity<GlucoseLiveActivityAttributes>.activities {
await activity.end(nil, dismissalPolicy: .immediate)
Expand Down Expand Up @@ -178,7 +178,7 @@ final class LiveActivityManager {
}
}
}

@objc private func handleBackgroundAudioFailed() {
guard Storage.shared.laEnabled.value, current != nil else { return }
// The background audio session has permanently failed — the app will lose its
Expand All @@ -188,7 +188,7 @@ final class LiveActivityManager {
Storage.shared.laRenewBy.value = Date().timeIntervalSince1970
refreshFromCurrentState(reason: "audio-session-failed")
}

private func shouldRestartBecauseExtensionLooksStuck() -> Bool {
guard Storage.shared.laEnabled.value else { return false }
guard !dismissedByUser else { return false }
Expand Down Expand Up @@ -554,7 +554,7 @@ final class LiveActivityManager {
highMgdl: Storage.shared.highLine.value,
)
GlucoseSnapshotStore.shared.save(snapshot)
//WatchConnectivityManager.shared.send(snapshot: snapshot)
WatchConnectivityManager.shared.send(snapshot: snapshot)

// LA update: gated on LA being active, snapshot having changed, and activities enabled.
guard Storage.shared.laEnabled.value, !dismissedByUser else { return }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding {
// MARK: - Core Glucose

var glucoseMgdl: Double? {
guard let bg = Observable.shared.bg.value, bg > 0 else { return nil }
return Double(bg)
guard let bg = Storage.shared.lastBgMgdl.value, bg > 0 else { return nil }
return bg
}

var deltaMgdl: Double? {
Expand Down
Loading