LoopAPNSCarbsView.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. // LoopFollow
  2. // LoopAPNSCarbsView.swift
  3. import HealthKit
  4. import SwiftUI
  5. struct LoopAPNSCarbsView: View {
  6. @Environment(\.presentationMode) var presentationMode
  7. @State private var carbsAmount = HKQuantity(unit: .gram(), doubleValue: 0.0)
  8. @State private var absorptionTimeString = "3.0"
  9. @State private var foodType = ""
  10. @State private var consumedDate = Date()
  11. @State private var showDatePickerSheet = false
  12. @State private var isLoading = false
  13. @State private var showAlert = false
  14. @State private var alertMessage = ""
  15. @State private var alertType: AlertType = .success
  16. @State private var otpTimeRemaining: Int? = nil
  17. @State private var showTOTPWarning = false
  18. private let otpPeriod: TimeInterval = 30
  19. private var otpTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
  20. @FocusState private var carbsFieldIsFocused: Bool
  21. @FocusState private var absorptionFieldIsFocused: Bool
  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. }
  31. var body: some View {
  32. NavigationView {
  33. VStack {
  34. Form {
  35. Section {
  36. HKQuantityInputView(
  37. label: "Carbs Amount",
  38. quantity: $carbsAmount,
  39. unit: .gram(),
  40. maxLength: 4,
  41. minValue: HKQuantity(unit: .gram(), doubleValue: 1.0),
  42. maxValue: Storage.shared.maxCarbs.value,
  43. isFocused: $carbsFieldIsFocused,
  44. onValidationError: { message in
  45. alertMessage = message
  46. alertType = .error
  47. showAlert = true
  48. }
  49. )
  50. VStack(alignment: .leading, spacing: 8) {
  51. Text("Food Type")
  52. .font(.headline)
  53. HStack(spacing: 12) {
  54. // Fast carb entry emoji (0.5 hours)
  55. Button(action: {
  56. foodType = "🍭"
  57. absorptionTimeString = "0.5"
  58. }) {
  59. Text("🍭")
  60. .font(.title)
  61. .frame(width: 44, height: 44)
  62. .background(Color.blue.opacity(0.1))
  63. .cornerRadius(8)
  64. }
  65. .buttonStyle(PlainButtonStyle())
  66. // Medium carb entry emoji (3 hours)
  67. Button(action: {
  68. foodType = "🌮"
  69. absorptionTimeString = "3.0"
  70. }) {
  71. Text("🌮")
  72. .font(.title)
  73. .frame(width: 44, height: 44)
  74. .background(Color.blue.opacity(0.1))
  75. .cornerRadius(8)
  76. }
  77. .buttonStyle(PlainButtonStyle())
  78. // Slow carb entry emoji (5 hours)
  79. Button(action: {
  80. foodType = "🍕"
  81. absorptionTimeString = "5.0"
  82. }) {
  83. Text("🍕")
  84. .font(.title)
  85. .frame(width: 44, height: 44)
  86. .background(Color.blue.opacity(0.1))
  87. .cornerRadius(8)
  88. }
  89. .buttonStyle(PlainButtonStyle())
  90. // Custom carb entry emoji (clears and focuses absorption)
  91. Button(action: {
  92. foodType = "🍽️"
  93. absorptionTimeString = ""
  94. absorptionFieldIsFocused = true
  95. }) {
  96. Text("🍽️")
  97. .font(.title)
  98. .frame(width: 44, height: 44)
  99. .background(Color.blue.opacity(0.1))
  100. .cornerRadius(8)
  101. }
  102. .buttonStyle(PlainButtonStyle())
  103. Spacer()
  104. }
  105. }
  106. HStack {
  107. Text("Absorption Time")
  108. Spacer()
  109. TextField("0.0", text: $absorptionTimeString)
  110. .keyboardType(.decimalPad)
  111. .multilineTextAlignment(.trailing)
  112. .focused($absorptionFieldIsFocused)
  113. .onChange(of: absorptionTimeString) { newValue in
  114. // Only allow numbers and decimal point
  115. let filtered = newValue.filter { "0123456789.".contains($0) }
  116. // Ensure only one decimal point
  117. let components = filtered.components(separatedBy: ".")
  118. if components.count > 2 {
  119. absorptionTimeString = String(filtered.dropLast())
  120. } else {
  121. absorptionTimeString = filtered
  122. }
  123. }
  124. Text("hr")
  125. .foregroundColor(.secondary)
  126. }
  127. // Time input section
  128. VStack(alignment: .leading) {
  129. Text("Time")
  130. .font(.headline)
  131. Button(action: {
  132. showDatePickerSheet = true
  133. }) {
  134. HStack {
  135. Text(consumedDate, format: Date.FormatStyle().hour().minute())
  136. .font(.body)
  137. .foregroundColor(.primary)
  138. Spacer()
  139. Image(systemName: "chevron.right")
  140. .font(.caption)
  141. .foregroundColor(.secondary)
  142. }
  143. .padding(.vertical, 8)
  144. .padding(.horizontal, 12)
  145. .background(Color(.systemGray6))
  146. .cornerRadius(8)
  147. }
  148. }
  149. }
  150. Section {
  151. Button(action: sendCarbs) {
  152. if isLoading {
  153. HStack {
  154. ProgressView()
  155. .scaleEffect(0.8)
  156. Text("Sending...")
  157. }
  158. } else {
  159. Text("Send Carbs")
  160. }
  161. }
  162. .disabled(carbsAmount.doubleValue(for: .gram()) <= 0 || isLoading || isTOTPBlocked)
  163. .frame(maxWidth: .infinity)
  164. }
  165. // TOTP Blocking Warning Section
  166. if isTOTPBlocked && showTOTPWarning {
  167. Section {
  168. VStack(alignment: .leading, spacing: 8) {
  169. HStack {
  170. Image(systemName: "exclamationmark.triangle.fill")
  171. .foregroundColor(.orange)
  172. Text("TOTP Code Already Used")
  173. .font(.headline)
  174. .foregroundColor(.orange)
  175. }
  176. Text("This TOTP code has already been used for a command. Please wait for the next code to be generated before sending another command.")
  177. .font(.caption)
  178. .foregroundColor(.secondary)
  179. .multilineTextAlignment(.leading)
  180. }
  181. .padding(.vertical, 4)
  182. }
  183. }
  184. Section(header: Text("Security")) {
  185. VStack(alignment: .leading) {
  186. Text("Current OTP Code")
  187. .font(.headline)
  188. if let otpCode = TOTPGenerator.extractOTPFromURL(Storage.shared.loopAPNSQrCodeURL.value) {
  189. HStack {
  190. Text(otpCode)
  191. .font(.system(.body, design: .monospaced))
  192. .foregroundColor(.green)
  193. .padding(.vertical, 4)
  194. .padding(.horizontal, 8)
  195. .background(Color.green.opacity(0.1))
  196. .cornerRadius(4)
  197. Text("(" + (otpTimeRemaining.map { "\($0)s left" } ?? "-") + ")")
  198. .font(.caption)
  199. .foregroundColor(.secondary)
  200. }
  201. } else {
  202. Text("Invalid QR code URL")
  203. .foregroundColor(.red)
  204. }
  205. }
  206. }
  207. }
  208. .navigationTitle("Carbs")
  209. .navigationBarTitleDisplayMode(.inline)
  210. }
  211. .sheet(isPresented: $showDatePickerSheet) {
  212. VStack {
  213. Text("Consumption Time")
  214. .font(.headline)
  215. .padding()
  216. Form {
  217. DatePicker("Time", selection: $consumedDate, displayedComponents: [.hourAndMinute, .date])
  218. .datePickerStyle(.automatic)
  219. }
  220. }
  221. .presentationDetents([.fraction(1 / 4)])
  222. }
  223. .onAppear {
  224. // Validate APNS setup
  225. let apnsService = LoopAPNSService()
  226. if !apnsService.validateSetup() {
  227. alertMessage = "Loop APNS setup is incomplete. Please configure all required fields in settings."
  228. alertType = .error
  229. showAlert = true
  230. }
  231. // Reset timer state so it shows '-' until first tick
  232. otpTimeRemaining = nil
  233. // Don't reset TOTP usage flag here - let the timer handle it
  234. // Validate TOTP state when view appears
  235. _ = isTOTPBlocked
  236. // Add delay before showing TOTP warning to prevent flash after successful send
  237. if isTOTPBlocked {
  238. DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
  239. showTOTPWarning = true
  240. }
  241. } else {
  242. showTOTPWarning = false
  243. }
  244. }
  245. .onReceive(otpTimer) { _ in
  246. let now = Date().timeIntervalSince1970
  247. let newOtpTimeRemaining = Int(otpPeriod - (now.truncatingRemainder(dividingBy: otpPeriod)))
  248. // Check if we've moved to a new TOTP period (when time remaining increases)
  249. if let currentOtpTimeRemaining = otpTimeRemaining,
  250. newOtpTimeRemaining > currentOtpTimeRemaining
  251. {
  252. // New TOTP code generated, reset the usage flag
  253. TOTPService.shared.resetTOTPUsage()
  254. }
  255. // Also check if we're at the very beginning of a new period (when time remaining is close to 30)
  256. if newOtpTimeRemaining >= 29 {
  257. // We're at the start of a new TOTP period, reset the usage flag
  258. TOTPService.shared.resetTOTPUsage()
  259. }
  260. otpTimeRemaining = newOtpTimeRemaining
  261. }
  262. .alert(isPresented: $showAlert) {
  263. switch alertType {
  264. case .success:
  265. return Alert(
  266. title: Text("Success"),
  267. message: Text(alertMessage),
  268. dismissButton: .default(Text("OK")) {
  269. presentationMode.wrappedValue.dismiss()
  270. }
  271. )
  272. case .error:
  273. return Alert(
  274. title: Text("Error"),
  275. message: Text(alertMessage),
  276. dismissButton: .default(Text("OK"))
  277. )
  278. case .confirmation:
  279. let timeFormatter = DateFormatter()
  280. timeFormatter.timeStyle = .short
  281. timeFormatter.dateStyle = .short
  282. return Alert(
  283. title: Text("Confirm Carbs"),
  284. message: Text("Send \(Int(carbsAmount.doubleValue(for: .gram())))g of carbs with \(absorptionTimeString)h absorption time at \(timeFormatter.string(from: consumedDate))?"),
  285. primaryButton: .default(Text("Send")) {
  286. sendCarbsConfirmed()
  287. },
  288. secondaryButton: .cancel()
  289. )
  290. }
  291. }
  292. }
  293. }
  294. private func sendCarbs() {
  295. guard carbsAmount.doubleValue(for: .gram()) > 0 else {
  296. alertMessage = "Please enter a valid carb amount"
  297. alertType = .error
  298. showAlert = true
  299. return
  300. }
  301. // Check guardrails
  302. let maxCarbs = Storage.shared.maxCarbs.value.doubleValue(for: .gram())
  303. let carbsValue = carbsAmount.doubleValue(for: .gram())
  304. if carbsValue > maxCarbs {
  305. alertMessage = "Carbs amount (\(Int(carbsValue))g) exceeds the maximum allowed (\(Int(maxCarbs))g). Please reduce the amount."
  306. alertType = .error
  307. showAlert = true
  308. return
  309. }
  310. // Validate time constraints (similar to LoopCaregiver)
  311. let now = Date()
  312. let maxPastHours = 12
  313. let maxFutureHours = 1
  314. let oldestAcceptedDate = now.addingTimeInterval(-60 * 60 * Double(maxPastHours))
  315. let latestAcceptedDate = now.addingTimeInterval(60 * 60 * Double(maxFutureHours))
  316. if consumedDate < oldestAcceptedDate {
  317. alertMessage = "Time must be within the prior \(maxPastHours) hours"
  318. alertType = .error
  319. showAlert = true
  320. return
  321. }
  322. if consumedDate > latestAcceptedDate {
  323. alertMessage = "Time must be within the next \(maxFutureHours) hour"
  324. alertType = .error
  325. showAlert = true
  326. return
  327. }
  328. alertType = .confirmation
  329. showAlert = true
  330. }
  331. private func sendCarbsConfirmed() {
  332. isLoading = true
  333. // Extract OTP from QR code URL
  334. guard let otpCode = TOTPGenerator.extractOTPFromURL(Storage.shared.loopAPNSQrCodeURL.value) else {
  335. alertMessage = "Invalid QR code URL. Please re-scan the QR code in settings."
  336. alertType = .error
  337. isLoading = false
  338. showAlert = true
  339. return
  340. }
  341. // Parse absorption time string to double
  342. guard let absorptionTimeValue = Double(absorptionTimeString), absorptionTimeValue >= 0.5, absorptionTimeValue <= 8.0 else {
  343. alertMessage = "Please enter a valid absorption time between 0.5 and 8.0 hours"
  344. alertType = .error
  345. isLoading = false
  346. showAlert = true
  347. return
  348. }
  349. // Create the APNS payload for carbs with custom time
  350. // We "randomize" the milliseconds to avoid issue with NS which
  351. // doesn't allow entries at the same second.
  352. let adjustedConsumedDate = consumedDate.dateUsingCurrentSeconds()
  353. let payload = LoopAPNSPayload(
  354. type: .carbs,
  355. carbsAmount: carbsAmount.doubleValue(for: .gram()),
  356. absorptionTime: absorptionTimeValue,
  357. foodType: foodType.isEmpty ? nil : foodType,
  358. consumedDate: adjustedConsumedDate,
  359. otp: otpCode
  360. )
  361. let apnsService = LoopAPNSService()
  362. apnsService.sendCarbsViaAPNS(payload: payload) { success, errorMessage in
  363. DispatchQueue.main.async {
  364. self.isLoading = false
  365. if success {
  366. // Mark TOTP code as used
  367. TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value)
  368. let timeFormatter = DateFormatter()
  369. timeFormatter.timeStyle = .short
  370. self.alertMessage = "Carbs sent successfully for \(timeFormatter.string(from: adjustedConsumedDate))!"
  371. self.alertType = .success
  372. LogManager.shared.log(
  373. category: .apns,
  374. message: "Carbs sent - Amount: \(carbsAmount.doubleValue(for: .gram()))g, Absorption: \(absorptionTimeString)h, Time: \(adjustedConsumedDate)"
  375. )
  376. } else {
  377. self.alertMessage = errorMessage ?? "Failed to send carbs. Check your Loop APNS configuration."
  378. self.alertType = .error
  379. LogManager.shared.log(
  380. category: .apns,
  381. message: "Failed to send carbs: \(errorMessage ?? "unknown error")"
  382. )
  383. }
  384. self.showAlert = true
  385. }
  386. }
  387. }
  388. }
  389. // APNS Payload structure for carbs
  390. struct LoopAPNSPayload {
  391. enum PayloadType {
  392. case carbs
  393. case bolus
  394. }
  395. let type: PayloadType
  396. let carbsAmount: Double?
  397. let absorptionTime: Double?
  398. let foodType: String?
  399. let bolusAmount: Double?
  400. let consumedDate: Date?
  401. let otp: String
  402. init(type: PayloadType, carbsAmount: Double? = nil, absorptionTime: Double? = nil, foodType: String? = nil, bolusAmount: Double? = nil, consumedDate: Date? = nil, otp: String) {
  403. self.type = type
  404. self.carbsAmount = carbsAmount
  405. self.absorptionTime = absorptionTime
  406. self.foodType = foodType
  407. self.bolusAmount = bolusAmount
  408. self.consumedDate = consumedDate
  409. self.otp = otp
  410. }
  411. }