MealView.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. // LoopFollow
  2. // MealView.swift
  3. import HealthKit
  4. import LocalAuthentication
  5. import SwiftUI
  6. struct MealView: View {
  7. @Environment(\.presentationMode) private var presentationMode
  8. @State private var carbs = HKQuantity(unit: .gram(), doubleValue: 0.0)
  9. @State private var protein = HKQuantity(unit: .gram(), doubleValue: 0.0)
  10. @State private var fat = HKQuantity(unit: .gram(), doubleValue: 0.0)
  11. @State private var bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0)
  12. private let pushNotificationManager = PushNotificationManager()
  13. @ObservedObject private var maxCarbs = Storage.shared.maxCarbs
  14. @ObservedObject private var maxProtein = Storage.shared.maxProtein
  15. @ObservedObject private var maxFat = Storage.shared.maxFat
  16. @ObservedObject private var mealWithBolus = Storage.shared.mealWithBolus
  17. @ObservedObject private var mealWithFatProtein = Storage.shared.mealWithFatProtein
  18. @ObservedObject private var maxBolus = Storage.shared.maxBolus
  19. @FocusState private var carbsFieldIsFocused: Bool
  20. @FocusState private var proteinFieldIsFocused: Bool
  21. @FocusState private var fatFieldIsFocused: Bool
  22. @FocusState private var bolusFieldIsFocused: Bool
  23. @State private var showAlert: Bool = false
  24. @State private var alertType: AlertType? = nil
  25. @State private var alertMessage: String? = nil
  26. @State private var isLoading: Bool = false
  27. @State private var statusMessage: String? = nil
  28. @State private var selectedTime: Date? = nil
  29. @State private var isScheduling: Bool = false
  30. @State private var showFatProteinOrderBanner = false
  31. enum AlertType {
  32. case confirmMeal
  33. case statusSuccess
  34. case statusFailure
  35. case validationError
  36. }
  37. var body: some View {
  38. NavigationView {
  39. VStack {
  40. Form {
  41. Section(header: Text("Meal Data")) {
  42. // TODO: This banner can be deleted in March 2027. Check the commit for other places to cleanup.
  43. if showFatProteinOrderBanner {
  44. HStack {
  45. Image(systemName: "arrow.left.arrow.right")
  46. Text("The order of Fat and Protein inputs has changed.").font(.callout)
  47. Spacer()
  48. Button {
  49. Storage.shared.hasSeenFatProteinOrderChange.value = true
  50. withAnimation { showFatProteinOrderBanner = false }
  51. } label: {
  52. Image(systemName: "xmark.circle.fill")
  53. }
  54. .buttonStyle(.plain)
  55. }
  56. .listRowBackground(Color.orange.opacity(0.75))
  57. .transition(.opacity)
  58. }
  59. HKQuantityInputView(
  60. label: "Carbs",
  61. quantity: $carbs,
  62. unit: .gram(),
  63. maxLength: 4,
  64. minValue: HKQuantity(unit: .gram(), doubleValue: 0),
  65. maxValue: maxCarbs.value,
  66. isFocused: $carbsFieldIsFocused,
  67. onValidationError: { message in
  68. handleValidationError(message)
  69. }
  70. )
  71. if mealWithFatProtein.value {
  72. HKQuantityInputView(
  73. label: "Fat",
  74. quantity: $fat,
  75. unit: .gram(),
  76. maxLength: 4,
  77. minValue: HKQuantity(unit: .gram(), doubleValue: 0),
  78. maxValue: maxFat.value,
  79. isFocused: $fatFieldIsFocused,
  80. onValidationError: { message in
  81. handleValidationError(message)
  82. }
  83. )
  84. HKQuantityInputView(
  85. label: "Protein",
  86. quantity: $protein,
  87. unit: .gram(),
  88. maxLength: 4,
  89. minValue: HKQuantity(unit: .gram(), doubleValue: 0),
  90. maxValue: maxProtein.value,
  91. isFocused: $proteinFieldIsFocused,
  92. onValidationError: { message in
  93. handleValidationError(message)
  94. }
  95. )
  96. }
  97. if mealWithBolus.value {
  98. HKQuantityInputView(
  99. label: "Bolus Amount",
  100. quantity: $bolusAmount,
  101. unit: .internationalUnit(),
  102. maxLength: 4,
  103. minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0),
  104. maxValue: maxBolus.value,
  105. isFocused: $bolusFieldIsFocused,
  106. onValidationError: { message in
  107. handleValidationError(message)
  108. }
  109. )
  110. }
  111. }
  112. Section(header: Text("Schedule")) {
  113. Toggle("Schedule for later", isOn: $isScheduling)
  114. if isScheduling {
  115. DatePicker(
  116. "Select Time",
  117. selection: Binding(
  118. get: { self.selectedTime ?? Date() },
  119. set: { self.selectedTime = $0 }
  120. ),
  121. displayedComponents: .hourAndMinute
  122. )
  123. .datePickerStyle(CompactDatePickerStyle())
  124. if bolusAmount.doubleValue(for: .internationalUnit()) > 0 {
  125. Text("Note: The meal will be scheduled, but the bolus is enacted immediately.")
  126. }
  127. }
  128. }
  129. LoadingButtonView(
  130. buttonText: "Send Meal",
  131. progressText: "Sending Meal Data...",
  132. isLoading: isLoading,
  133. action: {
  134. carbsFieldIsFocused = false
  135. proteinFieldIsFocused = false
  136. fatFieldIsFocused = false
  137. DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
  138. guard carbs.doubleValue(for: .gram()) != 0 ||
  139. protein.doubleValue(for: .gram()) != 0 ||
  140. fat.doubleValue(for: .gram()) != 0
  141. else {
  142. return
  143. }
  144. if !showAlert {
  145. alertType = .confirmMeal
  146. showAlert = true
  147. }
  148. }
  149. },
  150. isDisabled: isButtonDisabled
  151. )
  152. }
  153. .navigationTitle("Meal")
  154. .navigationBarTitleDisplayMode(.inline)
  155. }
  156. .onAppear {
  157. selectedTime = nil
  158. isScheduling = false
  159. if !Storage.shared.hasSeenFatProteinOrderChange.value && Storage.shared.mealWithFatProtein.value {
  160. showFatProteinOrderBanner = true
  161. }
  162. }
  163. .alert(isPresented: $showAlert) {
  164. switch alertType {
  165. case .confirmMeal:
  166. let carbsAmount = carbs.doubleValue(for: HKUnit.gram())
  167. let proteinAmount = protein.doubleValue(for: HKUnit.gram())
  168. let fatAmount = fat.doubleValue(for: HKUnit.gram())
  169. let bolusAmount = bolusAmount.doubleValue(for: .internationalUnit())
  170. var message = "Are you sure you want to send the meal data"
  171. if let selectedTime = selectedTime {
  172. let timeFormatter = DateFormatter()
  173. timeFormatter.timeStyle = .short
  174. let timeString = timeFormatter.string(from: selectedTime)
  175. message += " for \(timeString)?"
  176. } else {
  177. message += " now?"
  178. }
  179. if carbsAmount > 0 {
  180. message += String(format: "\nCarbs: %.0f g", carbsAmount)
  181. }
  182. if proteinAmount > 0 {
  183. message += String(format: "\nProtein: %.0f g", proteinAmount)
  184. }
  185. if fatAmount > 0 {
  186. message += String(format: "\nFat: %.0f g", fatAmount)
  187. }
  188. if bolusAmount > 0 {
  189. message += String(format: "\nBolus: %.2f U", bolusAmount)
  190. }
  191. return Alert(
  192. title: Text("Confirm Meal"),
  193. message: Text(message),
  194. primaryButton: .default(Text("Confirm"), action: {
  195. DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
  196. if bolusAmount > 0 {
  197. AuthService.authenticate(reason: "Confirm your identity to send bolus.") { result in
  198. DispatchQueue.main.async {
  199. switch result {
  200. case .success:
  201. self.sendMealCommand()
  202. case let .unavailable(message):
  203. self.alertMessage = message
  204. self.alertType = .validationError
  205. self.showAlert = true
  206. case .failed:
  207. self.alertMessage = "Authentication failed"
  208. self.alertType = .validationError
  209. self.showAlert = true
  210. case .canceled:
  211. // User canceled, no alert
  212. break
  213. }
  214. }
  215. }
  216. } else {
  217. self.sendMealCommand()
  218. }
  219. }
  220. }),
  221. secondaryButton: .cancel()
  222. )
  223. case .statusSuccess:
  224. return Alert(
  225. title: Text("Status"),
  226. message: Text(statusMessage ?? ""),
  227. dismissButton: .default(Text("OK"), action: {
  228. presentationMode.wrappedValue.dismiss()
  229. })
  230. )
  231. case .statusFailure:
  232. return Alert(
  233. title: Text("Status"),
  234. message: Text(statusMessage ?? ""),
  235. dismissButton: .default(Text("OK"))
  236. )
  237. case .validationError:
  238. return Alert(
  239. title: Text("Validation Error"),
  240. message: Text(alertMessage ?? ""),
  241. dismissButton: .default(Text("OK"))
  242. )
  243. case .none:
  244. return Alert(title: Text("Unknown Alert"))
  245. }
  246. }
  247. }
  248. }
  249. private var isButtonDisabled: Bool {
  250. return isLoading
  251. }
  252. private func sendMealCommand() {
  253. isLoading = true
  254. var scheduledDate: Date? = nil
  255. if isScheduling, let selectedTime = selectedTime {
  256. let calendar = Calendar.current
  257. let now = Date()
  258. let selectedDateComponents = calendar.dateComponents([.hour, .minute], from: selectedTime)
  259. let currentSecond = calendar.component(.second, from: now)
  260. scheduledDate = calendar.date(bySettingHour: selectedDateComponents.hour ?? 0,
  261. minute: selectedDateComponents.minute ?? 0,
  262. second: currentSecond,
  263. of: now) ?? now
  264. }
  265. pushNotificationManager.sendMealPushNotification(
  266. carbs: carbs,
  267. protein: protein,
  268. fat: fat,
  269. bolusAmount: bolusAmount,
  270. scheduledTime: scheduledDate
  271. ) { success, errorMessage in
  272. DispatchQueue.main.async {
  273. isLoading = false
  274. if success {
  275. statusMessage = "Meal command sent successfully."
  276. LogManager.shared.log(
  277. category: .apns,
  278. message: "sendMealPushNotification succeeded - Carbs: \(carbs.doubleValue(for: .gram())) g, Protein: \(protein.doubleValue(for: .gram())) g, Fat: \(fat.doubleValue(for: .gram())) g, Bolus: \(bolusAmount.doubleValue(for: .internationalUnit())) U, Scheduled: \(scheduledDate != nil ? formatDate(scheduledDate!) : "now")"
  279. )
  280. // Reset meal values and scheduled data after success
  281. carbs = HKQuantity(unit: .gram(), doubleValue: 0.0)
  282. protein = HKQuantity(unit: .gram(), doubleValue: 0.0)
  283. fat = HKQuantity(unit: .gram(), doubleValue: 0.0)
  284. selectedTime = nil
  285. isScheduling = false
  286. alertType = .statusSuccess
  287. } else {
  288. statusMessage = errorMessage ?? "Failed to send meal command."
  289. LogManager.shared.log(
  290. category: .apns,
  291. message: "sendMealPushNotification failed with error: \(errorMessage ?? "unknown error")"
  292. )
  293. alertType = .statusFailure
  294. }
  295. showAlert = true
  296. }
  297. }
  298. }
  299. private func formatDate(_ date: Date) -> String {
  300. let formatter = DateFormatter()
  301. formatter.timeStyle = .short
  302. return formatter.string(from: date)
  303. }
  304. private func handleValidationError(_ message: String) {
  305. alertMessage = message
  306. alertType = .validationError
  307. showAlert = true
  308. }
  309. }