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
98 changes: 98 additions & 0 deletions HubShared/BGAnnouncement.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// LoopFollow
// BGAnnouncement.swift

// Compiled into the app and into LoopFollowNotificationService: the app speaks it
// for Speak BG, the extension renders it for the hub's speech push.

import Foundation

/// The sentence Speak BG says for a new reading, in the chosen language.
enum BGAnnouncement {
struct Texts {
var stable: String
var increase: String
var decrease: String
var currentBGIs: String

static func forLanguage(_ language: String) -> Texts {
switch language {
case "fr":
return Texts(
stable: "et c'est stable",
increase: "et c'est monté de",
decrease: "et c'est descendu de",
currentBGIs: "La glycémie est"
)
case "it":
return Texts(
stable: "ed è stabile",
increase: "ed è salita di",
decrease: "ed è scesa di",
currentBGIs: "Glicemia attuale è"
)
case "sk":
return Texts(
stable: "a je stabilná",
increase: "a stúpla o",
decrease: "a klesla o",
currentBGIs: "Aktuálna glykémia je"
)
case "sv":
return Texts(
stable: "och det är stabilt",
increase: "och det har ökat med",
decrease: "och det har minskat med",
currentBGIs: "Blodsockret är"
)
case "en": fallthrough
default:
return Texts(
stable: "and it is stable",
increase: "and it is up",
decrease: "and it is down",
currentBGIs: "Glucose is"
)
}
}
}

static let voiceLanguageMap: [String: String] = [
"en": "en-US",
"fr": "fr-FR",
"it": "it-IT",
"sk": "sk-SK",
"sv": "sv-SE",
]

static func voiceLanguageCode(forAppLanguage appLanguage: String) -> String {
voiceLanguageMap[appLanguage, default: "en-US"]
}

/// A change of this many mg/dL or less is spoken as stable.
static let negligibleChange = 3

/// The announcement for `current` after `previous` (mg/dL), with values in the
/// display unit and a period as the decimal mark, which every voice reads as one.
static func text(current: Int, previous: Int, language: String, mmol: Bool,
locale: Locale = .current) -> String
{
let texts = Texts.forLanguage(language)
let difference = current - previous
let currentText = format(current, mmol: mmol, locale: locale)
if abs(difference) <= negligibleChange {
return "\(texts.currentBGIs) \(currentText) \(texts.stable)"
}
let direction = difference < 0 ? texts.decrease : texts.increase
return "\(texts.currentBGIs) \(currentText) \(direction) \(format(abs(difference), mmol: mmol, locale: locale))"
}

static func format(_ mgdl: Int, mmol: Bool, locale: Locale) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = locale
formatter.minimumFractionDigits = mmol ? 1 : 0
formatter.maximumFractionDigits = mmol ? 1 : 0
let value = mmol ? Double(mgdl) * GlucoseConversion.mgDlToMmolL : Double(mgdl)
return (formatter.string(from: NSNumber(value: value)) ?? String(value)).replacingOccurrences(of: ",", with: ".")
}
}
174 changes: 174 additions & 0 deletions HubShared/HubAlarmKit.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// LoopFollow
// HubAlarmKit.swift

// Compiled into the app and into LoopFollowNotificationService only: the extension
// rings hub alarms through it; the app stops them, handles their snooze intent and
// rings its own notices.

import Foundation

#if canImport(AlarmKit) && !targetEnvironment(macCatalyst)
import ActivityKit
import AlarmKit
import AppIntents
import struct SwiftUI.Color

/// Rings an alarm LF Hub delivered as an AlarmKit alarm, which breaks through
/// the silent switch and Focus. The notification service extension calls this
/// for every hub alarm push, so it rings even while LoopFollow is suspended
/// or not running. The AlarmKit alarm takes the LoopFollow alarm's id, so a
/// repeated push for an alarm that is still ringing does not ring twice.
@available(iOS 26, *)
enum HubAlarmKit {
/// A fixed alarm at or before now never fires, so it is set this far ahead.
private static let immediateFireDelay: TimeInterval = 2

static var isAuthorized: Bool {
AlarmKit.AlarmManager.shared.authorizationState == .authorized
}

/// The user has not been asked yet; once asked, only the Settings app changes it.
static var canAsk: Bool {
AlarmKit.AlarmManager.shared.authorizationState == .notDetermined
}

static func requestAuthorization() async -> Bool {
if AlarmKit.AlarmManager.shared.authorizationState == .notDetermined {
_ = try? await AlarmKit.AlarmManager.shared.requestAuthorization()
}
return isAuthorized
}

/// Whether the alarm is ringing or about to, when it starts if this call scheduled it,
/// and why. Not ringing when AlarmKit is not authorized or scheduling failed, so the
/// notification keeps its sound. Without `soundName` it rings with the default sound.
/// `symbol` is the SF Symbol on the alarm's button: the alarm type's icon. The button
/// reads Acknowledge for an alarm whose snooze is 0 (`acknowledge`), as in the app.
static func ring(alarmID: UUID, title: String, soundName: String?, symbol: String = "moon.zzz",
acknowledge: Bool = false) async -> (ringing: Bool, firesAt: Date?, detail: String)
{
// A denied state is final; notDetermined is what an extension reads even
// after the app was authorized, so scheduling is attempted then too.
let state = AlarmKit.AlarmManager.shared.authorizationState
guard state != .denied else { return (false, nil, "authorization denied") }
if ((try? AlarmKit.AlarmManager.shared.alarms) ?? []).contains(where: { $0.id == alarmID }) {
return (true, nil, "already scheduled")
}

let presentation = AlarmPresentation(
alert: AlarmPresentation.Alert(
title: LocalizedStringResource(stringLiteral: title),
stopButton: AlarmButton(
text: LocalizedStringResource(stringLiteral: buttonTitle(acknowledge: acknowledge)),
textColor: .white,
systemImageName: symbol
)
)
)
let firesAt = Date().addingTimeInterval(immediateFireDelay)
let configuration = AlarmKit.AlarmManager.AlarmConfiguration<HubAlarmMetadata>.alarm(
schedule: .fixed(firesAt),
attributes: AlarmAttributes(presentation: presentation, tintColor: .red),
stopIntent: SnoozeHubAlarmIntent(alarmID: alarmID),
sound: soundName.map { .named($0) } ?? .default
)
do {
let alarm = try await AlarmKit.AlarmManager.shared.schedule(id: alarmID, configuration: configuration)
return (true, firesAt, "scheduled, state \(alarm.state) (authorization read \(state))")
} catch {
return (false, nil, "schedule failed with authorization \(state): \(error)")
}
}

static func buttonTitle(acknowledge: Bool) -> String {
acknowledge ? "Acknowledge" : "Snooze"
}

/// Rings a notice from the app itself (not a LoopFollow alarm) as a system
/// alarm now, with an OK button and no snooze. Replaces a notice with the same id.
static func ringNotice(id: UUID, title: String, symbol: String) async -> Bool {
guard AlarmKit.AlarmManager.shared.authorizationState == .authorized else { return false }
stop(alarmID: id)
let presentation = AlarmPresentation(
alert: AlarmPresentation.Alert(
title: LocalizedStringResource(stringLiteral: title),
stopButton: AlarmButton(
text: LocalizedStringResource(stringLiteral: "OK"),
textColor: .white,
systemImageName: symbol
)
)
)
let configuration = AlarmKit.AlarmManager.AlarmConfiguration<HubAlarmMetadata>.alarm(
schedule: .fixed(Date().addingTimeInterval(immediateFireDelay)),
attributes: AlarmAttributes(presentation: presentation, tintColor: .orange),
sound: .default
)
return (try? await AlarmKit.AlarmManager.shared.schedule(id: id, configuration: configuration)) != nil
}

/// Whether the alarm is sounding now.
static func isAlerting(alarmID: UUID) -> Bool {
((try? AlarmKit.AlarmManager.shared.alarms) ?? []).contains { $0.id == alarmID && $0.state == .alerting }
}

static func stop(alarmID: UUID) {
try? AlarmKit.AlarmManager.shared.stop(id: alarmID)
try? AlarmKit.AlarmManager.shared.cancel(id: alarmID)
}
}

@available(iOS 26, *)
struct HubAlarmMetadata: AlarmMetadata {}

/// Runs in the app when the AlarmKit alarm's button is tapped, and snoozes
/// the LoopFollow alarm the way the notification's snooze action does.
@available(iOS 26, *)
struct SnoozeHubAlarmIntent: LiveActivityIntent {
static let title: LocalizedStringResource = "Snooze LoopFollow Alarm"
static let isDiscoverable = false

@Parameter(title: "Alarm ID") var alarmID: String

init() {}

init(alarmID: UUID) {
self.alarmID = alarmID.uuidString
}

func perform() async throws -> some IntentResult {
if let id = UUID(uuidString: alarmID) {
await HubAlarmSnoozeBridge.shared.snooze(id)
}
return .result()
}
}
#endif

/// Hands an AlarmKit snooze to the app's alarm handling. Taps that arrive
/// before the app registers its handler are queued until it does.
@MainActor
final class HubAlarmSnoozeBridge {
static let shared = HubAlarmSnoozeBridge()
private init() {}

private var handler: (@MainActor (UUID) async -> Void)?
private var pending: [UUID] = []

func setHandler(_ handler: @escaping @MainActor (UUID) async -> Void) {
self.handler = handler
let queued = pending
pending = []
Task { for id in queued {
await handler(id)
} }
}

func snooze(_ id: UUID) async {
if let handler {
await handler(id)
} else {
pending.append(id)
}
}
}
32 changes: 32 additions & 0 deletions HubShared/HubAppGroup.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// LoopFollow
// HubAppGroup.swift

/// What the app shares with LoopFollowNotificationService through the App Group's
/// defaults. The device key is not shared: the extension's one hub call, the
/// delivery receipt, needs no key.
enum HubAppGroup {
/// The hub's address while the app is connected to a hub; absent otherwise.
static let hubURLKey = "lfhubURL"

/// The notification category of a hub alarm whose snooze is 0: its buttons read
/// Acknowledge, as the app's own action for such an alarm does.
static let acknowledgeCategory = "lfhub-ack"

/// How the extension delivers a push from the hub.
enum Delivery: Equatable {
/// As a system alarm, when the hub asks for one.
case systemAlarm
/// As the notification the hub sent, with its sound.
case notification
/// Quietly to the notification list, with no sound: this phone has left the hub,
/// so nothing it cannot silence rings, and the push is still kept.
case quiet
}

/// A push reaching a phone that is not connected to a hub (`hubURL` absent) is
/// delivered quietly.
static func delivery(hubURL: String?, systemAlarm: Bool) -> Delivery {
guard let hubURL, !hubURL.isEmpty else { return .quiet }
return systemAlarm ? .systemAlarm : .notification
}
}
Loading