LoopAPNSBolusView.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. // LoopFollow
  2. // LoopAPNSBolusView.swift
  3. import HealthKit
  4. import LocalAuthentication
  5. import SwiftUI
  6. struct LoopAPNSBolusView: View {
  7. @Environment(\.presentationMode) var presentationMode
  8. @State private var insulinAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0)
  9. @State private var isLoading = false
  10. @State private var showAlert = false
  11. @State private var alertMessage = ""
  12. @State private var alertType: AlertType = .success
  13. @FocusState private var insulinFieldIsFocused: Bool
  14. // Add state for recommended bolus and warning
  15. @State private var recommendedBolus: Double? = nil
  16. @State private var lastLoopTime: TimeInterval? = nil
  17. @State private var otpTimeRemaining: Int? = nil
  18. @State private var showOldCalculationWarning = false
  19. private let otpPeriod: TimeInterval = 30
  20. private var otpTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
  21. // Computed property to check if TOTP should be blocked
  22. private var isTOTPBlocked: Bool {
  23. TOTPService.shared.isTOTPBlocked(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value)
  24. }
  25. enum AlertType {
  26. case success
  27. case error
  28. case confirmation
  29. case oldCalculationWarning
  30. }
  31. var body: some View {
  32. NavigationView {
  33. VStack {
  34. Form {
  35. // Recommended bolus section
  36. if let recommendedBolus = recommendedBolus, recommendedBolus > 0, let lastLoopTime = lastLoopTime {
  37. let timeSinceCalculation = Date().timeIntervalSince1970 - lastLoopTime
  38. let minutesSinceCalculation = Int(timeSinceCalculation / 60)
  39. // Only show if calculation is less than 12 minutes old
  40. if minutesSinceCalculation < 12 {
  41. Section(header: Text("Recommended Bolus")) {
  42. Button(action: {
  43. handleRecommendedBolusTap()
  44. }) {
  45. HStack {
  46. VStack(alignment: .leading, spacing: 4) {
  47. Text("\(String(format: "%.2f", recommendedBolus))U")
  48. .font(.headline)
  49. .foregroundColor(.primary)
  50. Text("Calculated \(minutesSinceCalculation) minute\(minutesSinceCalculation == 1 ? "" : "s") ago")
  51. .font(.caption)
  52. .foregroundColor(.secondary)
  53. }
  54. Spacer()
  55. Image(systemName: "arrow.up.circle.fill")
  56. .foregroundColor(.blue)
  57. .font(.title2)
  58. }
  59. .padding(.vertical, 8)
  60. }
  61. .buttonStyle(PlainButtonStyle())
  62. }
  63. }
  64. }
  65. Section {
  66. HKQuantityInputView(
  67. label: "Insulin Amount",
  68. quantity: $insulinAmount,
  69. unit: .internationalUnit(),
  70. maxLength: 5,
  71. minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.025),
  72. maxValue: Storage.shared.maxBolus.value,
  73. isFocused: $insulinFieldIsFocused,
  74. onValidationError: { message in
  75. alertMessage = message
  76. alertType = .error
  77. showAlert = true
  78. }
  79. )
  80. }
  81. // Warning section for recommended bolus age
  82. if let recommendedBolus = recommendedBolus, let lastLoopTime = lastLoopTime {
  83. let timeSinceCalculation = Date().timeIntervalSince1970 - lastLoopTime
  84. let minutesSinceCalculation = Int(timeSinceCalculation / 60)
  85. // Only show warning if calculation is less than 12 minutes old
  86. if minutesSinceCalculation < 12 {
  87. Section {
  88. let warningColor: Color = minutesSinceCalculation >= 5 ? .red : .yellow
  89. VStack(alignment: .leading, spacing: 8) {
  90. Text("WARNING: New treatments may have occurred since the last recommended bolus was calculated \(presentableMinutesFormat(timeInterval: timeSinceCalculation)) ago.")
  91. .font(.callout)
  92. .foregroundColor(warningColor)
  93. .multilineTextAlignment(.leading)
  94. }
  95. }
  96. }
  97. }
  98. Section {
  99. Button(action: sendInsulin) {
  100. if isLoading {
  101. HStack {
  102. ProgressView()
  103. .scaleEffect(0.8)
  104. Text("Sending...")
  105. }
  106. } else {
  107. Text("Send Insulin")
  108. }
  109. }
  110. .disabled(insulinAmount.doubleValue(for: .internationalUnit()) <= 0 || isLoading || isTOTPBlocked)
  111. .frame(maxWidth: .infinity)
  112. }
  113. // TOTP Blocking Warning Section
  114. if isTOTPBlocked {
  115. Section {
  116. VStack(alignment: .leading, spacing: 8) {
  117. HStack {
  118. Image(systemName: "exclamationmark.triangle.fill")
  119. .foregroundColor(.orange)
  120. Text("TOTP Code Already Used")
  121. .font(.headline)
  122. .foregroundColor(.orange)
  123. }
  124. Text("This TOTP code has already been used for a command. Please wait for the next code to be generated before sending another command.")
  125. .font(.caption)
  126. .foregroundColor(.secondary)
  127. .multilineTextAlignment(.leading)
  128. }
  129. .padding(.vertical, 4)
  130. }
  131. }
  132. Section(header: Text("Security")) {
  133. VStack(alignment: .leading) {
  134. Text("Current OTP Code")
  135. .font(.headline)
  136. if let otpCode = TOTPGenerator.extractOTPFromURL(Storage.shared.loopAPNSQrCodeURL.value) {
  137. HStack {
  138. Text(otpCode)
  139. .font(.system(.body, design: .monospaced))
  140. .foregroundColor(.green)
  141. .padding(.vertical, 4)
  142. .padding(.horizontal, 8)
  143. .background(Color.green.opacity(0.1))
  144. .cornerRadius(4)
  145. Text("(" + (otpTimeRemaining.map { "\($0)s left" } ?? "-") + ")")
  146. .font(.caption)
  147. .foregroundColor(.secondary)
  148. }
  149. } else {
  150. Text("Invalid QR code URL")
  151. .foregroundColor(.red)
  152. }
  153. }
  154. }
  155. }
  156. .navigationTitle("Insulin")
  157. .navigationBarTitleDisplayMode(.inline)
  158. }
  159. .onAppear {
  160. // Validate APNS setup
  161. let apnsService = LoopAPNSService()
  162. if !apnsService.validateSetup() {
  163. alertMessage = "Loop APNS setup is incomplete. Please configure all required fields in settings."
  164. alertType = .error
  165. showAlert = true
  166. }
  167. loadRecommendedBolus()
  168. // Reset timer state so it shows '-' until first tick
  169. otpTimeRemaining = nil
  170. // Don't reset TOTP usage flag here - let the timer handle it
  171. // Validate TOTP state when view appears
  172. _ = isTOTPBlocked
  173. }
  174. .onReceive(otpTimer) { _ in
  175. let now = Date().timeIntervalSince1970
  176. let newOtpTimeRemaining = Int(otpPeriod - (now.truncatingRemainder(dividingBy: otpPeriod)))
  177. // Check if we've moved to a new TOTP period (when time remaining increases)
  178. if let currentOtpTimeRemaining = otpTimeRemaining,
  179. newOtpTimeRemaining > currentOtpTimeRemaining
  180. {
  181. // New TOTP code generated, reset the usage flag
  182. TOTPService.shared.resetTOTPUsage()
  183. }
  184. // Also check if we're at the very beginning of a new period (when time remaining is close to 30)
  185. if newOtpTimeRemaining >= 29 {
  186. // We're at the start of a new TOTP period, reset the usage flag
  187. TOTPService.shared.resetTOTPUsage()
  188. }
  189. otpTimeRemaining = newOtpTimeRemaining
  190. // Check if recommended bolus calculation is older than 5 minutes (but less than 12 minutes)
  191. if let lastLoopTime = lastLoopTime {
  192. let timeSinceCalculation = now - lastLoopTime
  193. let minutesSinceCalculation = Int(timeSinceCalculation / 60)
  194. // Only show warning if calculation is between 5-12 minutes old
  195. if minutesSinceCalculation > 5 && minutesSinceCalculation < 12 && !showOldCalculationWarning {
  196. showOldCalculationWarning = true
  197. alertMessage = "This recommended bolus was calculated \(minutesSinceCalculation) minutes ago. New treatments may have occurred since then. Proceed with caution."
  198. alertType = .oldCalculationWarning
  199. showAlert = true
  200. }
  201. }
  202. }
  203. .alert(isPresented: $showAlert) {
  204. switch alertType {
  205. case .success:
  206. return Alert(
  207. title: Text("Success"),
  208. message: Text(alertMessage),
  209. dismissButton: .default(Text("OK")) {
  210. presentationMode.wrappedValue.dismiss()
  211. }
  212. )
  213. case .error:
  214. return Alert(
  215. title: Text("Error"),
  216. message: Text(alertMessage),
  217. dismissButton: .default(Text("OK"))
  218. )
  219. case .confirmation:
  220. return Alert(
  221. title: Text("Confirm Insulin"),
  222. message: Text("Send \(String(format: "%.2f", insulinAmount.doubleValue(for: .internationalUnit()))) units of insulin?"),
  223. primaryButton: .default(Text("Send")) {
  224. authenticateAndSendInsulin()
  225. },
  226. secondaryButton: .cancel()
  227. )
  228. case .oldCalculationWarning:
  229. return Alert(
  230. title: Text("Old Calculation Warning"),
  231. message: Text(alertMessage),
  232. primaryButton: .default(Text("Use Anyway")) {
  233. applyRecommendedBolus()
  234. },
  235. secondaryButton: .cancel()
  236. )
  237. }
  238. }
  239. }
  240. }
  241. private func loadRecommendedBolus() {
  242. // Load recommended bolus from Observable
  243. recommendedBolus = Observable.shared.deviceRecBolus.value
  244. lastLoopTime = Observable.shared.alertLastLoopTime.value
  245. // Reset warning state when new data is loaded
  246. showOldCalculationWarning = false
  247. }
  248. private func handleRecommendedBolusTap() {
  249. guard let recommendedBolus = recommendedBolus, recommendedBolus > 0 else { return }
  250. // Apply the recommended bolus directly (warning is handled by timer)
  251. applyRecommendedBolus()
  252. }
  253. private func applyRecommendedBolus() {
  254. guard let recommendedBolus = recommendedBolus, recommendedBolus > 0 else { return }
  255. insulinAmount = HKQuantity(unit: .internationalUnit(), doubleValue: recommendedBolus)
  256. }
  257. private func presentableMinutesFormat(timeInterval: TimeInterval) -> String {
  258. let minutes = Int(timeInterval / 60)
  259. var result = "\(minutes) minute"
  260. if minutes == 0 || minutes > 1 {
  261. result += "s"
  262. }
  263. return result
  264. }
  265. private func sendInsulin() {
  266. guard insulinAmount.doubleValue(for: .internationalUnit()) > 0 else {
  267. alertMessage = "Please enter a valid insulin amount"
  268. alertType = .error
  269. showAlert = true
  270. return
  271. }
  272. // Check guardrails
  273. let maxBolus = Storage.shared.maxBolus.value.doubleValue(for: .internationalUnit())
  274. let insulinValue = insulinAmount.doubleValue(for: .internationalUnit())
  275. if insulinValue > maxBolus {
  276. alertMessage = "Insulin amount (\(String(format: "%.2f", insulinValue))U) exceeds the maximum allowed (\(String(format: "%.2f", maxBolus))U). Please reduce the amount."
  277. alertType = .error
  278. showAlert = true
  279. return
  280. }
  281. alertType = .confirmation
  282. showAlert = true
  283. }
  284. private func authenticateAndSendInsulin() {
  285. let context = LAContext()
  286. var error: NSError?
  287. let reason = "Confirm your identity to send insulin."
  288. if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
  289. context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in
  290. DispatchQueue.main.async {
  291. if success {
  292. sendInsulinConfirmed()
  293. } else {
  294. alertMessage = "Authentication failed"
  295. alertType = .error
  296. showAlert = true
  297. }
  298. }
  299. }
  300. } else if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) {
  301. context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in
  302. DispatchQueue.main.async {
  303. if success {
  304. sendInsulinConfirmed()
  305. } else {
  306. alertMessage = "Authentication failed"
  307. alertType = .error
  308. showAlert = true
  309. }
  310. }
  311. }
  312. } else {
  313. alertMessage = "Biometric authentication not available"
  314. alertType = .error
  315. showAlert = true
  316. }
  317. }
  318. private func sendInsulinConfirmed() {
  319. isLoading = true
  320. // Extract OTP from QR code URL
  321. guard let otpCode = TOTPGenerator.extractOTPFromURL(Storage.shared.loopAPNSQrCodeURL.value) else {
  322. alertMessage = "Invalid QR code URL. Please re-scan the QR code in settings."
  323. alertType = .error
  324. isLoading = false
  325. showAlert = true
  326. return
  327. }
  328. let payload = LoopAPNSPayload(
  329. type: .bolus,
  330. bolusAmount: insulinAmount.doubleValue(for: .internationalUnit()),
  331. otp: otpCode
  332. )
  333. Task {
  334. do {
  335. let apnsService = LoopAPNSService()
  336. let success = try await apnsService.sendBolusViaAPNS(payload: payload)
  337. DispatchQueue.main.async {
  338. isLoading = false
  339. if success {
  340. // Mark TOTP code as used
  341. TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value)
  342. alertMessage = "Insulin sent successfully!"
  343. alertType = .success
  344. LogManager.shared.log(
  345. category: .apns,
  346. message: "Insulin sent - Amount: \(insulinAmount.doubleValue(for: .internationalUnit()))U"
  347. )
  348. } else {
  349. alertMessage = "Failed to send insulin. Check your Loop APNS configuration."
  350. alertType = .error
  351. LogManager.shared.log(
  352. category: .apns,
  353. message: "Failed to send insulin"
  354. )
  355. }
  356. showAlert = true
  357. }
  358. } catch {
  359. DispatchQueue.main.async {
  360. isLoading = false
  361. alertMessage = "Error sending insulin: \(error.localizedDescription)"
  362. alertType = .error
  363. LogManager.shared.log(
  364. category: .apns,
  365. message: "APNS insulin error: \(error.localizedDescription)"
  366. )
  367. showAlert = true
  368. }
  369. }
  370. }
  371. }
  372. }