MealView.swift 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  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. }
  130. .safeAreaInset(edge: .bottom) {
  131. Button {
  132. carbsFieldIsFocused = false
  133. proteinFieldIsFocused = false
  134. fatFieldIsFocused = false
  135. DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
  136. guard carbs.doubleValue(for: .gram()) != 0 ||
  137. protein.doubleValue(for: .gram()) != 0 ||
  138. fat.doubleValue(for: .gram()) != 0
  139. else {
  140. return
  141. }
  142. if !showAlert {
  143. alertType = .confirmMeal
  144. showAlert = true
  145. }
  146. }
  147. } label: {
  148. if isLoading {
  149. HStack {
  150. ProgressView()
  151. .scaleEffect(0.8)
  152. Text("Sending Meal Data...")
  153. }
  154. .frame(maxWidth: .infinity)
  155. } else {
  156. Text("Send Meal")
  157. .frame(maxWidth: .infinity)
  158. }
  159. }
  160. .buttonStyle(.borderedProminent)
  161. .controlSize(.large)
  162. .disabled(isButtonDisabled)
  163. .padding(.horizontal)
  164. .padding(.vertical, 8)
  165. .background(.bar)
  166. }
  167. .navigationTitle("Meal")
  168. .navigationBarTitleDisplayMode(.inline)
  169. }
  170. .onAppear {
  171. selectedTime = nil
  172. isScheduling = false
  173. if !Storage.shared.hasSeenFatProteinOrderChange.value && Storage.shared.mealWithFatProtein.value {
  174. showFatProteinOrderBanner = true
  175. }
  176. }
  177. .alert(isPresented: $showAlert) {
  178. switch alertType {
  179. case .confirmMeal:
  180. let carbsAmount = carbs.doubleValue(for: HKUnit.gram())
  181. let proteinAmount = protein.doubleValue(for: HKUnit.gram())
  182. let fatAmount = fat.doubleValue(for: HKUnit.gram())
  183. let bolusAmount = bolusAmount.doubleValue(for: .internationalUnit())
  184. var message = "Are you sure you want to send the meal data"
  185. if let selectedTime = selectedTime {
  186. let timeFormatter = DateFormatter()
  187. timeFormatter.timeStyle = .short
  188. let timeString = timeFormatter.string(from: selectedTime)
  189. message += " for \(timeString)?"
  190. } else {
  191. message += " now?"
  192. }
  193. if carbsAmount > 0 {
  194. message += String(format: "\nCarbs: %.0f g", carbsAmount)
  195. }
  196. if proteinAmount > 0 {
  197. message += String(format: "\nProtein: %.0f g", proteinAmount)
  198. }
  199. if fatAmount > 0 {
  200. message += String(format: "\nFat: %.0f g", fatAmount)
  201. }
  202. if bolusAmount > 0 {
  203. message += String(format: "\nBolus: %.2f U", bolusAmount)
  204. }
  205. return Alert(
  206. title: Text("Confirm Meal"),
  207. message: Text(message),
  208. primaryButton: .default(Text("Confirm"), action: {
  209. DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
  210. if bolusAmount > 0 {
  211. AuthService.authenticate(reason: "Confirm your identity to send bolus.") { result in
  212. DispatchQueue.main.async {
  213. switch result {
  214. case .success:
  215. self.sendMealCommand()
  216. case let .unavailable(message):
  217. self.alertMessage = message
  218. self.alertType = .validationError
  219. self.showAlert = true
  220. case .failed:
  221. self.alertMessage = "Authentication failed"
  222. self.alertType = .validationError
  223. self.showAlert = true
  224. case .canceled:
  225. // User canceled, no alert
  226. break
  227. }
  228. }
  229. }
  230. } else {
  231. self.sendMealCommand()
  232. }
  233. }
  234. }),
  235. secondaryButton: .cancel()
  236. )
  237. case .statusSuccess:
  238. return Alert(
  239. title: Text("Status"),
  240. message: Text(statusMessage ?? ""),
  241. dismissButton: .default(Text("OK"), action: {
  242. presentationMode.wrappedValue.dismiss()
  243. })
  244. )
  245. case .statusFailure:
  246. return Alert(
  247. title: Text("Status"),
  248. message: Text(statusMessage ?? ""),
  249. dismissButton: .default(Text("OK"))
  250. )
  251. case .validationError:
  252. return Alert(
  253. title: Text("Validation Error"),
  254. message: Text(alertMessage ?? ""),
  255. dismissButton: .default(Text("OK"))
  256. )
  257. case .none:
  258. return Alert(title: Text("Unknown Alert"))
  259. }
  260. }
  261. }
  262. }
  263. private var isButtonDisabled: Bool {
  264. return isLoading
  265. }
  266. private func sendMealCommand() {
  267. isLoading = true
  268. var scheduledDate: Date? = nil
  269. if isScheduling, let selectedTime = selectedTime {
  270. let calendar = Calendar.current
  271. let now = Date()
  272. let selectedDateComponents = calendar.dateComponents([.hour, .minute], from: selectedTime)
  273. let currentSecond = calendar.component(.second, from: now)
  274. scheduledDate = calendar.date(bySettingHour: selectedDateComponents.hour ?? 0,
  275. minute: selectedDateComponents.minute ?? 0,
  276. second: currentSecond,
  277. of: now) ?? now
  278. }
  279. pushNotificationManager.sendMealPushNotification(
  280. carbs: carbs,
  281. protein: protein,
  282. fat: fat,
  283. bolusAmount: bolusAmount,
  284. scheduledTime: scheduledDate
  285. ) { success, errorMessage in
  286. DispatchQueue.main.async {
  287. isLoading = false
  288. if success {
  289. statusMessage = "Meal command sent successfully."
  290. LogManager.shared.log(
  291. category: .apns,
  292. 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")"
  293. )
  294. // Reset meal values and scheduled data after success
  295. carbs = HKQuantity(unit: .gram(), doubleValue: 0.0)
  296. protein = HKQuantity(unit: .gram(), doubleValue: 0.0)
  297. fat = HKQuantity(unit: .gram(), doubleValue: 0.0)
  298. selectedTime = nil
  299. isScheduling = false
  300. alertType = .statusSuccess
  301. } else {
  302. statusMessage = errorMessage ?? "Failed to send meal command."
  303. LogManager.shared.log(
  304. category: .apns,
  305. message: "sendMealPushNotification failed with error: \(errorMessage ?? "unknown error")"
  306. )
  307. alertType = .statusFailure
  308. }
  309. showAlert = true
  310. }
  311. }
  312. }
  313. private func formatDate(_ date: Date) -> String {
  314. let formatter = DateFormatter()
  315. formatter.timeStyle = .short
  316. return formatter.string(from: date)
  317. }
  318. private func handleValidationError(_ message: String) {
  319. alertMessage = message
  320. alertType = .validationError
  321. showAlert = true
  322. }
  323. }