Skip to content
Open
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
144 changes: 0 additions & 144 deletions CLAUDE.md

This file was deleted.

2 changes: 1 addition & 1 deletion LoopFollow.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,7 @@
374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */,
374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */,
374A77982F5BD8AB00E96858 /* APNSClient.swift */,
37989DD32F60A11E0004BD8B /* WatchConnectivityManager.swift */,
);
path = LiveActivity;
sourceTree = "<group>";
Expand Down Expand Up @@ -1680,7 +1681,6 @@
isa = PBXGroup;
children = (
379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */,
37989DD32F60A11E0004BD8B /* WatchConnectivityManager.swift */,
374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */,
DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */,
DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */,
Expand Down
6 changes: 4 additions & 2 deletions LoopFollow/LiveActivity/GlucoseSnapshotStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ final class GlucoseSnapshotStore {
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)
Expand All @@ -45,8 +46,9 @@ final class GlucoseSnapshotStore {
}

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
let snapshot = try decoder.decode(GlucoseSnapshot.self, from: data)
return snapshot
} catch {
Expand Down
18 changes: 0 additions & 18 deletions LoopFollow/LiveActivity/LAAppGroupSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,6 @@ enum LAAppGroupSettings {
static let smallWidgetSlot = "la.smallWidgetSlot"
static let displayName = "la.displayName"
static let showDisplayName = "la.showDisplayName"
static let watchSlots = "watch.slots"
static let watchSelectedSlots = "watch.selectedSlots"
}

Expand Down Expand Up @@ -208,23 +207,6 @@ enum LAAppGroupSettings {
return LiveActivitySlotOption(rawValue: raw) ?? LiveActivitySlotDefaults.smallWidgetSlot
}

// MARK: - Watch slots (Write)

/// Persists the 4-position Watch data card slot configuration.
static func setWatchSlots(_ slots: [LiveActivitySlotOption]) {
let raw = slots.prefix(4).map(\.rawValue)
defaults?.set(raw, forKey: Keys.watchSlots)
}

// MARK: - Watch slots (Read)

static func watchSlots() -> [LiveActivitySlotOption] {
guard let raw = defaults?.stringArray(forKey: Keys.watchSlots), raw.count == 4 else {
return [.iob, .cob, .projectedBG, .battery]
}
return raw.map { LiveActivitySlotOption(rawValue: $0) ?? .none }
}

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

/// Persists the user's ordered list of selected Watch data slots.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
// LoopFollow
// WatchConnectivityManager.swift

// WatchConnectivityManager.swift
// Philippe Achkar
// 2026-03-10

import Foundation
import WatchConnectivity

Expand All @@ -15,8 +11,21 @@ final class WatchConnectivityManager: NSObject {

// MARK: - Init

/// Timestamp of the last snapshot the Watch ACK'd via sendAck().
private var lastWatchAckTimestamp: TimeInterval = 0
/// Serial queue protecting mutable state (the last-ack timestamp) from
/// concurrent access: `send(snapshot:)` may be called from any thread, and
/// WCSession delegate callbacks arrive on an arbitrary background queue.
private let stateQueue = DispatchQueue(label: "com.loopfollow.WatchConnectivityManager.state")

/// Backing storage for `lastWatchAckTimestamp`. Always access via the
/// thread-safe accessor below.
private var _lastWatchAckTimestamp: TimeInterval = 0

/// Timestamp of the last snapshot the Watch ACK'd. Read/write is serialized
/// through `stateQueue`.
private var lastWatchAckTimestamp: TimeInterval {
get { stateQueue.sync { _lastWatchAckTimestamp } }
set { stateQueue.sync { _lastWatchAckTimestamp = newValue } }
}

override private init() {
super.init()
Expand Down Expand Up @@ -56,8 +65,9 @@ final class WatchConnectivityManager: NSObject {
}

do {
// GlucoseSnapshot has a custom encoder that writes updatedAt as a
// Double (timeIntervalSince1970), so no date strategy needs to be set.
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(snapshot)
let payload: [String: Any] = ["snapshot": data]

Expand Down
66 changes: 38 additions & 28 deletions LoopFollow/WatchComplication/ComplicationEntryBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ enum ComplicationID {
static let gaugeCorner = "LoopFollowGaugeCorner"
/// graphicCorner stacked text only (Complication 2).
static let stackCorner = "LoopFollowStackCorner"
// DEBUG COMPLICATION — enabled for pipeline diagnostics.
// Shows two timestamps to isolate pipeline failures:
// outer (top): HH:mm of snapshot.updatedAt — when CGM data last reached the Watch
// inner (↺): HH:mm when ClockKit last called getCurrentTimelineEntry
// If outer changes but inner is stale → reloadTimeline() not firing or ClockKit ignoring it.
// If inner changes but outer is stale → data delivery broken, complication rebuilding with old data.
static let debugCorner = "LoopFollowDebugCorner"
#if DEBUG
// DEBUG COMPLICATION — pipeline diagnostics only, never shipped in release builds.
// Shows two timestamps to isolate pipeline failures:
// outer (top): HH:mm of snapshot.updatedAt — when CGM data last reached the Watch
// inner (↺): HH:mm when ClockKit last called getCurrentTimelineEntry
// If outer changes but inner is stale → reloadTimeline() not firing or ClockKit ignoring it.
// If inner changes but outer is stale → data delivery broken, complication rebuilding with old data.
static let debugCorner = "LoopFollowDebugCorner"
#endif
}

// MARK: - Entry builder
Expand All @@ -35,7 +37,9 @@ enum ComplicationEntryBuilder {
case .graphicCorner:
switch identifier {
case ComplicationID.stackCorner: return graphicCornerStackTemplate(snapshot: snapshot)
case ComplicationID.debugCorner: return graphicCornerDebugTemplate(snapshot: snapshot)
#if DEBUG
case ComplicationID.debugCorner: return graphicCornerDebugTemplate(snapshot: snapshot)
#endif
default: return graphicCornerGaugeTemplate(snapshot: snapshot)
}
default:
Expand All @@ -59,11 +63,13 @@ enum ComplicationEntryBuilder {
innerTextProvider: CLKSimpleTextProvider(text: ""),
outerTextProvider: CLKSimpleTextProvider(text: "--")
)
case ComplicationID.debugCorner:
return CLKComplicationTemplateGraphicCornerStackText(
innerTextProvider: CLKSimpleTextProvider(text: "STALE"),
outerTextProvider: CLKSimpleTextProvider(text: "--:--")
)
#if DEBUG
case ComplicationID.debugCorner:
return CLKComplicationTemplateGraphicCornerStackText(
innerTextProvider: CLKSimpleTextProvider(text: "STALE"),
outerTextProvider: CLKSimpleTextProvider(text: "--:--")
)
#endif
default:
return staleGaugeTemplate()
}
Expand All @@ -90,11 +96,13 @@ enum ComplicationEntryBuilder {
innerTextProvider: CLKSimpleTextProvider(text: "→ --"),
outerTextProvider: outer
)
case ComplicationID.debugCorner:
return CLKComplicationTemplateGraphicCornerStackText(
innerTextProvider: CLKSimpleTextProvider(text: "DEBUG"),
outerTextProvider: CLKSimpleTextProvider(text: "--:--")
)
#if DEBUG
case ComplicationID.debugCorner:
return CLKComplicationTemplateGraphicCornerStackText(
innerTextProvider: CLKSimpleTextProvider(text: "DEBUG"),
outerTextProvider: CLKSimpleTextProvider(text: "--:--")
)
#endif
default:
let outer = CLKSimpleTextProvider(text: "---")
outer.tintColor = .green
Expand Down Expand Up @@ -184,7 +192,7 @@ enum ComplicationEntryBuilder {
bgText.tintColor = thresholdColor(for: snapshot)

let bottomLabel: String
if let _ = snapshot.projected {
if snapshot.projected != nil {
// ⇢ = dashed arrow (U+21E2); swap for ▸ (U+25B8) if it renders poorly on-device
bottomLabel = "\(WatchFormat.delta(snapshot)) | ⇢\(WatchFormat.projected(snapshot))"
} else {
Expand All @@ -197,7 +205,7 @@ enum ComplicationEntryBuilder {
)
}

// MARK: - Graphic Corner — Debug (Complication 3)
// MARK: - Graphic Corner — Debug (Complication 3, DEBUG builds only)

// Outer (top): HH:mm of the snapshot's updatedAt — when the CGM reading arrived.
// Inner (bottom): "↺ HH:mm" — when ClockKit last called getCurrentTimelineEntry.
Expand All @@ -207,15 +215,17 @@ enum ComplicationEntryBuilder {
// inner changes → ClockKit is refreshing the complication face
// inner stale → reloadTimeline is not being called or ClockKit is ignoring it

private static func graphicCornerDebugTemplate(snapshot: GlucoseSnapshot) -> CLKComplicationTemplate {
let dataTime = WatchFormat.updateTime(snapshot)
let buildTime = WatchFormat.currentTime()
#if DEBUG
private static func graphicCornerDebugTemplate(snapshot: GlucoseSnapshot) -> CLKComplicationTemplate {
let dataTime = WatchFormat.updateTime(snapshot)
let buildTime = WatchFormat.currentTime()

return CLKComplicationTemplateGraphicCornerStackText(
innerTextProvider: CLKSimpleTextProvider(text: "↺ \(buildTime)"),
outerTextProvider: CLKSimpleTextProvider(text: dataTime)
)
}
return CLKComplicationTemplateGraphicCornerStackText(
innerTextProvider: CLKSimpleTextProvider(text: "↺ \(buildTime)"),
outerTextProvider: CLKSimpleTextProvider(text: dataTime)
)
}
#endif

// MARK: - Threshold color

Expand Down
Loading