// LoopFollow // LoopAPNSBolusView.swift import HealthKit import LocalAuthentication import SwiftUI struct LoopAPNSBolusView: View { @Environment(\.presentationMode) var presentationMode @State private var insulinAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0) @State private var isLoading = false @State private var showAlert = false @State private var alertMessage = "" @State private var alertType: AlertType = .success @ObservedObject private var quickPickBoluses = QuickPickBolusesManager.shared @FocusState private var insulinFieldIsFocused: Bool // Add state for recommended bolus and warning @State private var recommendedBolus: Double? = nil @State private var lastLoopTime: TimeInterval? = nil @State private var otpTimeRemaining: Int? = nil @State private var showOldCalculationWarning = false @State private var showTOTPWarning = false private let otpPeriod: TimeInterval = 30 private var otpTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() // Computed property to check if TOTP should be blocked private var isTOTPBlocked: Bool { TOTPService.shared.isTOTPBlocked(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) } enum AlertType { case success case error case confirmation case oldCalculationWarning } var body: some View { NavigationView { VStack { Form { // Recommended bolus section if let recommendedBolus = recommendedBolus, recommendedBolus > 0, let lastLoopTime = lastLoopTime { let timeSinceCalculation = Date().timeIntervalSince1970 - lastLoopTime let minutesSinceCalculation = Int(timeSinceCalculation / 60) // Only show if calculation is less than 12 minutes old if minutesSinceCalculation < 12 { Section(header: Text("Recommended Bolus")) { Button(action: { handleRecommendedBolusTap() }) { HStack { VStack(alignment: .leading, spacing: 4) { Text("\(String(format: "%.2f", recommendedBolus))U") .font(.headline) .foregroundColor(.primary) Text("Calculated \(minutesSinceCalculation) minute\(minutesSinceCalculation == 1 ? "" : "s") ago") .font(.caption) .foregroundColor(.secondary) } Spacer() Image(systemName: "arrow.up.circle.fill") .foregroundColor(.blue) .font(.title2) } .padding(.vertical, 8) } .buttonStyle(PlainButtonStyle()) } } } if !quickPickBoluses.quickPickBoluses.isEmpty { Section(header: QuickPickSectionHeader(title: "Quick-Pick Boluses", infoText: QuickPickSectionHeader.bolusInfoText)) { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(quickPickBoluses.quickPickBoluses) { bolus in Button { insulinAmount = HKQuantity(unit: .internationalUnit(), doubleValue: bolus.units) } label: { Text("\(InsulinFormatter.shared.string(bolus.units))U") .font(.subheadline.weight(.medium)) .padding(.horizontal, 14) .padding(.vertical, 8) .background(Color.accentColor.opacity(0.15)) .foregroundColor(.accentColor) .cornerRadius(8) } .buttonStyle(.plain) } } .padding(.vertical, 4) } } } Section { HKQuantityInputView( label: "Insulin Amount", quantity: $insulinAmount, unit: .internationalUnit(), maxLength: 5, minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.025), maxValue: Storage.shared.maxBolus.value, isFocused: $insulinFieldIsFocused, onValidationError: { message in alertMessage = message alertType = .error showAlert = true } ) } // Warning section for recommended bolus age if let recommendedBolus = recommendedBolus, let lastLoopTime = lastLoopTime { let timeSinceCalculation = Date().timeIntervalSince1970 - lastLoopTime let minutesSinceCalculation = Int(timeSinceCalculation / 60) // Only show warning if calculation is less than 12 minutes old if minutesSinceCalculation < 12 { Section { let warningColor: Color = minutesSinceCalculation >= 5 ? .red : .yellow VStack(alignment: .leading, spacing: 8) { Text("WARNING: New treatments may have occurred since the last recommended bolus was calculated \(presentableMinutesFormat(timeInterval: timeSinceCalculation)) ago.") .font(.callout) .foregroundColor(warningColor) .multilineTextAlignment(.leading) } } } } // TOTP Blocking Warning Section if isTOTPBlocked && showTOTPWarning { Section { VStack(alignment: .leading, spacing: 8) { HStack { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) Text("TOTP Code Already Used") .font(.headline) .foregroundColor(.orange) } Text("This TOTP code has already been used for a command. Please wait for the next code to be generated before sending another command.") .font(.caption) .foregroundColor(.secondary) .multilineTextAlignment(.leading) } .padding(.vertical, 4) } } Section(header: Text("Security")) { VStack(alignment: .leading) { Text("Current OTP Code") .font(.headline) if let otpCode = TOTPGenerator.extractOTPFromURL(Storage.shared.loopAPNSQrCodeURL.value) { HStack { Text(otpCode) .font(.system(.body, design: .monospaced)) .foregroundColor(.green) .padding(.vertical, 4) .padding(.horizontal, 8) .background(Color.green.opacity(0.1)) .cornerRadius(4) Text("(" + (otpTimeRemaining.map { "\($0)s left" } ?? "-") + ")") .font(.caption) .foregroundColor(.secondary) } } else { Text("Invalid QR code URL") .foregroundColor(.red) } } } } .safeAreaInset(edge: .bottom) { Button(action: sendInsulin) { if isLoading { HStack { ProgressView() .scaleEffect(0.8) Text("Sending...") } .frame(maxWidth: .infinity) } else { Text("Send Insulin") .frame(maxWidth: .infinity) } } .buttonStyle(.borderedProminent) .controlSize(.large) .disabled(insulinAmount.doubleValue(for: .internationalUnit()) <= 0 || isLoading || isTOTPBlocked) .padding(.horizontal) .padding(.vertical, 8) .background(.bar) } .navigationTitle("Insulin") .navigationBarTitleDisplayMode(.inline) } .onAppear { // Validate APNS setup let apnsService = LoopAPNSService() if !apnsService.validateSetup() { alertMessage = "Loop APNS setup is incomplete. Please configure all required fields in settings." alertType = .error showAlert = true } let step = Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()) let maxBolus = Storage.shared.maxBolus.value.doubleValue(for: .internationalUnit()) quickPickBoluses.refresh(stepIncrement: max(0.001, step), maxBolus: maxBolus) loadRecommendedBolus() // Reset timer state so it shows '-' until first tick otpTimeRemaining = nil // Don't reset TOTP usage flag here - let the timer handle it // Validate TOTP state when view appears _ = isTOTPBlocked // Add delay before showing TOTP warning to prevent flash after successful send if isTOTPBlocked { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { showTOTPWarning = true } } else { showTOTPWarning = false } } .onReceive(otpTimer) { _ in let now = Date().timeIntervalSince1970 let newOtpTimeRemaining = Int(otpPeriod - (now.truncatingRemainder(dividingBy: otpPeriod))) // Check if we've moved to a new TOTP period (when time remaining increases) if let currentOtpTimeRemaining = otpTimeRemaining, newOtpTimeRemaining > currentOtpTimeRemaining { // New TOTP code generated, reset the usage flag TOTPService.shared.resetTOTPUsage() } // Also check if we're at the very beginning of a new period (when time remaining is close to 30) if newOtpTimeRemaining >= 29 { // We're at the start of a new TOTP period, reset the usage flag TOTPService.shared.resetTOTPUsage() } otpTimeRemaining = newOtpTimeRemaining // Check if recommended bolus calculation is older than 5 minutes (but less than 12 minutes) if let lastLoopTime = lastLoopTime { let timeSinceCalculation = now - lastLoopTime let minutesSinceCalculation = Int(timeSinceCalculation / 60) // Only show warning if calculation is between 5-12 minutes old if minutesSinceCalculation > 5 && minutesSinceCalculation < 12 && !showOldCalculationWarning { showOldCalculationWarning = true alertMessage = "This recommended bolus was calculated \(minutesSinceCalculation) minutes ago. New treatments may have occurred since then. Proceed with caution." alertType = .oldCalculationWarning showAlert = true } } } .alert(isPresented: $showAlert) { switch alertType { case .success: return Alert( title: Text("Success"), message: Text(alertMessage), dismissButton: .default(Text("OK")) { presentationMode.wrappedValue.dismiss() } ) case .error: return Alert( title: Text("Error"), message: Text(alertMessage), dismissButton: .default(Text("OK")) ) case .confirmation: return Alert( title: Text("Confirm Insulin"), message: Text("Send \(String(format: "%.2f", insulinAmount.doubleValue(for: .internationalUnit()))) units of insulin?"), primaryButton: .default(Text("Send")) { authenticateAndSendInsulin() }, secondaryButton: .cancel() ) case .oldCalculationWarning: return Alert( title: Text("Old Calculation Warning"), message: Text(alertMessage), primaryButton: .default(Text("Use Anyway")) { applyRecommendedBolus() }, secondaryButton: .cancel() ) } } } } private func loadRecommendedBolus() { // Load recommended bolus from Observable recommendedBolus = Observable.shared.deviceRecBolus.value lastLoopTime = Observable.shared.alertLastLoopTime.value // Reset warning state when new data is loaded showOldCalculationWarning = false } private func handleRecommendedBolusTap() { guard let recommendedBolus = recommendedBolus, recommendedBolus > 0 else { return } // Apply the recommended bolus directly (warning is handled by timer) applyRecommendedBolus() } private func applyRecommendedBolus() { guard let recommendedBolus = recommendedBolus, recommendedBolus > 0 else { return } insulinAmount = HKQuantity(unit: .internationalUnit(), doubleValue: recommendedBolus) } private func presentableMinutesFormat(timeInterval: TimeInterval) -> String { let minutes = Int(timeInterval / 60) var result = "\(minutes) minute" if minutes == 0 || minutes > 1 { result += "s" } return result } private func sendInsulin() { guard insulinAmount.doubleValue(for: .internationalUnit()) > 0 else { alertMessage = "Please enter a valid insulin amount" alertType = .error showAlert = true return } // Check guardrails let maxBolus = Storage.shared.maxBolus.value.doubleValue(for: .internationalUnit()) let insulinValue = insulinAmount.doubleValue(for: .internationalUnit()) if insulinValue > maxBolus { alertMessage = "Insulin amount (\(String(format: "%.2f", insulinValue))U) exceeds the maximum allowed (\(String(format: "%.2f", maxBolus))U). Please reduce the amount." alertType = .error showAlert = true return } alertType = .confirmation showAlert = true } private func authenticateAndSendInsulin() { AuthService.authenticate(reason: "Confirm your identity to send insulin.") { result in DispatchQueue.main.async { switch result { case .success: self.sendInsulinConfirmed() case let .unavailable(message): self.alertMessage = message self.alertType = .error self.showAlert = true case .failed: self.alertMessage = "Authentication failed" self.alertType = .error self.showAlert = true case .canceled: // User canceled: no alert to avoid spammy UX break } } } } private func sendInsulinConfirmed() { isLoading = true // Extract OTP from QR code URL guard let otpCode = TOTPGenerator.extractOTPFromURL(Storage.shared.loopAPNSQrCodeURL.value) else { alertMessage = "Invalid QR code URL. Please re-scan the QR code in settings." alertType = .error isLoading = false showAlert = true return } let payload = LoopAPNSPayload( type: .bolus, bolusAmount: insulinAmount.doubleValue(for: .internationalUnit()), otp: otpCode ) let apnsService = LoopAPNSService() apnsService.sendBolusViaAPNS(payload: payload) { success, errorMessage in DispatchQueue.main.async { self.isLoading = false if success { let sentUnits = insulinAmount.doubleValue(for: .internationalUnit()) if sentUnits > 0 { QuickPickBolusesManager.shared.recordBolus(units: sentUnits) } // Mark TOTP code as used TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) self.alertMessage = "Insulin sent successfully!" self.alertType = .success LogManager.shared.log( category: .apns, message: "Insulin sent - Amount: \(insulinAmount.doubleValue(for: .internationalUnit()))U" ) } else { self.alertMessage = errorMessage ?? "Failed to send insulin. Check your Loop APNS configuration." self.alertType = .error LogManager.shared.log( category: .apns, message: "Failed to send insulin: \(errorMessage ?? "unknown error")" ) } self.showAlert = true } } } }