LoopAPNSCarbsView.swift 17 KB

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