diff --git a/Config.xcconfig b/Config.xcconfig index 6795681025..07f292a308 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -19,7 +19,7 @@ TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group // The developers set the version numbers, please leave them alone APP_VERSION = 1.0.2 -APP_DEV_VERSION = 1.0.2.66 +APP_DEV_VERSION = 1.0.2.71 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = autoISF 3.01 diff --git a/Trio Watch App Extension/Views/AcknowledgementPendingView.swift b/Trio Watch App Extension/Views/AcknowledgementPendingView.swift index 22e951844e..b3218c35a3 100644 --- a/Trio Watch App Extension/Views/AcknowledgementPendingView.swift +++ b/Trio Watch App Extension/Views/AcknowledgementPendingView.swift @@ -43,17 +43,8 @@ struct AcknowledgementPendingView: View { .navigationBarBackButtonHidden(true) .toolbar(.hidden) .background(trioBackgroundColor) - .onChange(of: state.showCommsAnimation) { oldValue, newValue in - if newValue && !oldValue { - DispatchQueue.main.asyncAfter(deadline: .now() + 5) { - // If after 5 seconds there is still no acknowledgement banner, return to root - if !state.showAcknowledgmentBanner { - // Navigate back to the root - navigationPath.removeLast(navigationPath.count) - } - } - } - } + // WatchState times out a missing answer by showing a banner (beginAwaitingAcknowledgment), so every + // request ends here. A timer in this view never started: the flag is already true when the view appears. .onChange(of: state.showAcknowledgmentBanner) { _, newValue in if !newValue { // Navigate back to the root when acknowledgment banner disappears diff --git a/Trio Watch App Extension/Views/BolusConfirmationView.swift b/Trio Watch App Extension/Views/BolusConfirmationView.swift index 39b08c4ba2..e839a8f31f 100644 --- a/Trio Watch App Extension/Views/BolusConfirmationView.swift +++ b/Trio Watch App Extension/Views/BolusConfirmationView.swift @@ -52,11 +52,7 @@ struct BolusConfirmationView: View { Spacer() Button("Cancel") { - if state.carbsAmount > 0 { - state.carbsAmount = 0 // reset carbs in state - } - bolusAmount = 0 // reset bolus in state - confirmationProgress = 0 // reset auth progress + state.clearEntry() navigationPath.removeLast(navigationPath.count) } .buttonStyle(.bordered) @@ -80,13 +76,8 @@ struct BolusConfirmationView: View { WKInterfaceDevice.current().play(.success) DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - if state.carbsAmount > 0 { - state.sendCarbsRequest(state.carbsAmount, Date()) - state.carbsAmount = 0 // reset carbs in state - } - state.sendBolusRequest(Decimal(bolusAmount)) - bolusAmount = 0 // reset bolus in state - confirmationProgress = 0 // reset auth progress + // Sends carbs and bolus, or refuses an entry older than 2 min; either way the entry is cleared + state.sendConfirmedEntry(bolusAmount: bolusAmount) navigationPath.append(NavigationDestinations.acknowledgmentPending) } } else if newValue > 0 { diff --git a/Trio Watch App Extension/Views/BolusInputView.swift b/Trio Watch App Extension/Views/BolusInputView.swift index fc176a5842..54dca746d1 100644 --- a/Trio Watch App Extension/Views/BolusInputView.swift +++ b/Trio Watch App Extension/Views/BolusInputView.swift @@ -122,8 +122,7 @@ struct BolusInputView: View { Button(actionButtonLabel) { if isCarbsOnly { - state.sendCarbsRequest(state.carbsAmount) - state.carbsAmount = 0 // reset carbs in state + state.sendCarbsEntry(state.carbsAmount) navigationPath.append(NavigationDestinations.acknowledgmentPending) } else { state.bolusAmount = min(bolusAmount, effectiveBolusLimit) @@ -156,7 +155,10 @@ struct BolusInputView: View { .clipShape(Circle()) } } + // The entry's 2 min clock restarts whenever the amount changes, including a recommendation landing + .onChange(of: bolusAmount) { state.markEntryEdited() } .onAppear { + state.markEntryEdited() // Set initial bolus amount to recommended value // Only do this if user has not updated amount previously, e.g., when navigating to next and then back to this view if bolusAmount == 0 { diff --git a/Trio Watch App Extension/Views/CarbsInputView.swift b/Trio Watch App Extension/Views/CarbsInputView.swift index bc77bd3a37..97850e6966 100644 --- a/Trio Watch App Extension/Views/CarbsInputView.swift +++ b/Trio Watch App Extension/Views/CarbsInputView.swift @@ -94,7 +94,7 @@ struct CarbsInputView: View { state.carbsAmount = Int(min(carbsAmount, effectiveCarbsLimit)) navigationPath.append(NavigationDestinations.bolusInput) } else { - state.sendCarbsRequest(Int(min(carbsAmount, effectiveCarbsLimit))) + state.sendCarbsEntry(Int(min(carbsAmount, effectiveCarbsLimit))) navigationPath.append(NavigationDestinations.acknowledgmentPending) } } @@ -103,6 +103,9 @@ struct CarbsInputView: View { .disabled(!(carbsAmount > 0.0) || carbsAmount > effectiveCarbsLimit) } .background(trioBackgroundColor) + // The entry's 2 min clock restarts whenever the user touches it + .onAppear { state.markEntryEdited() } + .onChange(of: carbsAmount) { state.markEntryEdited() } .toolbar { ToolbarItem(placement: .topBarTrailing) { Image("premeal") diff --git a/Trio Watch App Extension/Views/TrioMainWatchView.swift b/Trio Watch App Extension/Views/TrioMainWatchView.swift index f8803e3d91..71a0917625 100644 --- a/Trio Watch App Extension/Views/TrioMainWatchView.swift +++ b/Trio Watch App Extension/Views/TrioMainWatchView.swift @@ -3,6 +3,7 @@ import SwiftUI import WatchKit struct TrioMainWatchView: View { + @Environment(\.scenePhase) private var scenePhase @State private var state = WatchState() // misc @@ -233,6 +234,15 @@ struct TrioMainWatchView: View { if newPath.isEmpty { // Reset conditional view navigation when returning to root view continueToBolus = false + // Back on the main screen, so no entry is in progress + state.clearEntry() + } + } + .onChange(of: scenePhase) { _, newPhase in + // An entry left for more than 2 min (wrist down, app in the background) is + // cleared and the input screens are closed, so a stale amount can't be confirmed + if newPhase == .active, state.expireStaleEntry() { + navigationPath = NavigationPath() } } } diff --git a/Trio Watch App Extension/WatchState+Requests.swift b/Trio Watch App Extension/WatchState+Requests.swift index 30799a2c88..fc8b348a6e 100644 --- a/Trio Watch App Extension/WatchState+Requests.swift +++ b/Trio Watch App Extension/WatchState+Requests.swift @@ -4,27 +4,50 @@ import WatchConnectivity // MARK: - Send Data to Phone extension WatchState { + /// Sends the entry confirmed with the crown: its carbs, if any, then the bolus. + /// Refuses an entry older than `entryLifetime` and clears it instead. + func sendConfirmedEntry(bolusAmount amount: Double, at now: Date = Date()) { + guard refuseIfExpired(at: now) == false else { return } + + let carbs = carbsAmount + clearEntry() + if carbs > 0 { + sendCarbsRequest(carbs, now) + } + sendBolusRequest(Decimal(amount)) + } + + /// Sends a carbs-only entry ("Log Carbs"). Refuses an entry older than `entryLifetime` and clears it instead. + func sendCarbsEntry(_ amount: Int, at now: Date = Date()) { + guard refuseIfExpired(at: now) == false else { return } + + clearEntry() + sendCarbsRequest(amount, now) + } + + private func refuseIfExpired(at now: Date) -> Bool { + guard !isEntryFresh(at: now) else { return false } + WatchLogger.shared.warning(.treatments, "Entry older than \(Int(Self.entryLifetime / 60)) min not sent") + clearEntry() + handleRequestNotSent(Self.entryExpiredMessage) + return true + } + /// Sends a bolus insulin request to the paired iPhone /// - Parameters: /// - amount: The insulin amount to be delivered func sendBolusRequest(_ amount: Decimal) { - guard let session = session, session.isReachable else { - WatchLogger.shared.warning(.bolus, "Bolus request not sent, phone unreachable") + guard amount > 0 else { + WatchLogger.shared.warning(.bolus, "Bolus of \(amount) U not sent, amount must be above 0") + handleRequestNotSent(Self.invalidBolusAmountMessage) return } - WatchLogger.shared.info(.bolus, "Bolus of \(amount) U requested") - - let message: [String: Any] = [ - WatchMessageKeys.bolus: amount - ] + guard let session = reachableSession(.bolus, notSentLog: "Bolus request not sent, phone unreachable") else { return } - session.sendMessage(message, replyHandler: nil) { error in - WatchLogger.shared.error(.bolus, "Sending bolus request failed", error: error) - } + WatchLogger.shared.info(.bolus, "Bolus of \(amount) U requested") - // Display pending communication animation - showCommsAnimation = true + send([WatchMessageKeys.bolus: amount], on: session, .bolus, failureLog: "Sending bolus request failed") } /// Sends a carbohydrate entry request to the paired iPhone @@ -32,10 +55,7 @@ extension WatchState { /// - amount: The amount of carbs in grams /// - date: The timestamp for the carb entry (defaults to current time) func sendCarbsRequest(_ amount: Int, _ date: Date = Date()) { - guard let session = session, session.isReachable else { - WatchLogger.shared.warning(.treatments, "Carbs request not sent, phone unreachable") - return - } + guard let session = reachableSession(.treatments, notSentLog: "Carbs request not sent, phone unreachable") else { return } WatchLogger.shared.info(.treatments, "Carbs of \(amount) g requested for \(date)") @@ -43,99 +63,91 @@ extension WatchState { WatchMessageKeys.carbs: amount, WatchMessageKeys.date: date.timeIntervalSince1970 ] - - session.sendMessage(message, replyHandler: nil) { error in - WatchLogger.shared.error(.treatments, "Sending carbs request failed", error: error) - } - - // Display pending communication animation - showCommsAnimation = true + send(message, on: session, .treatments, failureLog: "Sending carbs request failed") } /// Sends a request to cancel the current override preset to the paired iPhone func sendCancelOverrideRequest() { - guard let session = session, session.isReachable else { - WatchLogger.shared.warning(.treatments, "Override cancel not sent, phone unreachable") - return - } + guard let session = reachableSession(.treatments, notSentLog: "Override cancel not sent, phone unreachable") + else { return } WatchLogger.shared.info(.treatments, "Override cancel requested") - let message: [String: Any] = [ - WatchMessageKeys.cancelOverride: true - ] - - session.sendMessage(message, replyHandler: nil) { error in - WatchLogger.shared.error(.treatments, "Sending override cancel failed", error: error) - } - - // Display pending communication animation - showCommsAnimation = true + send([WatchMessageKeys.cancelOverride: true], on: session, .treatments, failureLog: "Sending override cancel failed") } /// Sends a request to activate an override preset to the paired iPhone /// - Parameter presetName: The name of the override preset to activate func sendActivateOverrideRequest(presetName: String) { - guard let session = session, session.isReachable else { - WatchLogger.shared.warning(.treatments, "Override request not sent, phone unreachable") - return - } + guard let session = reachableSession(.treatments, notSentLog: "Override request not sent, phone unreachable") + else { return } WatchLogger.shared.info(.treatments, "Override preset \(presetName) requested") - let message: [String: Any] = [ - WatchMessageKeys.activateOverride: presetName - ] - - session.sendMessage(message, replyHandler: nil) { error in - WatchLogger.shared.error(.treatments, "Sending override request failed", error: error) - } - - // Display pending communication animation - showCommsAnimation = true + send( + [WatchMessageKeys.activateOverride: presetName], + on: session, + .treatments, + failureLog: "Sending override request failed" + ) } /// Sends a request to cancel the current temporary target to the paired iPhone func sendCancelTempTargetRequest() { - guard let session = session, session.isReachable else { - WatchLogger.shared.warning(.treatments, "Temp target cancel not sent, phone unreachable") - return - } + guard let session = reachableSession(.treatments, notSentLog: "Temp target cancel not sent, phone unreachable") + else { return } WatchLogger.shared.info(.treatments, "Temp target cancel requested") - let message: [String: Any] = [ - WatchMessageKeys.cancelTempTarget: true - ] - - session.sendMessage(message, replyHandler: nil) { error in - WatchLogger.shared.error(.treatments, "Sending temp target cancel failed", error: error) - } - - // Display pending communication animation - showCommsAnimation = true + send( + [WatchMessageKeys.cancelTempTarget: true], + on: session, + .treatments, + failureLog: "Sending temp target cancel failed" + ) } /// Sends a request to activate a temporary target preset to the paired iPhone /// - Parameter presetName: The name of the temporary target preset to activate func sendActivateTempTargetRequest(presetName: String) { - guard let session = session, session.isReachable else { - WatchLogger.shared.warning(.treatments, "Temp target request not sent, phone unreachable") - return - } + guard let session = reachableSession(.treatments, notSentLog: "Temp target request not sent, phone unreachable") + else { return } WatchLogger.shared.info(.treatments, "Temp target preset \(presetName) requested") - let message: [String: Any] = [ - WatchMessageKeys.activateTempTarget: presetName - ] + send( + [WatchMessageKeys.activateTempTarget: presetName], + on: session, + .treatments, + failureLog: "Sending temp target request failed" + ) + } - session.sendMessage(message, replyHandler: nil) { error in - WatchLogger.shared.error(.treatments, "Sending temp target request failed", error: error) + /// The session when the phone can take a request; otherwise tells the user nothing was sent + private func reachableSession(_ category: WatchLogEntry.Category, notSentLog: String) -> WCSession? { + guard let session = session, session.isReachable else { + WatchLogger.shared.warning(category, notSentLog) + handleRequestNotSent(Self.phoneUnreachableMessage) + return nil } + return session + } + + /// Sends a treatment request and waits for the phone's acknowledgment + private func send( + _ message: [String: Any], + on session: WCSession, + _ category: WatchLogEntry.Category, + failureLog: String + ) { + beginAwaitingAcknowledgment() - // Display pending communication animation - showCommsAnimation = true + session.sendMessage(message, replyHandler: nil) { error in + WatchLogger.shared.error(category, failureLog, error: error) + DispatchQueue.main.async { + self.handleMissingAcknowledgment() + } + } } /// Sends a request to calculate a bolus recommendation based on the current carbs amount diff --git a/Trio Watch App Extension/WatchState.swift b/Trio Watch App Extension/WatchState.swift index 25558fb583..30e223cf79 100644 --- a/Trio Watch App Extension/WatchState.swift +++ b/Trio Watch App Extension/WatchState.swift @@ -36,6 +36,14 @@ import WatchConnectivity var proteinAmount: Int = 0 var bolusAmount: Double = 0.0 var confirmationProgress: Double = 0.0 + /// Last time the user opened or changed a carbs/bolus entry; nil when no entry is in progress + @ObservationIgnored private(set) var entryEditedAt: Date? + /// An entry left alone for longer than this is cleared and never sent + static let entryLifetime: TimeInterval = 2 * 60 + static let entryExpiredMessage = String( + localized: "Entry expired after 2 minutes. Nothing was sent. Please enter it again.", + comment: "Watch message when a carbs or bolus entry was too old to send" + ) // Safety limits var maxBolus: Decimal = 10 @@ -61,6 +69,22 @@ import WatchConnectivity var acknowledgementStatus: AcknowledgementStatus = .pending var acknowledgmentMessage: String = "" var shouldNavigateToRoot: Bool = true + @ObservationIgnored private var acknowledgmentTimeoutWorkItem: DispatchWorkItem? + + /// Long enough for a pump to accept a bolus, short enough that the user isn't left guessing + static let acknowledgmentTimeout: TimeInterval = 15 + static let noAnswerMessage = String( + localized: "No answer from iPhone. Check Trio on your iPhone before trying again.", + comment: "Watch message when the phone did not confirm a request" + ) + static let phoneUnreachableMessage = String( + localized: "iPhone not reachable. Nothing was sent.", + comment: "Watch message when a request could not be sent to the phone" + ) + static let invalidBolusAmountMessage = String( + localized: "Error! Bolus amount must be greater than 0 U.", + comment: "Error message when a bolus of zero or less is requested" + ) // Bolus calculation progress var showBolusCalculationProgress: Bool = false @@ -107,9 +131,72 @@ import WatchConnectivity } } + // MARK: - Entry lifetime + + /// Starts or restarts the entry's clock. Call when an input screen appears and on every amount change. + func markEntryEdited(at now: Date = Date()) { + entryEditedAt = now + } + + /// True only for an entry changed within `entryLifetime`; an entry with no recorded edit is never fresh + func isEntryFresh(at now: Date = Date()) -> Bool { + guard let entryEditedAt else { return false } + return now.timeIntervalSince(entryEditedAt) <= Self.entryLifetime + } + + /// Ends the entry: clears amounts, the recommendation and the confirmation progress + func clearEntry() { + bolusAmount = 0 + carbsAmount = 0 + fatAmount = 0 + proteinAmount = 0 + recommendedBolus = 0 + confirmationProgress = 0 + entryEditedAt = nil + } + + /// Clears an entry older than `entryLifetime`. Returns true when it did, so the caller can + /// leave the input screens. + @discardableResult func expireStaleEntry(at now: Date = Date()) -> Bool { + guard entryEditedAt != nil, !isEntryFresh(at: now) else { return false } + WatchLogger.shared.info(.treatments, "Entry older than \(Int(Self.entryLifetime / 60)) min cleared") + clearEntry() + return true + } + // MARK: – Handle Acknowledgement Messages FROM Phone + /// Shows "Processing…" and starts the clock for the phone's answer + func beginAwaitingAcknowledgment(timeout: TimeInterval = WatchState.acknowledgmentTimeout) { + acknowledgmentTimeoutWorkItem?.cancel() + acknowledgementStatus = .pending + showCommsAnimation = true + + let workItem = DispatchWorkItem { [weak self] in + self?.handleMissingAcknowledgment() + } + acknowledgmentTimeoutWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: workItem) + } + + /// No answer arrived in time, or sending reported an error. Sending can report an error and + /// still deliver the request, so this tells the user to check the phone rather than claiming + /// the request failed. + func handleMissingAcknowledgment() { + guard showCommsAnimation else { return } + WatchLogger.shared.warning(.treatments, "No answer from the phone for the last request") + handleAcknowledgment(success: false, message: Self.noAnswerMessage) + } + + /// The request was refused on the watch and never sent + func handleRequestNotSent(_ message: String) { + handleAcknowledgment(success: false, message: message) + } + func handleAcknowledgment(success: Bool, message: String, isFinal: Bool = true) { + acknowledgmentTimeoutWorkItem?.cancel() + acknowledgmentTimeoutWorkItem = nil + if success { acknowledgementStatus = .success acknowledgmentMessage = message @@ -251,21 +338,22 @@ import WatchConnectivity /// Called when the reachability status of the paired iPhone changes /// Updates the local reachability status func sessionReachabilityDidChange(_ session: WCSession) { + let isReachable = session.isReachable DispatchQueue.main.async { - if WatchLogger.shared.shouldLog("reachability", state: "\(session.isReachable)") { - WatchLogger.shared.debug(.connectivity, "Phone reachable: \(session.isReachable)") - } - - if session.isReachable { - self.forceConditionalWatchStateUpdate() + self.handleReachabilityChange(isReachable: isReachable) + } + } - // reset input amounts - self.bolusAmount = 0 - self.carbsAmount = 0 + /// Reachability flips on every wrist raise, so this must leave entries in progress alone: + /// clearing `bolusAmount` here turned a confirmed bolus into a 0 U request. + /// The main view resets the entry fields when it appears. + func handleReachabilityChange(isReachable: Bool) { + if WatchLogger.shared.shouldLog("reachability", state: "\(isReachable)") { + WatchLogger.shared.debug(.connectivity, "Phone reachable: \(isReachable)") + } - // reset auth progress - self.confirmationProgress = 0 - } + if isReachable { + forceConditionalWatchStateUpdate() } } diff --git a/Trio Watch App Tests/Unit Tests.swift b/Trio Watch App Tests/Unit Tests.swift index 0d0cde0eb4..996dfb5a53 100644 --- a/Trio Watch App Tests/Unit Tests.swift +++ b/Trio Watch App Tests/Unit Tests.swift @@ -1,11 +1,15 @@ -import SwiftUICore +import SwiftUI import Testing @testable import Trio_Watch_App -import XCTest -@Suite("Watch App Tests") final class TrioWatchAppTests { +@Suite("Watch App Tests", .serialized) @MainActor final class TrioWatchAppTests { var watchState = WatchState() + /// Lets main-queue work scheduled by WatchState (asyncAfter, async) run + private func waitForMainQueue(_ seconds: Double) async throws { + try await Task.sleep(for: .milliseconds(Int(seconds * 1000))) + } + // MARK: - Color Conversion Tests @Test("Hex string to color conversion") func testHexStringToColor() throws { @@ -40,61 +44,233 @@ import XCTest @Test("Carb limits have correct default values") func testCarbLimits() throws { #expect(watchState.maxCarbs == Decimal(250)) - #expect(watchState.maxCOB == Decimal(120)) } - @Test("Bolus cancellation resets all related values") func testBolusCancellation() throws { - // Given - watchState.bolusProgress = 0.5 - watchState.activeBolusAmount = 5.0 - watchState.isBolusCanceled = false + @Test("Acknowledgment states transition correctly") func testAcknowledgmentStates() async throws { + // Given - Initial state + #expect(watchState.acknowledgementStatus == .pending) + #expect(!watchState.showAcknowledgmentBanner) - // When - watchState.sendCancelBolusRequest() + // When/Then - Success acknowledgment + watchState.handleAcknowledgment(success: true, message: "Success") + #expect(watchState.acknowledgementStatus == .success) + #expect(watchState.acknowledgmentMessage == "Success") + try await waitForMainQueue(0.5) + #expect(watchState.showAcknowledgmentBanner) - // Then - #expect(watchState.isBolusCanceled) - #expect(watchState.bolusProgress == 0) - #expect(watchState.activeBolusAmount == 0) + // When/Then - Failure acknowledgment + watchState.handleAcknowledgment(success: false, message: "Error") + #expect(watchState.acknowledgementStatus == .failure) + #expect(watchState.acknowledgmentMessage == "Error") } - @Test("Meal bolus combo state transitions work correctly") func testMealBolusComboState() throws { - // Given - Initial state - #expect(!watchState.isMealBolusCombo) - #expect(watchState.mealBolusStep == .savingCarbs) + // MARK: - Reachability must not touch entries in progress - // When - Setup meal bolus combo - watchState.carbsAmount = 30 + @Test("Phone reconnecting keeps the bolus being confirmed") func testReconnectKeepsBolusEntry() throws { + // Given - the user is on the confirm screen, partway through dialing the crown watchState.bolusAmount = 3.0 + watchState.carbsAmount = 20 + watchState.confirmationProgress = 0.5 + + // When - raising the wrist reconnects the phone + watchState.handleReachabilityChange(isReachable: false) + watchState.handleReachabilityChange(isReachable: true) + + // Then - the confirmed amount is still the one the user entered, not 0 U + #expect(watchState.bolusAmount == 3.0) + #expect(watchState.carbsAmount == 20) + #expect(watchState.confirmationProgress == 0.5) + } - // Then - Test state transitions - watchState.handleAcknowledgment(success: true, message: "Saving carbs...", isFinal: false) - #expect(watchState.isMealBolusCombo) - #expect(watchState.mealBolusStep == .savingCarbs) + // MARK: - Bolus requests that must not be sent - watchState.handleAcknowledgment(success: true, message: "Enacting bolus...", isFinal: false) - #expect(watchState.isMealBolusCombo) - #expect(watchState.mealBolusStep == .enactingBolus) + @Test("Zero bolus is refused on the watch", arguments: [ + Decimal(0), + Decimal(-1) + ]) func testZeroBolusRefused(amount: Decimal) async throws { + watchState.sendBolusRequest(amount) - watchState.handleAcknowledgment(success: true, message: "Carbs and bolus logged successfully", isFinal: true) - #expect(!watchState.isMealBolusCombo) + #expect(watchState.acknowledgementStatus == .failure) + #expect(watchState.acknowledgmentMessage == WatchState.invalidBolusAmountMessage) + try await waitForMainQueue(0.5) + #expect(!watchState.showCommsAnimation) + #expect(watchState.showAcknowledgmentBanner) } - @Test("Acknowledgment states transition correctly") func testAcknowledgmentStates() throws { - // Given - Initial state - #expect(watchState.acknowledgementStatus == .pending) - #expect(!watchState.showAcknowledgmentBanner) + @Test("Bolus with the phone unreachable says nothing was sent") func testUnreachableBolus() async throws { + watchState.session = nil - // When/Then - Success acknowledgment - watchState.handleAcknowledgment(success: true, message: "Success") - #expect(watchState.acknowledgementStatus == .success) + watchState.sendBolusRequest(3) + + #expect(watchState.acknowledgementStatus == .failure) + #expect(watchState.acknowledgmentMessage == WatchState.phoneUnreachableMessage) + try await waitForMainQueue(0.5) + #expect(!watchState.showCommsAnimation) #expect(watchState.showAcknowledgmentBanner) - #expect(watchState.acknowledgmentMessage == "Success") + } - // When/Then - Failure acknowledgment - watchState.handleAcknowledgment(success: false, message: "Error") + @Test("Other treatment requests with the phone unreachable say nothing was sent") func testUnreachableTreatments() throws { + watchState.session = nil + let requests: [() -> Void] = [ + { self.watchState.sendCarbsRequest(20) }, + { self.watchState.sendCancelOverrideRequest() }, + { self.watchState.sendActivateOverrideRequest(presetName: "Exercise") }, + { self.watchState.sendCancelTempTargetRequest() }, + { self.watchState.sendActivateTempTargetRequest(presetName: "Eating soon") } + ] + + for request in requests { + watchState.acknowledgementStatus = .pending + watchState.acknowledgmentMessage = "" + request() + #expect(watchState.acknowledgementStatus == .failure) + #expect(watchState.acknowledgmentMessage == WatchState.phoneUnreachableMessage) + } + } + + // MARK: - Waiting for the phone's answer + + @Test("Processing ends with a check-your-phone message when the phone never answers") func testAcknowledgmentTimeout() async throws { + watchState.beginAwaitingAcknowledgment(timeout: 0.1) + #expect(watchState.showCommsAnimation) + #expect(watchState.acknowledgementStatus == .pending) + + try await waitForMainQueue(0.6) + + #expect(!watchState.showCommsAnimation) #expect(watchState.acknowledgementStatus == .failure) + #expect(watchState.acknowledgmentMessage == WatchState.noAnswerMessage) #expect(watchState.showAcknowledgmentBanner) - #expect(watchState.acknowledgmentMessage == "Error") + } + + @Test("An answer from the phone cancels the timeout") func testAcknowledgmentCancelsTimeout() async throws { + watchState.beginAwaitingAcknowledgment(timeout: 0.1) + + watchState.handleAcknowledgment(success: true, message: "Bolus enacted successfully.") + try await waitForMainQueue(0.6) + + #expect(watchState.acknowledgementStatus == .success) + #expect(watchState.acknowledgmentMessage == "Bolus enacted successfully.") + } + + @Test("A send error ends processing with the check-your-phone message") func testSendErrorEndsProcessing() async throws { + watchState.beginAwaitingAcknowledgment(timeout: 60) + + // What the sendMessage error handler does + watchState.handleMissingAcknowledgment() + + #expect(watchState.acknowledgementStatus == .failure) + #expect(watchState.acknowledgmentMessage == WatchState.noAnswerMessage) + try await waitForMainQueue(0.5) + #expect(!watchState.showCommsAnimation) + } + + @Test("A late timeout does not overwrite an answer already shown") func testMissingAcknowledgmentAfterAnswer() async throws { + watchState.beginAwaitingAcknowledgment(timeout: 60) + watchState.handleAcknowledgment(success: true, message: "Bolus enacted successfully.") + try await waitForMainQueue(0.3) + + watchState.handleMissingAcknowledgment() + + #expect(watchState.acknowledgementStatus == .success) + #expect(watchState.acknowledgmentMessage == "Bolus enacted successfully.") + } + + // MARK: - Entries expire after 2 minutes + + private let entryStart = Date(timeIntervalSince1970: 1_800_000_000) + + /// An entry on the confirm screen: 20 g carbs and 3 U, last edited at `entryStart` + private func enterMealBolus() { + watchState.carbsAmount = 20 + watchState.bolusAmount = 3.0 + watchState.confirmationProgress = 0.5 + watchState.markEntryEdited(at: entryStart) + } + + private func expectEntryCleared() { + #expect(watchState.bolusAmount == 0) + #expect(watchState.carbsAmount == 0) + #expect(watchState.confirmationProgress == 0) + #expect(watchState.entryEditedAt == nil) + } + + @Test("An entry is fresh for 2 minutes after its last edit") func testEntryFreshness() throws { + #expect(!watchState.isEntryFresh(at: entryStart), "no entry has been edited yet") + + watchState.markEntryEdited(at: entryStart) + + #expect(watchState.isEntryFresh(at: entryStart.addingTimeInterval(119))) + #expect(watchState.isEntryFresh(at: entryStart.addingTimeInterval(120))) + #expect(!watchState.isEntryFresh(at: entryStart.addingTimeInterval(121))) + + // Editing again restarts the clock + watchState.markEntryEdited(at: entryStart.addingTimeInterval(100)) + #expect(watchState.isEntryFresh(at: entryStart.addingTimeInterval(200))) + } + + @Test("Confirming an entry older than 2 minutes sends nothing and clears it") + func testExpiredEntryNotSent() throws { + enterMealBolus() + + watchState.sendConfirmedEntry(bolusAmount: 3.0, at: entryStart.addingTimeInterval(121)) + + // The expired message, not "phone unreachable", shows nothing was attempted + #expect(watchState.acknowledgementStatus == .failure) + #expect(watchState.acknowledgmentMessage == WatchState.entryExpiredMessage) + expectEntryCleared() + } + + @Test("Confirming an entry with no recorded edit sends nothing") func testUneditedEntryNotSent() throws { + watchState.bolusAmount = 3.0 + + watchState.sendConfirmedEntry(bolusAmount: 3.0, at: entryStart) + + #expect(watchState.acknowledgmentMessage == WatchState.entryExpiredMessage) + expectEntryCleared() + } + + @Test("Confirming a fresh entry sends it and clears it") func testFreshEntrySent() throws { + enterMealBolus() + // No phone in tests: reaching the "unreachable" refusal proves the send was attempted + watchState.session = nil + + watchState.sendConfirmedEntry(bolusAmount: 3.0, at: entryStart.addingTimeInterval(60)) + + #expect(watchState.acknowledgmentMessage == WatchState.phoneUnreachableMessage) + expectEntryCleared() + } + + @Test("Logging carbs older than 2 minutes sends nothing") func testExpiredCarbsNotSent() throws { + watchState.markEntryEdited(at: entryStart) + + watchState.sendCarbsEntry(20, at: entryStart.addingTimeInterval(121)) + + #expect(watchState.acknowledgmentMessage == WatchState.entryExpiredMessage) + expectEntryCleared() + } + + @Test("Logging fresh carbs sends them") func testFreshCarbsSent() throws { + watchState.markEntryEdited(at: entryStart) + watchState.session = nil + + watchState.sendCarbsEntry(20, at: entryStart.addingTimeInterval(60)) + + #expect(watchState.acknowledgmentMessage == WatchState.phoneUnreachableMessage) + expectEntryCleared() + } + + @Test("Returning to the app clears an entry older than 2 minutes") func testStaleEntryExpiresOnReturn() throws { + enterMealBolus() + + #expect(!watchState.expireStaleEntry(at: entryStart.addingTimeInterval(120))) + #expect(watchState.bolusAmount == 3.0) + + #expect(watchState.expireStaleEntry(at: entryStart.addingTimeInterval(121))) + expectEntryCleared() + } + + @Test("Returning to the app with no entry in progress changes nothing") func testNoEntryOnReturn() throws { + #expect(!watchState.expireStaleEntry(at: entryStart)) } } diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index c1892503cc..e7523f0db3 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -518,6 +518,7 @@ B015AFF52E600000000D7351 /* AdjustmentManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B015AFF42E600000000D7351 /* AdjustmentManagerTests.swift */; }; B015AFE32E500000000D7351 /* BolusSafetyValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B015AFE12E500000000D7351 /* BolusSafetyValidator.swift */; }; B015AFE52E500000000D7351 /* BolusSafetyValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B015AFE42E500000000D7351 /* BolusSafetyValidatorTests.swift */; }; + E1B0A5D00000000000000002 /* EnactBolusAmountTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B0A5D00000000000000001 /* EnactBolusAmountTests.swift */; }; B63C9D934FC54853BC4EF29A /* NightscoutUploadSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10E61A0885164081B57C357C /* NightscoutUploadSerializer.swift */; }; B6E925132EB3932A0076D719 /* OmnipodKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B6E925122EB3932A0076D719 /* OmnipodKit.framework */; }; B6E925142EB3932A0076D719 /* OmnipodKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = B6E925122EB3932A0076D719 /* OmnipodKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -1837,6 +1838,7 @@ B015AFF42E600000000D7351 /* AdjustmentManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjustmentManagerTests.swift; sourceTree = ""; }; B015AFE12E500000000D7351 /* BolusSafetyValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusSafetyValidator.swift; sourceTree = ""; }; B015AFE42E500000000D7351 /* BolusSafetyValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusSafetyValidatorTests.swift; sourceTree = ""; }; + E1B0A5D00000000000000001 /* EnactBolusAmountTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnactBolusAmountTests.swift; sourceTree = ""; }; B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsSearchTests.swift; sourceTree = ""; }; B3919BBB515547118D684CF2 /* AlgorithmSuggestedBolusTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AlgorithmSuggestedBolusTests.swift; sourceTree = ""; }; B3919BBB515547118D684CE2 /* DosingModeClampTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DosingModeClampTests.swift; sourceTree = ""; }; @@ -4383,6 +4385,7 @@ isa = PBXGroup; children = ( B015AFE42E500000000D7351 /* BolusSafetyValidatorTests.swift */, + E1B0A5D00000000000000001 /* EnactBolusAmountTests.swift */, ); path = BolusSafetyTests; sourceTree = ""; @@ -7168,6 +7171,7 @@ 41740E936552456AAC0EDCC3 /* MaxIOBSuggestionTests.swift in Sources */, 41740E936552456AAC0EDBC3 /* DosingModeMigrationTests.swift in Sources */, B015AFE52E500000000D7351 /* BolusSafetyValidatorTests.swift in Sources */, + E1B0A5D00000000000000002 /* EnactBolusAmountTests.swift in Sources */, 9A3C0DE10000000000000B06 /* WatchCommandTestDoubles.swift in Sources */, 9A3C0DE10000000000000B07 /* WatchCommandRequestCacheTests.swift in Sources */, 9A3C0DE10000000000000B08 /* WatchCommandProcessorTests.swift in Sources */, diff --git a/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved index 93a3e6ec7b..bdcee05b81 100644 --- a/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -295,7 +295,7 @@ "location" : "https://github.com/tidepool-org/TidepoolKit", "state" : { "branch" : "dev", - "revision" : "290bd0431c55e8d0e0e542b163de13b1f07b77d9" + "revision" : "0f127debeecf5b3b378f7d7100c8e3d7e2d99615" } } ], diff --git a/Trio/Resources/InfoPlist.xcstrings b/Trio/Resources/InfoPlist.xcstrings index fbf2be848f..bcba902aef 100644 --- a/Trio/Resources/InfoPlist.xcstrings +++ b/Trio/Resources/InfoPlist.xcstrings @@ -1,282 +1,6 @@ { "sourceLanguage" : "en", "strings" : { - "CFBundleDisplayName" : { - "comment" : "Bundle display name", - "extractionState" : "extracted_with_value", - "localizations" : { - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "da" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "es" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "he" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "nb-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "ro" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "ru" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "sv" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "tr" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "uk" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - } - } - }, - "CFBundleName" : { - "comment" : "Bundle name", - "extractionState" : "extracted_with_value", - "localizations" : { - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "da" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "es" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "he" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "nb-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "ro" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "ru" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "sv" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "tr" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "uk" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - } - } - }, "NFCReaderUsageDescription" : { "comment" : "Privacy - NFC Scan Usage Description", "extractionState" : "extracted_with_value", diff --git a/Trio/Sources/APS/APSManager.swift b/Trio/Sources/APS/APSManager.swift index 9645009b80..584a066220 100644 --- a/Trio/Sources/APS/APSManager.swift +++ b/Trio/Sources/APS/APSManager.swift @@ -688,7 +688,10 @@ final class BaseAPSManager: APSManager, Injectable { } func enactBolus(amount: Double, isSMB: Bool, callback: ((Bool, String) -> Void)?) async { - if amount <= 0 { + // Answer every caller: a silent return here left the watch waiting on "Processing…" forever + guard amount > 0 else { + warning(.bolus, "Bolus of \(amount) U not enacted, amount must be above 0") + callback?(false, Self.invalidBolusAmountMessage) return } @@ -715,6 +718,12 @@ final class BaseAPSManager: APSManager, Injectable { units: adjustPumpedVolumeToConcentration(amount).deliverable ) + guard roundedAmount > 0 else { + warning(.bolus, "Bolus of \(amount) U not enacted, rounds to 0 U on this pump") + callback?(false, Self.invalidBolusAmountMessage) + return + } + debug(.bolus, "Enacting bolus \(roundedAmount) U, \(isSMB ? "automatic" : "manual")") do { @@ -783,6 +792,11 @@ final class BaseAPSManager: APSManager, Injectable { } } + static let invalidBolusAmountMessage = String( + localized: "Error! Bolus amount must be greater than 0 U.", + comment: "Error message when a bolus of zero or less is requested" + ) + func cancelBolus(_ callback: ((Bool, String) -> Void)?) async { guard let pump = pumpManager, pump.status.pumpStatus.bolusing else { return } debug(.bolus, "Cancelling bolus") diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index 2323913e85..a8e21b9891 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -1238,6 +1238,9 @@ }, "This build of Trio can't send reports. Please share your logs on Discord or Facebook instead." : { + }, + "Tidepool Login Required" : { + }, "Title (optional)" : { @@ -1250,6 +1253,9 @@ }, "Up to %lld screenshots or photos." : { + }, + "Uploads to Tidepool are paused. Tap to log in." : { + }, "You've sent several reports today. Please try again tomorrow." : { @@ -131900,6 +131906,9 @@ } } }, + "Entry expired after 2 minutes. Nothing was sent. Please enter it again." : { + "comment" : "Watch message when a carbs or bolus entry was too old to send" + }, "Error" : { "comment" : "Error title", "localizations" : { @@ -132559,6 +132568,9 @@ } } }, + "Error! Bolus amount must be greater than 0 U." : { + "comment" : "Error message when a bolus of zero or less is requested" + }, "Error! Bolus cancellation failed with error: %@" : { "comment" : "Error message for canceling a bolus", "localizations" : { @@ -176363,6 +176375,9 @@ } } }, + "iPhone not reachable. Nothing was sent." : { + "comment" : "Watch message when a request could not be sent to the phone" + }, "is running" : { "localizations" : { "bg" : { @@ -211050,6 +211065,9 @@ } } }, + "No answer from iPhone. Check Trio on your iPhone before trying again." : { + "comment" : "Watch message when the phone did not confirm a request" + }, "No Bolus Data" : { "localizations" : { "bg" : { diff --git a/Trio/Sources/Modules/Home/HomeStateModel.swift b/Trio/Sources/Modules/Home/HomeStateModel.swift index 873a315882..4f60f48ebc 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel.swift @@ -23,6 +23,7 @@ extension Home { @ObservationIgnored @Injected() var pluginCGMManager: PluginManager! @ObservationIgnored @Injected() var fetchGlucoseManager: FetchGlucoseManager! @ObservationIgnored @Injected() var nightscoutManager: NightscoutManager! + @ObservationIgnored @Injected() var tidepoolManager: TidepoolManager! @ObservationIgnored @Injected() var determinationStorage: DeterminationStorage! @ObservationIgnored @Injected() var glucoseStorage: GlucoseStorage! @ObservationIgnored @Injected() var carbsStorage: CarbsStorage! @@ -139,6 +140,7 @@ extension Home { var pumpStatusHighlightMessage: String? var pumpStatusBadgeImage: UIImage? var pumpStatusBadgeColor: Color? + var tidepoolLoginRequired = false var cgmAvailable: Bool = false var cgmDisplayState: CgmDisplayState? var cgmProgressHighlight: DeviceLifecycleProgress? @@ -694,6 +696,11 @@ extension Home { .weakAssign(to: \.lastLoopDate, on: self) .store(in: &lifetime) + tidepoolManager.tidepoolLoginRequired + .receive(on: DispatchQueue.main) + .weakAssign(to: \.tidepoolLoginRequired, on: self) + .store(in: &lifetime) + apsManager.pumpName .receive(on: DispatchQueue.main) .weakAssign(to: \.pumpName, on: self) diff --git a/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift b/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift index f7f9cc4aa5..3ce09fe7f3 100644 --- a/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift +++ b/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift @@ -216,6 +216,7 @@ extension Home.RootView { pumpTimeMismatch: state.pumpStatusBadgeImage != nil, lastGlucoseDate: state.glucoseFromPersistence.last?.date, maxIOB: state.maxIOB, + tidepoolLoginRequired: state.tidepoolLoginRequired, hasOverride: overrideString != nil, hasTempTarget: tempTargetString != nil, hasTempProfile: activeProfile.first?.expiresAt != nil, @@ -386,6 +387,16 @@ extension Home.RootView { openMaxIOBSetting() } .transition(.blurReplace) + case .tidepoolLoginRequired: + panelBanner( + systemImage: "icloud.slash.fill", + title: String(localized: "Tidepool Login Required"), + subtitle: String(localized: "Uploads to Tidepool are paused. Tap to log in."), + tint: .orange + ) { + openTidepoolSetting() + } + .transition(.blurReplace) case .whatsNew: panelBanner( systemImage: "sparkles", @@ -446,6 +457,11 @@ extension Home.RootView { )) } + func openTidepoolSetting() { + selectedTab = 4 + settingsPath.append(SearchResultTarget(screen: .tidepoolConfig, scrollLabel: "Tidepool Integration".localized)) + } + func statsDistributionBar(_ segments: [(color: Color, fraction: CGFloat)]) -> some View { GeometryReader { g in let spacing: CGFloat = 2 diff --git a/Trio/Sources/Modules/Home/View/MultiUsePanelState.swift b/Trio/Sources/Modules/Home/View/MultiUsePanelState.swift index 949e8a2345..a8c414b4aa 100644 --- a/Trio/Sources/Modules/Home/View/MultiUsePanelState.swift +++ b/Trio/Sources/Modules/Home/View/MultiUsePanelState.swift @@ -17,6 +17,7 @@ enum MultiUsePanelState: Equatable { case pumpTimeMismatch case cgmStale case maxIOBZero + case tidepoolLoginRequired case adjustments(ActiveAdjustment) case whatsNew case dosingModeLimited(DosingMode) @@ -31,6 +32,7 @@ enum MultiUsePanelState: Equatable { pumpTimeMismatch: Bool, lastGlucoseDate: Date?, maxIOB: Decimal, + tidepoolLoginRequired: Bool, hasOverride: Bool, hasTempTarget: Bool, hasTempProfile: Bool, @@ -47,6 +49,8 @@ enum MultiUsePanelState: Equatable { return .dosingModeLimited(dosingMode) } if maxIOB <= 0 { return .maxIOBZero } + // Uploads only, so it yields to every dosing-related warning above. + if tidepoolLoginRequired { return .tidepoolLoginRequired } if hasOverride, hasTempTarget { return .adjustments(.dual) } if hasOverride { return .adjustments(.override) } if hasTempTarget { return .adjustments(.tempTarget) } diff --git a/Trio/Sources/Modules/Settings/View/TidepoolStartView.swift b/Trio/Sources/Modules/Settings/View/TidepoolStartView.swift index 0f9e0fc9dd..4d75cdbc58 100644 --- a/Trio/Sources/Modules/Settings/View/TidepoolStartView.swift +++ b/Trio/Sources/Modules/Settings/View/TidepoolStartView.swift @@ -30,12 +30,14 @@ struct TidepoolStartView: BaseView { state.setupTidepool.toggle() } label: { + let isAuthenticated = !state.provider.tidepoolManager.tidepoolLoginRequired.value HStack { - Text("Connected to Tidepool").font(.title3) + (isAuthenticated ? Text("Connected to Tidepool") : Text("Tidepool Login Required")) + .font(.title3) ZStack { Image(systemName: "network") - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green).font(.caption2) + Image(systemName: isAuthenticated ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundColor(isAuthenticated ? .green : .red).font(.caption2) .offset(x: 9, y: 6) } .accessibilityHidden(true) diff --git a/Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift b/Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift index 629a010569..9cb6bf61bd 100644 --- a/Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift +++ b/Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift @@ -13,7 +13,7 @@ import LoopKit enum AlertCatalogRegistry { static let entries: [Alert.CatalogEntry] = omniEntries + minimedEntries + danaEntries + medtrumEntries + libreLoopEntries + g7Entries - + syaiEntries + trioAlgorithmEntries + + syaiEntries + trioAlgorithmEntries + tidepoolEntries static func lookup(_ identifier: Alert.Identifier) -> Alert.CatalogEntry? { if let exact = entries.first(where: { $0.identifier == identifier }) { @@ -350,6 +350,21 @@ private extension AlertCatalogRegistry { ] } +// MARK: - Tidepool + +private extension AlertCatalogRegistry { + static let tidepoolEntries: [Alert.CatalogEntry] = [ + addEntry( + "TidepoolService", + "authentication-needed", + .active, + "Tidepool Service Authorization", + "Services", + .tidepoolLoginRequired + ) + ] +} + // MARK: - Helpers private extension AlertCatalogRegistry { diff --git a/Trio/Sources/Services/Alerts/AlertCatalogVendor.swift b/Trio/Sources/Services/Alerts/AlertCatalogVendor.swift index d4cd35709a..6524377c1c 100644 --- a/Trio/Sources/Services/Alerts/AlertCatalogVendor.swift +++ b/Trio/Sources/Services/Alerts/AlertCatalogVendor.swift @@ -43,6 +43,9 @@ extension Alert { case notLooping case algorithmError case glucoseDataStale + // Service concepts. Surface through `TrioAlertManager` via + // `TidepoolManager.issueAlert(_:)`. + case tidepoolLoginRequired /// Plugin-unique alarm that doesn't merge with anything else. case unspecified @@ -80,6 +83,7 @@ extension Alert { case .notLooping: return String(localized: "Trio Not Looping") case .algorithmError: return String(localized: "Algorithm Error") case .glucoseDataStale: return String(localized: "Glucose Data Stale") + case .tidepoolLoginRequired: return String(localized: "Tidepool Login Required") case .unspecified: return "" } } diff --git a/Trio/Sources/Services/Network/TidepoolManager.swift b/Trio/Sources/Services/Network/TidepoolManager.swift index 1214b1eadf..ea87f5ec10 100644 --- a/Trio/Sources/Services/Network/TidepoolManager.swift +++ b/Trio/Sources/Services/Network/TidepoolManager.swift @@ -13,6 +13,7 @@ protocol TidepoolManager { func addTidepoolService(service: Service) func getTidepoolServiceUI() -> ServiceUI? func getTidepoolPluginHost() -> PluginHost? + var tidepoolLoginRequired: CurrentValueSubject { get } func uploadCarbs() async func deleteCarbs(withSyncId id: UUID, carbs: Decimal, at: Date, enteredBy: String) func uploadInsulin() async @@ -32,6 +33,13 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { @Injected() private var apsManager: APSManager! @Injected() private var settingsManager: SettingsManager! @Injected() private var notificationCenter: NotificationCenter! + @Injected() private var trioAlertManager: TrioAlertManager! + + /// Raised by `TidepoolService` when its login session is cleared + static let authenticationAlertIdentifier = LoopKit.Alert.Identifier( + managerIdentifier: TidepoolService.serviceIdentifier, + alertIdentifier: "authentication-needed" + ) // Lazy access to avoid circular dependency (TidepoolManager ↔ FetchGlucoseManager) private var resolver: Resolver? @@ -70,6 +78,8 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { let viewContext = CoreDataStack.shared.persistentContainer.viewContext private var subscriptions = Set() + private var sessionSubscription: AnyCancellable? + let tidepoolLoginRequired = CurrentValueSubject(false) // MARK: - Upload triggers @@ -130,6 +140,7 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { }() @PersistedProperty(key: "TidepoolState") var rawTidepoolManager: Service.RawValue? + @PersistedProperty(key: "TidepoolLoginRequired") var persistedLoginRequired: Bool? init(resolver: Resolver) { self.resolver = resolver @@ -175,15 +186,35 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { broadcaster.register(PreferencesObserver.self, observer: self) } + /// Persisted because `TidepoolService` can't be restored without a session, so the flag must outlive a relaunch + private func setLoginRequired(_ required: Bool) { + persistedLoginRequired = required ? true : nil + tidepoolLoginRequired.send(required) + } + /// Loads the Tidepool service from saved state fileprivate func loadTidepoolManager() { + tidepoolLoginRequired.send(persistedLoginRequired ?? false) if let rawTidepoolManager = rawTidepoolManager { tidepoolService = tidepoolServiceFromRaw(rawTidepoolManager) tidepoolService?.serviceDelegate = self tidepoolService?.stateDelegate = self + observeTidepoolSession() } } + /// Tracks whether the Tidepool service lost its login; retracts the re-login alert once it is back + private func observeTidepoolSession() { + sessionSubscription = (tidepoolService as? TidepoolService)?.$session + .sink { [weak self] session in + guard let self else { return } + self.setLoginRequired(session == nil) + if session != nil { + self.trioAlertManager.retractAlert(identifier: Self.authenticationAlertIdentifier) + } + } + } + /// Returns the Tidepool service UI if available func getTidepoolServiceUI() -> ServiceUI? { tidepoolService as? ServiceUI @@ -197,6 +228,9 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { /// Adds a Tidepool service func addTidepoolService(service: Service) { tidepoolService = service as? RemoteDataService + tidepoolService?.serviceDelegate = self + tidepoolService?.stateDelegate = self + observeTidepoolSession() } /// Loads the Tidepool service from raw stored data @@ -261,9 +295,13 @@ extension BaseTidepoolManager: ServiceDelegate { return semanticVersion } - func issueAlert(_: LoopKit.Alert) {} + func issueAlert(_ alert: LoopKit.Alert) { + trioAlertManager.issueAlert(alert) + } - func retractAlert(identifier _: LoopKit.Alert.Identifier) {} + func retractAlert(identifier: LoopKit.Alert.Identifier) { + trioAlertManager.retractAlert(identifier: identifier) + } func enactRemoteOverride(name _: String, durationTime _: TimeInterval?, remoteAddress _: String) async throws {} @@ -877,7 +915,12 @@ extension BaseTidepoolManager: StatefulPluggableDelegate { func pluginDidUpdateState(_: LoopKit.StatefulPluggable) {} func pluginWantsDeletion(_: LoopKit.StatefulPluggable) { + // deleteService() logs out right after, which would raise the re-login alert + tidepoolService?.serviceDelegate = nil tidepoolService = nil + sessionSubscription = nil + setLoginRequired(false) + trioAlertManager.retractAlert(identifier: Self.authenticationAlertIdentifier) } } diff --git a/TrioTests/AlertCatalogRegistryEntriesTests.swift b/TrioTests/AlertCatalogRegistryEntriesTests.swift index b91d0a2095..cd81a7fefd 100644 --- a/TrioTests/AlertCatalogRegistryEntriesTests.swift +++ b/TrioTests/AlertCatalogRegistryEntriesTests.swift @@ -242,4 +242,11 @@ import Testing #expect(AlertCatalogRegistry.lookup(id("trio.aps", alertID))?.category == "Algorithm") } } + + @Test("Tidepool re-login alert is a Normal-tier Services entry") func tidepoolLoginEntry() { + let entry = AlertCatalogRegistry.lookup(id("TidepoolService", "authentication-needed")) + #expect(entry?.interruptionLevel == .active) + #expect(entry?.category == "Services") + #expect(entry?.concept == .tidepoolLoginRequired) + } } diff --git a/TrioTests/BolusSafetyTests/EnactBolusAmountTests.swift b/TrioTests/BolusSafetyTests/EnactBolusAmountTests.swift new file mode 100644 index 0000000000..eeafdd37ae --- /dev/null +++ b/TrioTests/BolusSafetyTests/EnactBolusAmountTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Swinject +import Testing +@testable import Trio + +/// A bolus of zero or less must be refused with an answer. `enactBolus` used to return without +/// calling back, so a watch that sent 0 U waited on "Processing…" forever. +@Suite("Enact bolus amount validation", .serialized) struct EnactBolusAmountTests { + private static func makeAPSManager() async throws -> APSManager { + let coreDataStack = try await CoreDataStack.createForTests() + let resolver = Assembler([ + StorageAssembly(), + ServiceAssembly(), + APSAssembly(), + NetworkAssembly(), + UIAssembly(), + SecurityAssembly(), + TestAssembly(testContext: coreDataStack.newTaskContext()) + ]).resolver + return try #require(resolver.resolve(APSManager.self)) + } + + @Test("Bolus of zero or less is refused with an answer", arguments: [ + 0.0, + -0.05, + -3.0, + Double.nan + ]) func testInvalidAmountAnswersFailure(amount: Double) async throws { + let apsManager = try await Self.makeAPSManager() + var answers: [(success: Bool, message: String)] = [] + + await apsManager.enactBolus(amount: amount, isSMB: false) { success, message in + answers.append((success, message)) + } + + #expect(answers.count == 1) + #expect(answers.first?.success == false) + #expect(answers.first?.message == BaseAPSManager.invalidBolusAmountMessage) + } +} diff --git a/TrioTests/CoreDataTests/CarbEntryMutationServiceTests.swift b/TrioTests/CoreDataTests/CarbEntryMutationServiceTests.swift index 6ca9db30ba..bce7dcb7bb 100644 --- a/TrioTests/CoreDataTests/CarbEntryMutationServiceTests.swift +++ b/TrioTests/CoreDataTests/CarbEntryMutationServiceTests.swift @@ -277,6 +277,7 @@ private final class StubTidepoolManager: TidepoolManager { func addTidepoolService(service _: Service) {} func getTidepoolServiceUI() -> ServiceUI? { nil } func getTidepoolPluginHost() -> PluginHost? { nil } + let tidepoolLoginRequired = CurrentValueSubject(false) func uploadCarbs() async {} func deleteCarbs(withSyncId _: UUID, carbs _: Decimal, at _: Date, enteredBy _: String) {} func uploadInsulin() async {} diff --git a/TrioTests/CoreDataTests/MultiUsePanelStateTests.swift b/TrioTests/CoreDataTests/MultiUsePanelStateTests.swift index 83db5f81f3..12f61c7f95 100644 --- a/TrioTests/CoreDataTests/MultiUsePanelStateTests.swift +++ b/TrioTests/CoreDataTests/MultiUsePanelStateTests.swift @@ -14,6 +14,7 @@ import Testing pumpTimeMismatch: Bool = false, lastGlucoseDate: Date?, maxIOB: Decimal = 10, + tidepoolLoginRequired: Bool = false, hasOverride: Bool = false, hasTempTarget: Bool = false, hasTempProfile: Bool = false, @@ -26,6 +27,7 @@ import Testing pumpTimeMismatch: pumpTimeMismatch, lastGlucoseDate: lastGlucoseDate, maxIOB: maxIOB, + tidepoolLoginRequired: tidepoolLoginRequired, hasOverride: hasOverride, hasTempTarget: hasTempTarget, hasTempProfile: hasTempProfile, @@ -141,4 +143,15 @@ import Testing @Test("Acknowledged release notes fall back to stats") func testAcknowledgedShowsStats() { #expect(resolve(lastGlucoseDate: fresh, hasUnacknowledgedReleaseNotes: false) == .stats) } + + @Test("Tidepool login sits between dosing warnings and release notes") func testTidepoolLoginRequired() { + #expect(resolve(lastGlucoseDate: fresh, tidepoolLoginRequired: true) == .tidepoolLoginRequired) + #expect(resolve( + lastGlucoseDate: fresh, + tidepoolLoginRequired: true, + hasUnacknowledgedReleaseNotes: true + ) == .tidepoolLoginRequired) + #expect(resolve(lastGlucoseDate: fresh, maxIOB: 0, tidepoolLoginRequired: true) == .maxIOBZero) + #expect(resolve(lastGlucoseDate: stale, tidepoolLoginRequired: true) == .cgmStale) + } } diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 399d0ea3f0..4e9e2714aa 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -49,6 +49,70 @@ xcconfig = parse_xcconfig_file(xcconfig_path) # Access BUNDLE_IDENTIFIER from the xcconfig file after replacing $(DEVELOPMENT_TEAM) with ENV["TEAMID"] ENV["BUNDLE_ID"] = xcconfig["BUNDLE_IDENTIFIER"] +# Plain-text excerpt of the newest release notes that scripts/capture-release-notes.sh bundled +# into the IPA. Mirrors the in-app sheet (ReleaseNotes.swift): summary, callouts, then highlights. +def bundled_release_notes(ipa_path) + json = IO.popen(["unzip", "-p", ipa_path, "Payload/Trio.app/BundledReleaseNotes.json"], err: File::NULL, &:read).force_encoding("UTF-8") + release = JSON.parse(json).first + return nil unless release + + plain = ->(text) { text.gsub(/!\[[^\]]*\]\([^)]*\)/, "").gsub(/\[([^\]]*)\]\([^)]*\)/, '\1').gsub("**", "").delete("`") } + raw_lines = release["body"].gsub(/\r\n?/, "\n").split("\n") + lines = raw_lines.map(&:strip) + + summary = [] + lines.each do |line| + break if line.start_with?("##") || ["---", "***"].include?(line) + next if line.empty? || line.start_with?("#", ">") + summary << plain.(line) + end + + callouts = [] + current = nil + lines.each do |line| + unless line.start_with?(">") + current = nil + next + end + content = line[1..].strip + if (marker = content[/\A\[!([^\]]*)\]/, 1]) + kind = marker.upcase + current = %w[NOTE TIP IMPORTANT WARNING CAUTION].include?(kind) ? [kind, []] : nil + callouts << current if current + elsif current && !content.empty? + current[1] << plain.(content) + end + end + + highlights = [] + inside = false + raw_lines.each do |raw| + line = raw.strip + if line.start_with?("#") + heading = line.sub(/\A#+/, "").strip + if heading.casecmp?("What's Changed At A Glance") + inside = true + elsif inside + break + end + next + end + next unless inside + break if ["---", "***"].include?(line) + next unless line.start_with?("- ", "* ") && !line[2..].strip.empty? + level = [raw[/\A[ \t]*/].gsub("\t", " ").length / 2, 2].min + highlights << " " * level + "- " + plain.(line[2..].strip) + end + + sections = [release["name"], summary.join("\n\n")] + sections += callouts.reject { |_, text| text.empty? }.map { |kind, text| "#{kind}:\n" + text.join("\n") } + sections << "What's Changed At A Glance\n" + highlights.join("\n") unless highlights.empty? + sections.reject(&:empty?).join("\n\n") +rescue StandardError => e + UI.important("Could not read bundled release notes: #{e}") + nil +end + # limit lane names to letters and underscores platform :ios do desc "Build Tai" @@ -165,12 +229,33 @@ platform :ios do key_content: "#{FASTLANE_KEY}" ) + # APP_DEV_VERSION only has a 4th component off main (e.g. 1.0.2.33 on dev) + dev_version = xcconfig["APP_DEV_VERSION"].to_s + on_main = dev_version.split(".").length != 4 + version_note = on_main ? "" : " "+dev_version + notes = git_branch+version_note+" "+last_git_commit[:abbreviated_commit_hash] + release_notes = on_main && bundled_release_notes("#{GITHUB_WORKSPACE}/Trio.ipa") + notes += "\n\n"+release_notes if release_notes + + # changelog alone only updates existing "What to Test" notes, so name the primary locale to create one. + # Keep changelog too: without it, skip_waiting_for_build_processing returns before notes are set. + primary_locale = begin + Spaceship::ConnectAPI::App.find(ENV["BUNDLE_ID"])&.primary_locale + rescue StandardError => e + UI.important("Could not look up primary locale, using en-US: #{e}") + nil + end || "en-US" + upload_to_testflight( api_key: api_key, skip_submission: false, ipa: "Trio.ipa", skip_waiting_for_build_processing: true, - changelog: git_branch+" "+last_git_commit[:abbreviated_commit_hash], + changelog: notes, + localized_build_info: { + "default" => { whats_new: notes }, + primary_locale => { whats_new: notes }, + }, verbose: true ) end