MealStatsView.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  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. mealStats.first { stat in
  83. Calendar.current.isDate(stat.date, inSameDayAs: date)
  84. }
  85. }
  86. /// Updates the current averages for macronutrients based on the visible date range
  87. ///
  88. /// This function:
  89. /// - Gets the cached meal averages for the currently visible date range from the state
  90. /// - Updates the currentAverages property with the retrieved values (carbs, fat, protein)
  91. private func updateAverages() {
  92. // Get cached averages for visible time window
  93. currentAverages = state.getCachedMealAverages(for: visibleDateRange)
  94. }
  95. /// Formats the visible date range into a human-readable string
  96. /// - Returns: A formatted string representing the visible date range
  97. ///
  98. /// For Day view:
  99. /// - Uses relative terms like "Today", "Yesterday", "Tomorrow" when applicable
  100. /// - Shows time range in hours and minutes
  101. /// - Combines dates if start and end are on the same day
  102. ///
  103. /// For other views:
  104. /// - Uses standard date formatting
  105. private func formatVisibleDateRange() -> String {
  106. let start = visibleDateRange.start
  107. let end = visibleDateRange.end
  108. let calendar = Calendar.current
  109. let today = Date()
  110. let timeFormat = start.formatted(.dateTime.hour().minute())
  111. // Special handling for Day view with relative dates
  112. if selectedDuration == .Day {
  113. let startDateText: String
  114. let endDateText: String
  115. // Format start date
  116. if calendar.isDate(start, inSameDayAs: today) {
  117. startDateText = "Today"
  118. } else if calendar.isDate(start, inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!) {
  119. startDateText = "Yesterday"
  120. } else if calendar.isDate(start, inSameDayAs: calendar.date(byAdding: .day, value: 1, to: today)!) {
  121. startDateText = "Tomorrow"
  122. } else {
  123. startDateText = start.formatted(.dateTime.day().month())
  124. }
  125. // Format end date
  126. if calendar.isDate(end, inSameDayAs: today) {
  127. endDateText = "Today"
  128. } else if calendar.isDate(end, inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!) {
  129. endDateText = "Yesterday"
  130. } else if calendar.isDate(end, inSameDayAs: calendar.date(byAdding: .day, value: 1, to: today)!) {
  131. endDateText = "Tomorrow"
  132. } else {
  133. endDateText = end.formatted(.dateTime.day().month())
  134. }
  135. // If start and end are on the same day, show date only once
  136. if calendar.isDate(start, inSameDayAs: end) {
  137. return "\(startDateText), \(timeFormat) - \(end.formatted(.dateTime.hour().minute()))"
  138. }
  139. return "\(startDateText), \(timeFormat) - \(endDateText), \(end.formatted(.dateTime.hour().minute()))"
  140. }
  141. // Standard format for other views
  142. return "\(start.formatted()) - \(end.formatted())"
  143. }
  144. /// Returns the initial scroll position date based on the selected duration
  145. /// - Returns: A Date representing where the chart should initially scroll to
  146. ///
  147. /// This function calculates an appropriate starting scroll position by subtracting
  148. /// a time interval from the current date based on the selected duration:
  149. /// - For Day view: 1 day before now
  150. /// - For Week view: 7 days before now
  151. /// - For Month view: 1 month before now
  152. /// - For Total view: 3 months before now
  153. private func getInitialScrollPosition() -> Date {
  154. let calendar = Calendar.current
  155. let now = Date()
  156. // Calculate scroll position based on selected time interval
  157. switch selectedDuration {
  158. case .Day:
  159. return calendar.date(byAdding: .day, value: -1, to: now)!
  160. case .Week:
  161. return calendar.date(byAdding: .day, value: -7, to: now)!
  162. case .Month:
  163. return calendar.date(byAdding: .month, value: -1, to: now)!
  164. case .Total:
  165. return calendar.date(byAdding: .month, value: -3, to: now)!
  166. }
  167. }
  168. private func isSameTimeUnit(_ date1: Date, _ date2: Date) -> Bool {
  169. switch selectedDuration {
  170. case .Day:
  171. return Calendar.current.isDate(date1, equalTo: date2, toGranularity: .hour)
  172. default:
  173. return Calendar.current.isDate(date1, inSameDayAs: date2)
  174. }
  175. }
  176. var body: some View {
  177. VStack(alignment: .leading, spacing: 8) {
  178. statsView
  179. chartsView
  180. }
  181. .onAppear {
  182. scrollPosition = getInitialScrollPosition()
  183. updateAverages()
  184. }
  185. .onChange(of: scrollPosition) {
  186. updateTimer.scheduleUpdate {
  187. updateAverages()
  188. }
  189. }
  190. .onChange(of: selectedDuration) {
  191. Task {
  192. scrollPosition = getInitialScrollPosition()
  193. updateAverages()
  194. }
  195. }
  196. }
  197. private var statsView: some View {
  198. HStack {
  199. Grid(alignment: .leading) {
  200. GridRow {
  201. Text("Carbs:")
  202. .font(.headline)
  203. .foregroundStyle(.secondary)
  204. Text(currentAverages.carbs.formatted(.number.precision(.fractionLength(1))))
  205. .font(.headline)
  206. .foregroundStyle(.secondary)
  207. .gridColumnAlignment(.trailing)
  208. Text("g")
  209. .font(.headline)
  210. .foregroundStyle(.secondary)
  211. }
  212. GridRow {
  213. Text("Fat:")
  214. .font(.headline)
  215. .foregroundStyle(.secondary)
  216. Text(currentAverages.fat.formatted(.number.precision(.fractionLength(1))))
  217. .font(.headline)
  218. .foregroundStyle(.secondary)
  219. .gridColumnAlignment(.trailing)
  220. Text("g")
  221. .font(.headline)
  222. .foregroundStyle(.secondary)
  223. }
  224. GridRow {
  225. Text("Protein:")
  226. .font(.headline)
  227. .foregroundStyle(.secondary)
  228. Text(currentAverages.protein.formatted(.number.precision(.fractionLength(1))))
  229. .font(.headline)
  230. .foregroundStyle(.secondary)
  231. .gridColumnAlignment(.trailing)
  232. Text("g")
  233. .font(.headline)
  234. .foregroundStyle(.secondary)
  235. }
  236. }
  237. Spacer()
  238. Text(formatVisibleDateRange())
  239. .font(.subheadline)
  240. .foregroundStyle(.secondary)
  241. }
  242. }
  243. private var chartsView: some View {
  244. Chart {
  245. ForEach(mealStats) { stat in
  246. // Carbs Bar (bottom)
  247. BarMark(
  248. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  249. y: .value("Amount", stat.carbs)
  250. )
  251. .foregroundStyle(by: .value("Type", "Carbs"))
  252. .position(by: .value("Type", "Macros"))
  253. .opacity(
  254. selectedDate.map { date in
  255. isSameTimeUnit(stat.date, date) ? 1 : 0.3
  256. } ?? 1
  257. )
  258. // Fat Bar (middle)
  259. BarMark(
  260. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  261. y: .value("Amount", stat.fat)
  262. )
  263. .foregroundStyle(by: .value("Type", "Fat"))
  264. .position(by: .value("Type", "Macros"))
  265. .opacity(
  266. selectedDate.map { date in
  267. isSameTimeUnit(stat.date, date) ? 1 : 0.3
  268. } ?? 1
  269. )
  270. // Protein Bar (top)
  271. BarMark(
  272. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  273. y: .value("Amount", stat.protein)
  274. )
  275. .foregroundStyle(by: .value("Type", "Protein"))
  276. .position(by: .value("Type", "Macros"))
  277. .opacity(
  278. selectedDate.map { date in
  279. isSameTimeUnit(stat.date, date) ? 1 : 0.3
  280. } ?? 1
  281. )
  282. }
  283. // Selection popover outside of the ForEach loop!
  284. if let selectedDate,
  285. let selectedMeal = getMealForDate(selectedDate)
  286. {
  287. RuleMark(
  288. x: .value("Selected Date", selectedDate)
  289. )
  290. .foregroundStyle(.secondary.opacity(0.3))
  291. .annotation(
  292. position: .top,
  293. spacing: 0,
  294. overflowResolution: .init(x: .fit(to: .chart), y: .fit(to: .chart))
  295. ) {
  296. MealSelectionPopover(date: selectedDate, meal: selectedMeal)
  297. }
  298. }
  299. }
  300. .chartForegroundStyleScale([
  301. "Carbs": Color.orange,
  302. "Fat": Color.green,
  303. "Protein": Color.blue
  304. ])
  305. .chartLegend(position: .bottom, alignment: .leading, spacing: 12)
  306. .chartYAxis {
  307. AxisMarks(position: .trailing) { value in
  308. if let amount = value.as(Double.self) {
  309. AxisValueLabel {
  310. Text(amount.formatted(.number.precision(.fractionLength(0))) + " g")
  311. }
  312. AxisGridLine()
  313. }
  314. }
  315. }
  316. .chartXAxis {
  317. AxisMarks(preset: .aligned, values: .stride(by: selectedDuration == .Day ? .hour : .day)) { value in
  318. if let date = value.as(Date.self) {
  319. let day = Calendar.current.component(.day, from: date)
  320. let hour = Calendar.current.component(.hour, from: date)
  321. switch selectedDuration {
  322. case .Day:
  323. if hour % 6 == 0 {
  324. AxisValueLabel(format: dateFormat, centered: true)
  325. AxisGridLine()
  326. }
  327. case .Month:
  328. if day % 5 == 0 {
  329. AxisValueLabel(format: dateFormat, centered: true)
  330. AxisGridLine()
  331. }
  332. case .Total:
  333. if day == 1 && Calendar.current.component(.month, from: date) % 3 == 1 {
  334. AxisValueLabel(format: dateFormat, centered: true)
  335. AxisGridLine()
  336. }
  337. default:
  338. AxisValueLabel(format: dateFormat, centered: true)
  339. AxisGridLine()
  340. }
  341. }
  342. }
  343. }
  344. .chartScrollableAxes(.horizontal)
  345. .chartXSelection(value: $selectedDate.animation(.easeInOut))
  346. .chartScrollPosition(x: $scrollPosition)
  347. .chartScrollTargetBehavior(
  348. .valueAligned(
  349. matching: selectedDuration == .Day ?
  350. DateComponents(minute: 0) :
  351. DateComponents(hour: 0),
  352. majorAlignment: .matching(alignmentComponents)
  353. )
  354. )
  355. .chartXVisibleDomain(length: visibleDomainLength)
  356. .frame(height: 250)
  357. }
  358. }
  359. /// A view that displays detailed meal information in a popover
  360. ///
  361. /// This view shows a formatted display of meal macronutrients including:
  362. /// - Date of the meal
  363. /// - Carbohydrates in grams
  364. /// - Fat in grams
  365. /// - Protein in grams
  366. private struct MealSelectionPopover: View {
  367. // The date when the meal was logged
  368. let date: Date
  369. // The meal statistics to display
  370. let meal: MealStats
  371. var body: some View {
  372. VStack(alignment: .leading, spacing: 4) {
  373. // Display formatted date header
  374. Text(date.formatted(.dateTime.month().day()))
  375. .font(.caption)
  376. .foregroundStyle(.secondary)
  377. // Grid layout for macronutrient values
  378. Grid(alignment: .leading) {
  379. // Carbohydrates row
  380. GridRow {
  381. Text("Carbs:")
  382. Text(meal.carbs.formatted(.number.precision(.fractionLength(1))))
  383. .gridColumnAlignment(.trailing)
  384. Text("g")
  385. }
  386. // Fat row
  387. GridRow {
  388. Text("Fat:")
  389. Text(meal.fat.formatted(.number.precision(.fractionLength(1))))
  390. .gridColumnAlignment(.trailing)
  391. Text("g")
  392. }
  393. // Protein row
  394. GridRow {
  395. Text("Protein:")
  396. Text(meal.protein.formatted(.number.precision(.fractionLength(1))))
  397. .gridColumnAlignment(.trailing)
  398. Text("g")
  399. }
  400. }
  401. .font(.caption)
  402. }
  403. .padding(8)
  404. .background(
  405. // Add background styling with shadow
  406. RoundedRectangle(cornerRadius: 8)
  407. .fill(Color(.systemBackground))
  408. .shadow(radius: 2)
  409. )
  410. }
  411. }