LoopAPNSBolusView.swift 20 KB

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