MealStatsView.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. import Charts
  2. import SwiftUI
  3. /// A view that displays a bar chart for meal statistics.
  4. ///
  5. /// This view presents macronutrient intake (carbohydrates, fats, and proteins) over time,
  6. /// allowing users to adjust the time interval and scroll through historical data.
  7. struct MealStatsView: View {
  8. /// The selected time interval for displaying statistics.
  9. @Binding var selectedDuration: Stat.StateModel.StatsTimeInterval
  10. /// The list of meal statistics data.
  11. let mealStats: [MealStats]
  12. /// The state model containing cached statistics data.
  13. let state: Stat.StateModel
  14. /// The current scroll position in the chart.
  15. @State private var scrollPosition = Date()
  16. /// The currently selected date in the chart.
  17. @State private var selectedDate: Date?
  18. /// The calculated macronutrient averages for the visible range.
  19. @State private var currentAverages: (carbs: Double, fat: Double, protein: Double) = (0, 0, 0)
  20. /// Timer to throttle updates when scrolling.
  21. @State private var updateTimer = Stat.UpdateTimer()
  22. /// Computes the visible date range based on the current scroll position.
  23. private var visibleDateRange: (start: Date, end: Date) {
  24. StatChartUtils.visibleDateRange(from: scrollPosition, for: selectedDuration)
  25. }
  26. /// Retrieves the meal statistic for a given date.
  27. /// - Parameter date: The date for which to retrieve meal data.
  28. /// - Returns: The `MealStats` object if available, otherwise `nil`.
  29. private func getMealForDate(_ date: Date) -> MealStats? {
  30. mealStats.first { stat in
  31. StatChartUtils.isSameTimeUnit(stat.date, date, for: selectedDuration)
  32. }
  33. }
  34. /// Updates the macronutrient averages based on the visible date range.
  35. private func updateAverages() {
  36. currentAverages = state.getCachedMealAverages(for: visibleDateRange)
  37. }
  38. /// A view displaying the statistics summary including macronutrient averages.
  39. private var statsView: some View {
  40. HStack {
  41. Grid(alignment: .leading) {
  42. GridRow {
  43. Text("Carbs:")
  44. Text(currentAverages.carbs.formatted(.number.precision(.fractionLength(1))))
  45. Text("g")
  46. }
  47. if state.useFPUconversion {
  48. GridRow {
  49. Text("Fat:")
  50. Text(currentAverages.fat.formatted(.number.precision(.fractionLength(1))))
  51. Text("g")
  52. }
  53. GridRow {
  54. Text("Protein:")
  55. Text(currentAverages.protein.formatted(.number.precision(.fractionLength(1))))
  56. Text("g")
  57. }
  58. }
  59. }
  60. .font(.headline)
  61. Spacer()
  62. Text(
  63. StatChartUtils
  64. .formatVisibleDateRange(from: visibleDateRange.start, to: visibleDateRange.end, for: selectedDuration)
  65. )
  66. .font(.callout)
  67. .foregroundStyle(.secondary)
  68. }
  69. }
  70. var body: some View {
  71. VStack(alignment: .leading, spacing: 8) {
  72. statsView.padding(.bottom)
  73. VStack(alignment: .trailing) {
  74. Text("Macro Nutrients (g)")
  75. .foregroundStyle(.secondary)
  76. .font(.footnote)
  77. .padding(.bottom, 4)
  78. chartsView
  79. }
  80. }
  81. .onAppear {
  82. scrollPosition = StatChartUtils.getInitialScrollPosition(for: selectedDuration)
  83. updateAverages()
  84. }
  85. .onChange(of: scrollPosition) {
  86. updateTimer.scheduleUpdate {
  87. updateAverages()
  88. }
  89. }
  90. .onChange(of: selectedDuration) {
  91. Task {
  92. scrollPosition = StatChartUtils.getInitialScrollPosition(for: selectedDuration)
  93. updateAverages()
  94. }
  95. }
  96. }
  97. /// A view displaying the bar chart for meal statistics.
  98. private var chartsView: some View {
  99. Chart {
  100. ForEach(mealStats) { stat in
  101. // Carbs Bar (bottom)
  102. BarMark(
  103. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  104. y: .value("Amount", stat.carbs)
  105. )
  106. .foregroundStyle(by: .value("Type", "Carbs"))
  107. .position(by: .value("Type", "Macros"))
  108. .opacity(
  109. selectedDate.map { date in
  110. StatChartUtils.isSameTimeUnit(stat.date, date, for: selectedDuration) ? 1 : 0.3
  111. } ?? 1
  112. )
  113. if state.useFPUconversion {
  114. // Fat Bar (middle)
  115. BarMark(
  116. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  117. y: .value("Amount", stat.fat)
  118. )
  119. .foregroundStyle(by: .value("Type", "Fat"))
  120. .position(by: .value("Type", "Macros"))
  121. .opacity(
  122. selectedDate.map { date in
  123. StatChartUtils.isSameTimeUnit(stat.date, date, for: selectedDuration) ? 1 : 0.3
  124. } ?? 1
  125. )
  126. // Protein Bar (top)
  127. BarMark(
  128. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  129. y: .value("Amount", stat.protein)
  130. )
  131. .foregroundStyle(by: .value("Type", "Protein"))
  132. .position(by: .value("Type", "Macros"))
  133. .opacity(
  134. selectedDate.map { date in
  135. StatChartUtils.isSameTimeUnit(stat.date, date, for: selectedDuration) ? 1 : 0.3
  136. } ?? 1
  137. )
  138. }
  139. }
  140. // Selection popover outside of the ForEach loop!
  141. if let selectedDate,
  142. let selectedMeal = getMealForDate(selectedDate)
  143. {
  144. RuleMark(
  145. x: .value("Selected Date", selectedDate)
  146. )
  147. .foregroundStyle(.secondary.opacity(0.5))
  148. .annotation(
  149. position: .top,
  150. spacing: 0,
  151. overflowResolution: .init(x: .fit(to: .chart), y: .fit(to: .chart))
  152. ) {
  153. MealSelectionPopover(
  154. date: selectedDate,
  155. meal: selectedMeal,
  156. selectedDuration: selectedDuration,
  157. isFpuEnabled: state.useFPUconversion
  158. )
  159. }
  160. }
  161. }
  162. .chartForegroundStyleScale(state.useFPUconversion ? [
  163. "Carbs": Color.orange,
  164. "Fat": Color.green,
  165. "Protein": Color.blue
  166. ] : ["Carbs": Color.orange])
  167. .chartLegend(position: .bottom, alignment: .leading, spacing: 12)
  168. .chartYAxis {
  169. AxisMarks(position: .trailing) { value in
  170. if let amount = value.as(Double.self) {
  171. AxisValueLabel {
  172. Text(amount.formatted(.number.precision(.fractionLength(0))))
  173. .font(.footnote)
  174. }
  175. AxisGridLine()
  176. }
  177. }
  178. }
  179. .chartXAxis {
  180. AxisMarks(preset: .aligned, values: .stride(by: selectedDuration == .Day ? .hour : .day)) { value in
  181. if let date = value.as(Date.self) {
  182. let day = Calendar.current.component(.day, from: date)
  183. let hour = Calendar.current.component(.hour, from: date)
  184. switch selectedDuration {
  185. case .Day:
  186. if hour % 6 == 0 {
  187. AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedDuration), centered: true)
  188. .font(.footnote)
  189. AxisGridLine()
  190. }
  191. case .Month:
  192. if day % 5 == 0 {
  193. AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedDuration), centered: true)
  194. .font(.footnote)
  195. AxisGridLine()
  196. }
  197. case .Total:
  198. if day == 1 && Calendar.current.component(.month, from: date) % 3 == 1 {
  199. AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedDuration), centered: true)
  200. .font(.footnote)
  201. AxisGridLine()
  202. }
  203. default:
  204. AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedDuration), centered: true)
  205. .font(.footnote)
  206. AxisGridLine()
  207. }
  208. }
  209. }
  210. }
  211. .chartScrollableAxes(.horizontal)
  212. .chartXSelection(value: $selectedDate.animation(.easeInOut))
  213. .chartScrollPosition(x: $scrollPosition)
  214. .chartScrollTargetBehavior(
  215. .valueAligned(
  216. matching: selectedDuration == .Day ?
  217. DateComponents(minute: 0) :
  218. DateComponents(hour: 0),
  219. majorAlignment: .matching(StatChartUtils.alignmentComponents(for: selectedDuration))
  220. )
  221. )
  222. .chartXVisibleDomain(length: StatChartUtils.visibleDomainLength(for: selectedDuration))
  223. .frame(height: 250)
  224. }
  225. }
  226. /// A view that displays detailed meal information in a popover
  227. ///
  228. /// This view shows a formatted display of meal macronutrients including:
  229. /// - Date of the meal
  230. /// - Carbohydrates in grams
  231. /// - Fat in grams
  232. /// - Protein in grams
  233. private struct MealSelectionPopover: View {
  234. // The date when the meal was logged
  235. let date: Date
  236. // The meal statistics to display
  237. let meal: MealStats
  238. // The selected duration in the time picker
  239. let selectedDuration: Stat.StateModel.StatsTimeInterval
  240. // Setting controlling whether to display fat and protein
  241. let isFpuEnabled: Bool
  242. private var timeText: String {
  243. if selectedDuration == .Day {
  244. let hour = Calendar.current.component(.hour, from: date)
  245. return "\(hour):00-\(hour + 1):00"
  246. } else {
  247. return date.formatted(.dateTime.month().day())
  248. }
  249. }
  250. var body: some View {
  251. VStack(alignment: .leading, spacing: 4) {
  252. // Display formatted date header
  253. Text(timeText)
  254. .font(.footnote)
  255. .fontWeight(.bold)
  256. // Grid layout for macronutrient values
  257. Grid(alignment: .leading) {
  258. // Carbohydrates row
  259. GridRow {
  260. Text("Carbs:")
  261. Text(meal.carbs.formatted(.number.precision(.fractionLength(1))))
  262. .gridColumnAlignment(.trailing)
  263. Text("g")
  264. }
  265. if isFpuEnabled {
  266. // Fat row
  267. GridRow {
  268. Text("Fat:")
  269. Text(meal.fat.formatted(.number.precision(.fractionLength(1))))
  270. .gridColumnAlignment(.trailing)
  271. Text("g")
  272. }
  273. // Protein row
  274. GridRow {
  275. Text("Protein:")
  276. Text(meal.protein.formatted(.number.precision(.fractionLength(1))))
  277. .gridColumnAlignment(.trailing)
  278. Text("g")
  279. }
  280. }
  281. }
  282. .font(.headline.bold())
  283. }
  284. .foregroundStyle(.white)
  285. .padding(20)
  286. .background(
  287. RoundedRectangle(cornerRadius: 10)
  288. .fill(Color.orange)
  289. )
  290. }
  291. }