LoopAPNSBolusView.swift 18 KB

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