MealView.swift 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  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. @ObservedObject private var quickPickMeals = QuickPickMealsManager.shared
  20. @FocusState private var carbsFieldIsFocused: Bool
  21. @FocusState private var proteinFieldIsFocused: Bool
  22. @FocusState private var fatFieldIsFocused: Bool
  23. @FocusState private var bolusFieldIsFocused: Bool
  24. @State private var showAlert: Bool = false
  25. @State private var alertType: AlertType? = nil
  26. @State private var alertMessage: String? = nil
  27. @State private var isLoading: Bool = false
  28. @State private var statusMessage: String? = nil
  29. @State private var selectedTime: Date? = nil
  30. @State private var isScheduling: Bool = false
  31. @State private var showFatProteinOrderBanner = false
  32. enum AlertType {
  33. case confirmMeal
  34. case statusSuccess
  35. case statusFailure
  36. case validationError
  37. }
  38. var body: some View {
  39. NavigationView {
  40. VStack {
  41. Form {
  42. if !quickPickMeals.quickPickMeals.isEmpty {
  43. Section(header: QuickPickSectionHeader(title: String(localized: "Quick-Pick Meals"), infoText: QuickPickSectionHeader.mealInfoText)) {
  44. ScrollView(.horizontal, showsIndicators: false) {
  45. HStack(spacing: 12) {
  46. ForEach(quickPickMeals.quickPickMeals) { meal in
  47. Button {
  48. applyQuickPickMeal(meal)
  49. } label: {
  50. VStack(spacing: 2) {
  51. Text("\(Int(meal.carbs))g")
  52. .font(.subheadline.weight(.medium))
  53. if mealWithFatProtein.value, meal.fat > 0 || meal.protein > 0 {
  54. Text("F\(Int(meal.fat)) P\(Int(meal.protein))")
  55. .font(.caption2)
  56. }
  57. if mealWithBolus.value, meal.bolus > 0 {
  58. Text("\(InsulinFormatter.shared.string(meal.bolus))U")
  59. .font(.caption2)
  60. }
  61. }
  62. .padding(.horizontal, 14)
  63. .padding(.vertical, 8)
  64. .background(Color.accentColor.opacity(0.15))
  65. .foregroundColor(.accentColor)
  66. .cornerRadius(8)
  67. }
  68. .buttonStyle(.plain)
  69. }
  70. }
  71. .padding(.vertical, 4)
  72. }
  73. }
  74. }
  75. Section(header: Text("Meal Data")) {
  76. // TODO: This banner can be deleted in March 2027. Check the commit for other places to cleanup.
  77. if showFatProteinOrderBanner {
  78. HStack {
  79. Image(systemName: "arrow.left.arrow.right")
  80. Text("The order of Fat and Protein inputs has changed.").font(.callout)
  81. Spacer()
  82. Button {
  83. Storage.shared.hasSeenFatProteinOrderChange.value = true
  84. withAnimation { showFatProteinOrderBanner = false }
  85. } label: {
  86. Image(systemName: "xmark.circle.fill")
  87. }
  88. .buttonStyle(.plain)
  89. }
  90. .listRowBackground(Color.orange.opacity(0.75))
  91. .transition(.opacity)
  92. }
  93. HKQuantityInputView(
  94. label: "Carbs",
  95. quantity: $carbs,
  96. unit: .gram(),
  97. maxLength: 4,
  98. minValue: HKQuantity(unit: .gram(), doubleValue: 0),
  99. maxValue: maxCarbs.value,
  100. isFocused: $carbsFieldIsFocused,
  101. onValidationError: { message in
  102. handleValidationError(message)
  103. }
  104. )
  105. if mealWithFatProtein.value {
  106. HKQuantityInputView(
  107. label: String(localized: "Fat"),
  108. quantity: $fat,
  109. unit: .gram(),
  110. maxLength: 4,
  111. minValue: HKQuantity(unit: .gram(), doubleValue: 0),
  112. maxValue: maxFat.value,
  113. isFocused: $fatFieldIsFocused,
  114. onValidationError: { message in
  115. handleValidationError(message)
  116. }
  117. )
  118. HKQuantityInputView(
  119. label: String(localized: "Protein"),
  120. quantity: $protein,
  121. unit: .gram(),
  122. maxLength: 4,
  123. minValue: HKQuantity(unit: .gram(), doubleValue: 0),
  124. maxValue: maxProtein.value,
  125. isFocused: $proteinFieldIsFocused,
  126. onValidationError: { message in
  127. handleValidationError(message)
  128. }
  129. )
  130. }
  131. if mealWithBolus.value {
  132. HKQuantityInputView(
  133. label: String(localized: "Bolus Amount"),
  134. quantity: $bolusAmount,
  135. unit: .internationalUnit(),
  136. maxLength: 4,
  137. minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0),
  138. maxValue: maxBolus.value,
  139. isFocused: $bolusFieldIsFocused,
  140. onValidationError: { message in
  141. handleValidationError(message)
  142. }
  143. )
  144. }
  145. }
  146. Section(header: Text("Schedule")) {
  147. Toggle("Schedule for later", isOn: $isScheduling)
  148. if isScheduling {
  149. DatePicker(
  150. String(localized: "Select Time"),
  151. selection: Binding(
  152. get: { self.selectedTime ?? Date() },
  153. set: { self.selectedTime = $0 }
  154. ),
  155. displayedComponents: .hourAndMinute
  156. )
  157. .datePickerStyle(CompactDatePickerStyle())
  158. if bolusAmount.doubleValue(for: .internationalUnit()) > 0 {
  159. Text("Note: The meal will be scheduled, but the bolus is enacted immediately.")
  160. }
  161. }
  162. }
  163. }
  164. .safeAreaInset(edge: .bottom) {
  165. Button {
  166. carbsFieldIsFocused = false
  167. proteinFieldIsFocused = false
  168. fatFieldIsFocused = false
  169. DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
  170. guard carbs.doubleValue(for: .gram()) != 0 ||
  171. protein.doubleValue(for: .gram()) != 0 ||
  172. fat.doubleValue(for: .gram()) != 0
  173. else {
  174. return
  175. }
  176. if !showAlert {
  177. alertType = .confirmMeal
  178. showAlert = true
  179. }
  180. }
  181. } label: {
  182. if isLoading {
  183. HStack {
  184. ProgressView()
  185. .scaleEffect(0.8)
  186. Text("Sending Meal Data...")
  187. }
  188. .frame(maxWidth: .infinity)
  189. } else {
  190. Text("Send Meal")
  191. .frame(maxWidth: .infinity)
  192. }
  193. }
  194. .buttonStyle(.borderedProminent)
  195. .controlSize(.large)
  196. .disabled(isButtonDisabled)
  197. .padding(.horizontal)
  198. .padding(.vertical, 8)
  199. .background(.bar)
  200. }
  201. .navigationTitle("Meal")
  202. .navigationBarTitleDisplayMode(.inline)
  203. }
  204. .onAppear {
  205. selectedTime = nil
  206. isScheduling = false
  207. quickPickMeals.refresh(
  208. maxCarbs: maxCarbs.value.doubleValue(for: .gram()),
  209. includeFatProtein: mealWithFatProtein.value
  210. )
  211. if !Storage.shared.hasSeenFatProteinOrderChange.value && Storage.shared.mealWithFatProtein.value {
  212. showFatProteinOrderBanner = true
  213. }
  214. }
  215. .alert(isPresented: $showAlert) {
  216. switch alertType {
  217. case .confirmMeal:
  218. let carbsAmount = carbs.doubleValue(for: HKUnit.gram())
  219. let proteinAmount = protein.doubleValue(for: HKUnit.gram())
  220. let fatAmount = fat.doubleValue(for: HKUnit.gram())
  221. let bolusAmount = bolusAmount.doubleValue(for: .internationalUnit())
  222. var message = String(localized: "Are you sure you want to send the meal data")
  223. if let selectedTime = selectedTime {
  224. let timeFormatter = DateFormatter()
  225. timeFormatter.timeStyle = .short
  226. let timeString = timeFormatter.string(from: selectedTime)
  227. message += String(localized: " for \(timeString)?")
  228. } else {
  229. message += String(localized: " now?")
  230. }
  231. if carbsAmount > 0 {
  232. message += String(format: "\nCarbs: %.0f g", carbsAmount)
  233. }
  234. if proteinAmount > 0 {
  235. message += String(format: "\nProtein: %.0f g", proteinAmount)
  236. }
  237. if fatAmount > 0 {
  238. message += String(format: "\nFat: %.0f g", fatAmount)
  239. }
  240. if bolusAmount > 0 {
  241. message += String(format: "\nBolus: %.2f U", bolusAmount)
  242. }
  243. return Alert(
  244. title: Text("Confirm Meal"),
  245. message: Text(message),
  246. primaryButton: .default(Text("Confirm"), action: {
  247. DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
  248. if bolusAmount > 0 {
  249. AuthService.authenticate(reason: String(localized: "Confirm your identity to send bolus.")) { result in
  250. DispatchQueue.main.async {
  251. switch result {
  252. case .success:
  253. self.sendMealCommand()
  254. case let .unavailable(message):
  255. self.alertMessage = message
  256. self.alertType = .validationError
  257. self.showAlert = true
  258. case .failed:
  259. self.alertMessage = String(localized: "Authentication failed")
  260. self.alertType = .validationError
  261. self.showAlert = true
  262. case .canceled:
  263. // User canceled, no alert
  264. break
  265. }
  266. }
  267. }
  268. } else {
  269. self.sendMealCommand()
  270. }
  271. }
  272. }),
  273. secondaryButton: .cancel()
  274. )
  275. case .statusSuccess:
  276. return Alert(
  277. title: Text("Status"),
  278. message: Text(statusMessage ?? ""),
  279. dismissButton: .default(Text("OK"), action: {
  280. presentationMode.wrappedValue.dismiss()
  281. })
  282. )
  283. case .statusFailure:
  284. return Alert(
  285. title: Text("Status"),
  286. message: Text(statusMessage ?? ""),
  287. dismissButton: .default(Text("OK"))
  288. )
  289. case .validationError:
  290. return Alert(
  291. title: Text("Validation Error"),
  292. message: Text(alertMessage ?? ""),
  293. dismissButton: .default(Text("OK"))
  294. )
  295. case .none:
  296. return Alert(title: Text("Unknown Alert"))
  297. }
  298. }
  299. }
  300. }
  301. private var isButtonDisabled: Bool {
  302. return isLoading
  303. }
  304. private func sendMealCommand() {
  305. isLoading = true
  306. var scheduledDate: Date? = nil
  307. if isScheduling, let selectedTime = selectedTime {
  308. let calendar = Calendar.current
  309. let now = Date()
  310. let selectedDateComponents = calendar.dateComponents([.hour, .minute], from: selectedTime)
  311. let currentSecond = calendar.component(.second, from: now)
  312. scheduledDate = calendar.date(bySettingHour: selectedDateComponents.hour ?? 0,
  313. minute: selectedDateComponents.minute ?? 0,
  314. second: currentSecond,
  315. of: now) ?? now
  316. }
  317. pushNotificationManager.sendMealPushNotification(
  318. carbs: carbs,
  319. protein: protein,
  320. fat: fat,
  321. bolusAmount: bolusAmount,
  322. scheduledTime: scheduledDate
  323. ) { success, errorMessage in
  324. DispatchQueue.main.async {
  325. isLoading = false
  326. if success {
  327. let sentCarbs = carbs.doubleValue(for: .gram())
  328. if sentCarbs > 0 {
  329. QuickPickMealsManager.shared.recordMeal(
  330. carbs: sentCarbs,
  331. fat: fat.doubleValue(for: .gram()),
  332. protein: protein.doubleValue(for: .gram()),
  333. bolus: bolusAmount.doubleValue(for: .internationalUnit())
  334. )
  335. }
  336. statusMessage = String(localized: "Meal command sent successfully.")
  337. LogManager.shared.log(
  338. category: .apns,
  339. 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")"
  340. )
  341. // Reset meal values and scheduled data after success
  342. carbs = HKQuantity(unit: .gram(), doubleValue: 0.0)
  343. protein = HKQuantity(unit: .gram(), doubleValue: 0.0)
  344. fat = HKQuantity(unit: .gram(), doubleValue: 0.0)
  345. selectedTime = nil
  346. isScheduling = false
  347. alertType = .statusSuccess
  348. } else {
  349. statusMessage = errorMessage ?? String(localized: "Failed to send meal command.")
  350. LogManager.shared.log(
  351. category: .apns,
  352. message: "sendMealPushNotification failed with error: \(errorMessage ?? "unknown error")"
  353. )
  354. alertType = .statusFailure
  355. }
  356. showAlert = true
  357. }
  358. }
  359. }
  360. private func formatDate(_ date: Date) -> String {
  361. let formatter = DateFormatter()
  362. formatter.timeStyle = .short
  363. return formatter.string(from: date)
  364. }
  365. private func applyQuickPickMeal(_ meal: QuickPickMeal) {
  366. let maxC = maxCarbs.value.doubleValue(for: .gram())
  367. carbs = HKQuantity(unit: .gram(), doubleValue: min(meal.carbs, maxC))
  368. if mealWithFatProtein.value {
  369. let maxF = maxFat.value.doubleValue(for: .gram())
  370. let maxP = maxProtein.value.doubleValue(for: .gram())
  371. fat = HKQuantity(unit: .gram(), doubleValue: min(meal.fat, maxF))
  372. protein = HKQuantity(unit: .gram(), doubleValue: min(meal.protein, maxP))
  373. }
  374. if mealWithBolus.value, meal.bolus > 0 {
  375. let maxB = maxBolus.value.doubleValue(for: .internationalUnit())
  376. bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: min(meal.bolus, maxB))
  377. }
  378. }
  379. private func handleValidationError(_ message: String) {
  380. alertMessage = message
  381. alertType = .validationError
  382. showAlert = true
  383. }
  384. }