MealStatsView.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. import Charts
  2. import SwiftUI
  3. struct MealStatsView: View {
  4. @Binding var selectedDuration: Stat.StateModel.StatsTimeInterval
  5. let mealStats: [MealStats]
  6. let state: Stat.StateModel
  7. @State private var scrollPosition = Date() // gets updated in onAppear block
  8. @State private var selectedDate: Date?
  9. @State private var currentAverages: (carbs: Double, fat: Double, protein: Double) = (0, 0, 0)
  10. @State private var updateTimer = Stat.UpdateTimer()
  11. /// Returns the time interval length for the visible domain based on selected duration
  12. /// - Returns: TimeInterval representing the visible time range in seconds
  13. ///
  14. /// Time intervals:
  15. /// - Day: 24 hours (86400 seconds)
  16. /// - Week: 7 days (604800 seconds)
  17. /// - Month: 30 days (2592000 seconds)
  18. /// - Total: 90 days (7776000 seconds)
  19. private var visibleDomainLength: TimeInterval {
  20. switch selectedDuration {
  21. case .Day: return 24 * 3600 // One day in seconds
  22. case .Week: return 7 * 24 * 3600 // One week in seconds
  23. case .Month: return 30 * 24 * 3600 // One month in seconds (approximated)
  24. case .Total: return 90 * 24 * 3600 // Three months in seconds
  25. }
  26. }
  27. /// Calculates the visible date range based on scroll position and domain length
  28. /// - Returns: Tuple containing start and end dates of the visible range
  29. ///
  30. /// The start date is determined by the current scroll position, while the end date
  31. /// is calculated by adding the visible domain length to the start date
  32. private var visibleDateRange: (start: Date, end: Date) {
  33. let start = scrollPosition // Current scroll position marks the start
  34. let end = start.addingTimeInterval(visibleDomainLength)
  35. return (start, end)
  36. }
  37. /// Returns the appropriate date format style based on the selected time interval
  38. /// - Returns: A Date.FormatStyle configured for the current time interval
  39. ///
  40. /// Format styles:
  41. /// - Day: Shows hour only (e.g. "13")
  42. /// - Week: Shows abbreviated weekday (e.g. "Mon")
  43. /// - Month: Shows day of month (e.g. "15")
  44. /// - Total: Shows abbreviated month (e.g. "Jan")
  45. private var dateFormat: Date.FormatStyle {
  46. switch selectedDuration {
  47. case .Day:
  48. return .dateTime.hour()
  49. case .Week:
  50. return .dateTime.weekday(.abbreviated)
  51. case .Month:
  52. return .dateTime.day()
  53. case .Total:
  54. return .dateTime.month(.abbreviated)
  55. }
  56. }
  57. /// Returns DateComponents for aligning dates based on the selected duration
  58. /// - Returns: DateComponents configured for the appropriate alignment
  59. ///
  60. /// This property provides date components for aligning dates in the chart:
  61. /// - For Day view: Aligns to start of day (midnight)
  62. /// - For Week view: Aligns to Monday (weekday 2)
  63. /// - For Month/Total view: Aligns to first day of month
  64. private var alignmentComponents: DateComponents {
  65. switch selectedDuration {
  66. case .Day:
  67. return DateComponents(hour: 0) // Align to midnight
  68. case .Week:
  69. return DateComponents(weekday: 2) // Monday is weekday 2 in Calendar
  70. case .Month,
  71. .Total:
  72. return DateComponents(day: 1) // First day of month
  73. }
  74. }
  75. /// Returns meal statistics for a specific date
  76. /// - Parameter date: The date to find meal statistics for
  77. /// - Returns: MealStats object if found for the given date, nil otherwise
  78. ///
  79. /// This function searches through the meal statistics array to find the first entry
  80. /// that matches the provided date (comparing only the day component, not time).
  81. private func getMealForDate(_ date: Date) -> MealStats? {
  82. let calendar = Calendar.current
  83. return mealStats.first { stat in
  84. switch selectedDuration {
  85. case .Day:
  86. return calendar.isDate(stat.date, equalTo: date, toGranularity: .hour)
  87. default:
  88. return calendar.isDate(stat.date, inSameDayAs: date)
  89. }
  90. }
  91. }
  92. /// Updates the current averages for macronutrients based on the visible date range
  93. ///
  94. /// This function:
  95. /// - Gets the cached meal averages for the currently visible date range from the state
  96. /// - Updates the currentAverages property with the retrieved values (carbs, fat, protein)
  97. private func updateAverages() {
  98. // Get cached averages for visible time window
  99. currentAverages = state.getCachedMealAverages(for: visibleDateRange)
  100. }
  101. /// Formats the visible date range into a human-readable string
  102. /// - Returns: A formatted string representing the visible date range
  103. ///
  104. /// For Day view:
  105. /// - Uses relative terms like "Today", "Yesterday", "Tomorrow" when applicable
  106. /// - Shows time range in hours and minutes
  107. /// - Combines dates if start and end are on the same day
  108. ///
  109. /// For other views:
  110. /// - Uses standard date formatting
  111. private func formatVisibleDateRange() -> String {
  112. let start = visibleDateRange.start
  113. let end = visibleDateRange.end
  114. let calendar = Calendar.current
  115. let today = Date()
  116. let timeFormat = start.formatted(.dateTime.hour().minute())
  117. // Special handling for Day view with relative dates
  118. if selectedDuration == .Day {
  119. let startDateText: String
  120. let endDateText: String
  121. // Format start date
  122. if calendar.isDate(start, inSameDayAs: today) {
  123. startDateText = "Today"
  124. } else if calendar.isDate(start, inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!) {
  125. startDateText = "Yesterday"
  126. } else if calendar.isDate(start, inSameDayAs: calendar.date(byAdding: .day, value: 1, to: today)!) {
  127. startDateText = "Tomorrow"
  128. } else {
  129. startDateText = start.formatted(.dateTime.day().month())
  130. }
  131. // Format end date
  132. if calendar.isDate(end, inSameDayAs: today) {
  133. endDateText = "Today"
  134. } else if calendar.isDate(end, inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!) {
  135. endDateText = "Yesterday"
  136. } else if calendar.isDate(end, inSameDayAs: calendar.date(byAdding: .day, value: 1, to: today)!) {
  137. endDateText = "Tomorrow"
  138. } else {
  139. endDateText = end.formatted(.dateTime.day().month())
  140. }
  141. // If start and end are on the same day, show date only once
  142. if calendar.isDate(start, inSameDayAs: end) {
  143. return "\(startDateText), \(timeFormat) - \(end.formatted(.dateTime.hour().minute()))"
  144. }
  145. return "\(startDateText), \(timeFormat) - \(endDateText), \(end.formatted(.dateTime.hour().minute()))"
  146. }
  147. // Standard format for other views
  148. return "\(start.formatted()) - \(end.formatted())"
  149. }
  150. /// Returns the initial scroll position date based on the selected duration
  151. /// - Returns: A Date representing where the chart should initially scroll to
  152. ///
  153. /// This function calculates an appropriate starting scroll position by subtracting
  154. /// a time interval from the current date based on the selected duration:
  155. /// - For Day view: 1 day before now
  156. /// - For Week view: 7 days before now
  157. /// - For Month view: 1 month before now
  158. /// - For Total view: 3 months before now
  159. private func getInitialScrollPosition() -> Date {
  160. let calendar = Calendar.current
  161. let now = Date()
  162. // Calculate scroll position based on selected time interval
  163. switch selectedDuration {
  164. case .Day:
  165. return calendar.date(byAdding: .day, value: -1, to: now)!
  166. case .Week:
  167. return calendar.date(byAdding: .day, value: -7, to: now)!
  168. case .Month:
  169. return calendar.date(byAdding: .month, value: -1, to: now)!
  170. case .Total:
  171. return calendar.date(byAdding: .month, value: -3, to: now)!
  172. }
  173. }
  174. private func isSameTimeUnit(_ date1: Date, _ date2: Date) -> Bool {
  175. switch selectedDuration {
  176. case .Day:
  177. return Calendar.current.isDate(date1, equalTo: date2, toGranularity: .hour)
  178. default:
  179. return Calendar.current.isDate(date1, inSameDayAs: date2)
  180. }
  181. }
  182. var body: some View {
  183. VStack(alignment: .leading, spacing: 8) {
  184. statsView
  185. chartsView
  186. }
  187. .onAppear {
  188. scrollPosition = getInitialScrollPosition()
  189. updateAverages()
  190. }
  191. .onChange(of: scrollPosition) {
  192. updateTimer.scheduleUpdate {
  193. updateAverages()
  194. }
  195. }
  196. .onChange(of: selectedDuration) {
  197. Task {
  198. scrollPosition = getInitialScrollPosition()
  199. updateAverages()
  200. }
  201. }
  202. }
  203. private var statsView: some View {
  204. HStack {
  205. Grid(alignment: .leading) {
  206. GridRow {
  207. Text("Carbs:")
  208. .font(.headline)
  209. .foregroundStyle(.secondary)
  210. Text(currentAverages.carbs.formatted(.number.precision(.fractionLength(1))))
  211. .font(.headline)
  212. .foregroundStyle(.secondary)
  213. .gridColumnAlignment(.trailing)
  214. Text("g")
  215. .font(.headline)
  216. .foregroundStyle(.secondary)
  217. }
  218. GridRow {
  219. Text("Fat:")
  220. .font(.headline)
  221. .foregroundStyle(.secondary)
  222. Text(currentAverages.fat.formatted(.number.precision(.fractionLength(1))))
  223. .font(.headline)
  224. .foregroundStyle(.secondary)
  225. .gridColumnAlignment(.trailing)
  226. Text("g")
  227. .font(.headline)
  228. .foregroundStyle(.secondary)
  229. }
  230. GridRow {
  231. Text("Protein:")
  232. .font(.headline)
  233. .foregroundStyle(.secondary)
  234. Text(currentAverages.protein.formatted(.number.precision(.fractionLength(1))))
  235. .font(.headline)
  236. .foregroundStyle(.secondary)
  237. .gridColumnAlignment(.trailing)
  238. Text("g")
  239. .font(.headline)
  240. .foregroundStyle(.secondary)
  241. }
  242. }
  243. Spacer()
  244. Text(formatVisibleDateRange())
  245. .font(.subheadline)
  246. .foregroundStyle(.secondary)
  247. }
  248. }
  249. private var chartsView: some View {
  250. Chart {
  251. ForEach(mealStats) { stat in
  252. // Carbs Bar (bottom)
  253. BarMark(
  254. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  255. y: .value("Amount", stat.carbs)
  256. )
  257. .foregroundStyle(by: .value("Type", "Carbs"))
  258. .position(by: .value("Type", "Macros"))
  259. .opacity(
  260. selectedDate.map { date in
  261. isSameTimeUnit(stat.date, date) ? 1 : 0.3
  262. } ?? 1
  263. )
  264. // Fat Bar (middle)
  265. BarMark(
  266. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  267. y: .value("Amount", stat.fat)
  268. )
  269. .foregroundStyle(by: .value("Type", "Fat"))
  270. .position(by: .value("Type", "Macros"))
  271. .opacity(
  272. selectedDate.map { date in
  273. isSameTimeUnit(stat.date, date) ? 1 : 0.3
  274. } ?? 1
  275. )
  276. // Protein Bar (top)
  277. BarMark(
  278. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  279. y: .value("Amount", stat.protein)
  280. )
  281. .foregroundStyle(by: .value("Type", "Protein"))
  282. .position(by: .value("Type", "Macros"))
  283. .opacity(
  284. selectedDate.map { date in
  285. isSameTimeUnit(stat.date, date) ? 1 : 0.3
  286. } ?? 1
  287. )
  288. }
  289. // Selection popover outside of the ForEach loop!
  290. if let selectedDate,
  291. let selectedMeal = getMealForDate(selectedDate)
  292. {
  293. RuleMark(
  294. x: .value("Selected Date", selectedDate)
  295. )
  296. .foregroundStyle(.secondary.opacity(0.5))
  297. .annotation(
  298. position: .top,
  299. spacing: 0,
  300. overflowResolution: .init(x: .fit(to: .chart), y: .fit(to: .chart))
  301. ) {
  302. MealSelectionPopover(date: selectedDate, meal: selectedMeal, selectedDuration: selectedDuration)
  303. }
  304. }
  305. }
  306. .chartForegroundStyleScale([
  307. "Carbs": Color.orange,
  308. "Fat": Color.green,
  309. "Protein": Color.blue
  310. ])
  311. .chartLegend(position: .bottom, alignment: .leading, spacing: 12)
  312. .chartYAxis {
  313. AxisMarks(position: .trailing) { value in
  314. if let amount = value.as(Double.self) {
  315. AxisValueLabel {
  316. Text(amount.formatted(.number.precision(.fractionLength(0))) + " g")
  317. }
  318. AxisGridLine()
  319. }
  320. }
  321. }
  322. .chartXAxis {
  323. AxisMarks(preset: .aligned, values: .stride(by: selectedDuration == .Day ? .hour : .day)) { value in
  324. if let date = value.as(Date.self) {
  325. let day = Calendar.current.component(.day, from: date)
  326. let hour = Calendar.current.component(.hour, from: date)
  327. switch selectedDuration {
  328. case .Day:
  329. if hour % 6 == 0 {
  330. AxisValueLabel(format: dateFormat, centered: true)
  331. AxisGridLine()
  332. }
  333. case .Month:
  334. if day % 5 == 0 {
  335. AxisValueLabel(format: dateFormat, centered: true)
  336. AxisGridLine()
  337. }
  338. case .Total:
  339. if day == 1 && Calendar.current.component(.month, from: date) % 3 == 1 {
  340. AxisValueLabel(format: dateFormat, centered: true)
  341. AxisGridLine()
  342. }
  343. default:
  344. AxisValueLabel(format: dateFormat, centered: true)
  345. AxisGridLine()
  346. }
  347. }
  348. }
  349. }
  350. .chartScrollableAxes(.horizontal)
  351. .chartXSelection(value: $selectedDate.animation(.easeInOut))
  352. .chartScrollPosition(x: $scrollPosition)
  353. .chartScrollTargetBehavior(
  354. .valueAligned(
  355. matching: selectedDuration == .Day ?
  356. DateComponents(minute: 0) :
  357. DateComponents(hour: 0),
  358. majorAlignment: .matching(alignmentComponents)
  359. )
  360. )
  361. .chartXVisibleDomain(length: visibleDomainLength)
  362. .frame(height: 250)
  363. }
  364. }
  365. /// A view that displays detailed meal information in a popover
  366. ///
  367. /// This view shows a formatted display of meal macronutrients including:
  368. /// - Date of the meal
  369. /// - Carbohydrates in grams
  370. /// - Fat in grams
  371. /// - Protein in grams
  372. private struct MealSelectionPopover: View {
  373. // The date when the meal was logged
  374. let date: Date
  375. // The meal statistics to display
  376. let meal: MealStats
  377. // The selected duration in the time picker
  378. let selectedDuration: Stat.StateModel.StatsTimeInterval
  379. var body: some View {
  380. VStack(alignment: .leading, spacing: 4) {
  381. // Display formatted date header
  382. Text(selectedDuration == .Day ? date.formatted(.dateTime.hour().minute()) : date.formatted(.dateTime.month().day()))
  383. .font(.subheadline)
  384. .fontWeight(.bold)
  385. // Grid layout for macronutrient values
  386. Grid(alignment: .leading) {
  387. // Carbohydrates row
  388. GridRow {
  389. Text("Carbs:")
  390. Text(meal.carbs.formatted(.number.precision(.fractionLength(1))))
  391. .gridColumnAlignment(.trailing)
  392. Text("g")
  393. }
  394. // Fat row
  395. GridRow {
  396. Text("Fat:")
  397. Text(meal.fat.formatted(.number.precision(.fractionLength(1))))
  398. .gridColumnAlignment(.trailing)
  399. Text("g")
  400. }
  401. // Protein row
  402. GridRow {
  403. Text("Protein:")
  404. Text(meal.protein.formatted(.number.precision(.fractionLength(1))))
  405. .gridColumnAlignment(.trailing)
  406. Text("g")
  407. }
  408. }
  409. .font(.headline.bold())
  410. }
  411. .foregroundStyle(.white)
  412. .padding(20)
  413. .background(
  414. RoundedRectangle(cornerRadius: 10)
  415. .fill(Color.orange.gradient)
  416. )
  417. }
  418. }