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

import AppIntents
import Foundation

enum SnoozeDuration: String, AppEnum {
case thirtyMinutes, oneHour, twoHours, fourHours, eightHours, twelveHours

static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Snooze Duration")

static var caseDisplayRepresentations: [SnoozeDuration: DisplayRepresentation] = [
.thirtyMinutes: DisplayRepresentation(title: "30 minutes", synonyms: ["half an hour"]),
.oneHour: DisplayRepresentation(title: "1 hour", synonyms: ["an hour", "one hour"]),
.twoHours: DisplayRepresentation(title: "2 hours", synonyms: ["two hours"]),
.fourHours: DisplayRepresentation(title: "4 hours", synonyms: ["four hours"]),
.eightHours: DisplayRepresentation(title: "8 hours", synonyms: ["eight hours"]),
.twelveHours: DisplayRepresentation(title: "12 hours", synonyms: ["twelve hours"]),
]

var seconds: TimeInterval {
switch self {
case .thirtyMinutes: return 30 * 60
case .oneHour: return 60 * 60
case .twoHours: return 2 * 60 * 60
case .fourHours: return 4 * 60 * 60
case .eightHours: return 8 * 60 * 60
case .twelveHours: return 12 * 60 * 60
}
}
}

struct SnoozeAllAlarmsIntent: AppIntent {
static var title: LocalizedStringResource = "Snooze All Alarms"
static var description = IntentDescription("Silences every LoopFollow alarm for the chosen time, like the Snooze All button in the app.")

@Parameter(title: "Duration", default: .oneHour)
var duration: SnoozeDuration

static var parameterSummary: some ParameterSummary {
Summary("Snooze all alarms for \(\.$duration)")
}

@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
try IntentDataSource.ensureStorageAvailable()
if Observable.shared.currentAlarm.value != nil {
AlarmManager.shared.performSnooze()
}
let until = Date().addingTimeInterval(duration.seconds)
Storage.shared.alarmConfiguration.value.snoozeUntil = until
return .result(dialog: "All alarms are snoozed until \(SpokenDuration.clockTime(until)).")
}
}

struct EndSnoozeIntent: AppIntent {
static var title: LocalizedStringResource = "End Snooze"
static var description = IntentDescription("Ends an active Snooze All so LoopFollow alarms can sound again.")

@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
try IntentDataSource.ensureStorageAvailable()
let wasActive = (Storage.shared.alarmConfiguration.value.snoozeUntil ?? .distantPast) > Date()
Storage.shared.alarmConfiguration.value.snoozeUntil = nil
return .result(dialog: wasActive ? "Alarms are active again." : "No snooze was active.")
}
}

struct MuteAlarmSoundsIntent: AppIntent {
static var title: LocalizedStringResource = "Mute Alarm Sounds"
static var description = IntentDescription("Mutes LoopFollow alarm sounds for the chosen time. Notifications and vibration continue.")

@Parameter(title: "Duration", default: .oneHour)
var duration: SnoozeDuration

static var parameterSummary: some ParameterSummary {
Summary("Mute alarm sounds for \(\.$duration)")
}

@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
try IntentDataSource.ensureStorageAvailable()
let until = Date().addingTimeInterval(duration.seconds)
Storage.shared.alarmConfiguration.value.muteUntil = until
return .result(dialog: "Alarm sounds are muted until \(SpokenDuration.clockTime(until)). Notifications and vibration continue.")
}
}

struct UnmuteAlarmSoundsIntent: AppIntent {
static var title: LocalizedStringResource = "Unmute Alarm Sounds"
static var description = IntentDescription("Ends an active mute so LoopFollow alarms play sound again.")

@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
try IntentDataSource.ensureStorageAvailable()
let wasActive = (Storage.shared.alarmConfiguration.value.muteUntil ?? .distantPast) > Date()
Storage.shared.alarmConfiguration.value.muteUntil = nil
return .result(dialog: wasActive ? "Alarm sounds are on again." : "Alarm sounds were not muted.")
}
}

struct SnoozeCurrentAlarmIntent: AppIntent {
static var title: LocalizedStringResource = "Snooze Current Alarm"
static var description = IntentDescription("Snoozes the alarm that is sounding right now, using that alarm's own snooze duration.")

@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
try IntentDataSource.ensureStorageAvailable()
guard let alarm = IntentDataSource.soundingAlarm() else {
return .result(dialog: "No alarm is sounding.")
}
let seconds = Double(alarm.snoozeDuration) * alarm.type.snoozeTimeUnit.seconds
AlarmManager.shared.performSnooze()
if seconds > 0 {
return .result(dialog: "\(alarm.name) is snoozed for \(SpokenDuration.duration(seconds)).")
}
return .result(dialog: "\(alarm.name) is acknowledged.")
}
}

struct GetAlarmStatusIntent: AppIntent {
static var title: LocalizedStringResource = "Get Alarm Status"
static var description = IntentDescription("Tells you whether LoopFollow alarms are snoozed or muted, and whether one is sounding.")

@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue<String> {
try IntentDataSource.ensureStorageAvailable()
let input = StatusReadoutInput(
snapshot: nil,
lastLoopTime: 0,
loopAppName: "",
hasNightscout: false,
alarms: IntentDataSource.alarmState(),
now: Date()
)
let text = StatusReadoutFormatter.alarmSentence(input)
return .result(value: text, dialog: IntentDialog("\(text)"))
}
}
51 changes: 51 additions & 0 deletions LoopFollow/AppIntents/IntentDataSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// LoopFollow
// IntentDataSource.swift

import Foundation

/// Gathers the app state an intent needs. Runs on the main actor because
/// `Storage` and `Observable` publish on the main thread.
@MainActor
enum IntentDataSource {
/// Storage holds defaults during a suspected before-first-unlock launch and
/// the App Group snapshot file is unreadable then, so intents refuse to answer.
static func ensureStorageAvailable() throws {
guard !StorageReadiness.isSuppressingWrites else {
throw LoopFollowIntentError.storageUnavailable
}
}

static func readoutInput(now: Date = Date()) throws -> StatusReadoutInput {
try ensureStorageAvailable()

// The live builder needs the in-memory glucose value, which is empty when
// Siri launched the app in the background; the saved snapshot covers that.
let live = Observable.shared.bg.value != nil
? GlucoseSnapshotBuilder.build(from: StorageCurrentGlucoseStateProvider())
: nil
let snapshot = live ?? GlucoseSnapshotStore.shared.load()

return StatusReadoutInput(
snapshot: snapshot,
lastLoopTime: Storage.shared.lastLoopTime.value,
loopAppName: Storage.shared.device.value,
hasNightscout: !Storage.shared.url.value.isEmpty,
alarms: alarmState(),
now: now
)
}

static func alarmState() -> AlarmStateInput {
let config = Storage.shared.alarmConfiguration.value
return AlarmStateInput(
snoozeUntil: config.snoozeUntil,
muteUntil: config.muteUntil,
soundingAlarmName: soundingAlarm()?.name
)
}

static func soundingAlarm() -> Alarm? {
guard let id = Observable.shared.currentAlarm.value else { return nil }
return Storage.shared.alarms.value.first { $0.id == id }
}
}
85 changes: 85 additions & 0 deletions LoopFollow/AppIntents/LoopFollowAppShortcuts.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// LoopFollow
// LoopFollowAppShortcuts.swift

#if !targetEnvironment(macCatalyst)
import AppIntents

/// Siri phrases. Apple allows at most ten App Shortcuts per app; the intents
/// without an entry here are still available in the Shortcuts app.
struct LoopFollowAppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: GetStatusIntent(),
phrases: [
"What's the status in \(.applicationName)",
"Check \(.applicationName)",
"\(.applicationName) status",
"How are they doing in \(.applicationName)",
],
shortTitle: "Get Status",
systemImageName: "waveform.path.ecg"
)
AppShortcut(
intent: GetGlucoseIntent(),
phrases: [
"What's the glucose in \(.applicationName)",
"What's the blood sugar in \(.applicationName)",
"What's the BG in \(.applicationName)",
],
shortTitle: "Get Glucose",
systemImageName: "drop"
)
AppShortcut(
intent: GetStatusItemIntent(),
phrases: [
"What's the \(\.$item) in \(.applicationName)",
"Check the \(\.$item) in \(.applicationName)",
],
shortTitle: "Get Status Item",
systemImageName: "list.bullet"
)
AppShortcut(
intent: SnoozeAllAlarmsIntent(),
phrases: [
"Snooze all alarms in \(.applicationName)",
"Snooze all alarms in \(.applicationName) for \(\.$duration)",
],
shortTitle: "Snooze All Alarms",
systemImageName: "bell.slash"
)
AppShortcut(
intent: EndSnoozeIntent(),
phrases: [
"End snooze in \(.applicationName)",
"Resume alarms in \(.applicationName)",
],
shortTitle: "End Snooze",
systemImageName: "bell"
)
AppShortcut(
intent: SnoozeCurrentAlarmIntent(),
phrases: ["Snooze the alarm in \(.applicationName)"],
shortTitle: "Snooze Current Alarm",
systemImageName: "bell.badge.slash"
)
AppShortcut(
intent: RestartLiveActivityIntent(),
phrases: ["Restart Live Activity in \(.applicationName)"],
shortTitle: "Restart Live Activity",
systemImageName: "dot.radiowaves.left.and.right"
)
AppShortcut(
intent: EnableSpeakBGIntent(),
phrases: ["Turn on Speak BG in \(.applicationName)"],
shortTitle: "Turn On Speak BG",
systemImageName: "speaker.wave.2"
)
AppShortcut(
intent: DisableSpeakBGIntent(),
phrases: ["Turn off Speak BG in \(.applicationName)"],
shortTitle: "Turn Off Speak BG",
systemImageName: "speaker.slash"
)
}
}
#endif
18 changes: 18 additions & 0 deletions LoopFollow/AppIntents/LoopFollowIntentError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// LoopFollow
// LoopFollowIntentError.swift

import AppIntents

enum LoopFollowIntentError: Error, CustomLocalizedStringResourceConvertible {
case storageUnavailable
case noGlucoseData

var localizedStringResource: LocalizedStringResource {
switch self {
case .storageUnavailable:
return "LoopFollow can't read its data until your iPhone has been unlocked once after restarting."
case .noGlucoseData:
return "LoopFollow has no glucose data yet. Open the app to check its setup."
}
}
}
48 changes: 48 additions & 0 deletions LoopFollow/AppIntents/SpokenDuration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// LoopFollow
// SpokenDuration.swift

import Foundation

/// English phrases for durations and clock times, written for Siri dialogs.
enum SpokenDuration {
private static let components: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .full
formatter.maximumUnitCount = 2
formatter.allowedUnits = [.day, .hour, .minute]
formatter.zeroFormattingBehavior = .dropAll
var calendar = Calendar(identifier: .gregorian)
calendar.locale = Locale(identifier: "en_US")
formatter.calendar = calendar
return formatter
}()

/// "1 hour, 30 minutes". Seconds are truncated to whole minutes; under a minute
/// reads "less than a minute".
static func duration(_ seconds: TimeInterval) -> String {
let wholeMinutes = (max(seconds, 0) / 60).rounded(.down) * 60
guard wholeMinutes >= 60 else { return "less than a minute" }
return components.string(from: wholeMinutes) ?? "\(Int(wholeMinutes / 60)) minutes"
}

/// "3 minutes ago"; under a minute reads "just now".
static func ago(_ seconds: TimeInterval) -> String {
guard seconds >= 60 else { return "just now" }
return "\(duration(seconds)) ago"
}

/// Age of an insertion timestamp (epoch seconds) relative to `now`.
static func age(since insertTime: TimeInterval, now: Date) -> String {
duration(now.timeIntervalSince1970 - insertTime)
}

/// Clock time in the device's 12/24-hour style, e.g. "2:30 PM" or "14:30".
static func clockTime(_ date: Date, locale: Locale = .current, timeZone: TimeZone = .current) -> String {
let formatter = DateFormatter()
formatter.locale = locale
formatter.timeZone = timeZone
formatter.timeStyle = .short
formatter.dateStyle = .none
return formatter.string(from: date).replacingOccurrences(of: "\u{202F}", with: " ")
}
}
50 changes: 50 additions & 0 deletions LoopFollow/AppIntents/StatusIntents.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// LoopFollow
// StatusIntents.swift

import AppIntents

struct GetStatusIntent: AppIntent {
static var title: LocalizedStringResource = "Get Status"
static var description = IntentDescription("Reads out the latest glucose, trend, IOB, COB and loop status that LoopFollow has received.")

@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue<String> {
let input = try IntentDataSource.readoutInput()
let text = StatusReadoutFormatter.fullReadout(input)
return .result(value: text, dialog: IntentDialog("\(text)"))
}
}

struct GetGlucoseIntent: AppIntent {
static var title: LocalizedStringResource = "Get Glucose"
static var description = IntentDescription("Reads out the latest glucose reading and returns it as a number in your display unit, for use in Shortcuts automations.")

@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue<Double> {
let input = try IntentDataSource.readoutInput()
guard let snapshot = input.snapshot, let text = StatusReadoutFormatter.glucoseLine(input) else {
throw LoopFollowIntentError.noGlucoseData
}
let value = StatusReadoutFormatter.displayValue(snapshot.glucose, unit: snapshot.unit)
return .result(value: value, dialog: IntentDialog("\(text)"))
}
}

struct GetStatusItemIntent: AppIntent {
static var title: LocalizedStringResource = "Get Status Item"
static var description = IntentDescription("Reads out one value, such as IOB, pump reservoir, sensor age or last loop time.")

@Parameter(title: "Item")
var item: StatusItem

static var parameterSummary: some ParameterSummary {
Summary("Get \(\.$item)")
}

@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue<String> {
let input = try IntentDataSource.readoutInput()
let text = StatusReadoutFormatter.text(for: item.field, input)
return .result(value: text, dialog: IntentDialog("\(text)"))
}
}
Loading