MealStatsView.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  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. // Special handling for Day view with relative dates
  117. if selectedDuration == .Day {
  118. let startDateText: String
  119. let endDateText: String
  120. let timeFormat = start.formatted(.dateTime.hour().minute())
  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 - only show dates without time
  148. let startText: String
  149. let endText: String
  150. // Check for relative dates
  151. if calendar.isDate(start, inSameDayAs: today) {
  152. startText = "Today"
  153. } else if calendar.isDate(start, inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!) {
  154. startText = "Yesterday"
  155. } else if calendar.isDate(start, inSameDayAs: calendar.date(byAdding: .day, value: 1, to: today)!) {
  156. startText = "Tomorrow"
  157. } else {
  158. startText = start.formatted(.dateTime.day().month())
  159. }
  160. if calendar.isDate(end, inSameDayAs: today) {
  161. endText = "Today"
  162. } else if calendar.isDate(end, inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!) {
  163. endText = "Yesterday"
  164. } else if calendar.isDate(end, inSameDayAs: calendar.date(byAdding: .day, value: 1, to: today)!) {
  165. endText = "Tomorrow"
  166. } else {
  167. endText = end.formatted(.dateTime.day().month())
  168. }
  169. return "\(startText) - \(endText)"
  170. }
  171. /// Returns the initial scroll position date based on the selected duration
  172. /// - Returns: A Date representing where the chart should initially scroll to
  173. ///
  174. /// This function calculates an appropriate starting scroll position by subtracting
  175. /// a time interval from the current date based on the selected duration:
  176. /// - For Day view: 1 day before now
  177. /// - For Week view: 7 days before now
  178. /// - For Month view: 1 month before now
  179. /// - For Total view: 3 months before now
  180. private func getInitialScrollPosition() -> Date {
  181. let calendar = Calendar.current
  182. let now = Date()
  183. // Calculate scroll position based on selected time interval
  184. switch selectedDuration {
  185. case .Day:
  186. return calendar.date(byAdding: .day, value: -1, to: now)!
  187. case .Week:
  188. return calendar.date(byAdding: .day, value: -7, to: now)!
  189. case .Month:
  190. return calendar.date(byAdding: .month, value: -1, to: now)!
  191. case .Total:
  192. return calendar.date(byAdding: .month, value: -3, to: now)!
  193. }
  194. }
  195. private func isSameTimeUnit(_ date1: Date, _ date2: Date) -> Bool {
  196. switch selectedDuration {
  197. case .Day:
  198. return Calendar.current.isDate(date1, equalTo: date2, toGranularity: .hour)
  199. default:
  200. return Calendar.current.isDate(date1, inSameDayAs: date2)
  201. }
  202. }
  203. var body: some View {
  204. VStack(alignment: .leading, spacing: 8) {
  205. statsView
  206. chartsView
  207. }
  208. .onAppear {
  209. scrollPosition = getInitialScrollPosition()
  210. updateAverages()
  211. }
  212. .onChange(of: scrollPosition) {
  213. updateTimer.scheduleUpdate {
  214. updateAverages()
  215. }
  216. }
  217. .onChange(of: selectedDuration) {
  218. Task {
  219. scrollPosition = getInitialScrollPosition()
  220. updateAverages()
  221. }
  222. }
  223. }
  224. private var statsView: some View {
  225. HStack {
  226. Grid(alignment: .leading) {
  227. GridRow {
  228. Text("Carbs:")
  229. .font(.headline)
  230. .foregroundStyle(.secondary)
  231. Text(currentAverages.carbs.formatted(.number.precision(.fractionLength(1))))
  232. .font(.headline)
  233. .foregroundStyle(.secondary)
  234. .gridColumnAlignment(.trailing)
  235. Text("g")
  236. .font(.headline)
  237. .foregroundStyle(.secondary)
  238. }
  239. GridRow {
  240. Text("Fat:")
  241. .font(.headline)
  242. .foregroundStyle(.secondary)
  243. Text(currentAverages.fat.formatted(.number.precision(.fractionLength(1))))
  244. .font(.headline)
  245. .foregroundStyle(.secondary)
  246. .gridColumnAlignment(.trailing)
  247. Text("g")
  248. .font(.headline)
  249. .foregroundStyle(.secondary)
  250. }
  251. GridRow {
  252. Text("Protein:")
  253. .font(.headline)
  254. .foregroundStyle(.secondary)
  255. Text(currentAverages.protein.formatted(.number.precision(.fractionLength(1))))
  256. .font(.headline)
  257. .foregroundStyle(.secondary)
  258. .gridColumnAlignment(.trailing)
  259. Text("g")
  260. .font(.headline)
  261. .foregroundStyle(.secondary)
  262. }
  263. }
  264. Spacer()
  265. Text(formatVisibleDateRange())
  266. .font(.footnote)
  267. .foregroundStyle(.secondary)
  268. }
  269. }
  270. private var chartsView: some View {
  271. Chart {
  272. ForEach(mealStats) { stat in
  273. // Carbs Bar (bottom)
  274. BarMark(
  275. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  276. y: .value("Amount", stat.carbs)
  277. )
  278. .foregroundStyle(by: .value("Type", "Carbs"))
  279. .position(by: .value("Type", "Macros"))
  280. .opacity(
  281. selectedDate.map { date in
  282. isSameTimeUnit(stat.date, date) ? 1 : 0.3
  283. } ?? 1
  284. )
  285. // Fat Bar (middle)
  286. BarMark(
  287. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  288. y: .value("Amount", stat.fat)
  289. )
  290. .foregroundStyle(by: .value("Type", "Fat"))
  291. .position(by: .value("Type", "Macros"))
  292. .opacity(
  293. selectedDate.map { date in
  294. isSameTimeUnit(stat.date, date) ? 1 : 0.3
  295. } ?? 1
  296. )
  297. // Protein Bar (top)
  298. BarMark(
  299. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  300. y: .value("Amount", stat.protein)
  301. )
  302. .foregroundStyle(by: .value("Type", "Protein"))
  303. .position(by: .value("Type", "Macros"))
  304. .opacity(
  305. selectedDate.map { date in
  306. isSameTimeUnit(stat.date, date) ? 1 : 0.3
  307. } ?? 1
  308. )
  309. }
  310. // Selection popover outside of the ForEach loop!
  311. if let selectedDate,
  312. let selectedMeal = getMealForDate(selectedDate)
  313. {
  314. RuleMark(
  315. x: .value("Selected Date", selectedDate)
  316. )
  317. .foregroundStyle(.secondary.opacity(0.5))
  318. .annotation(
  319. position: .top,
  320. spacing: 0,
  321. overflowResolution: .init(x: .fit(to: .chart), y: .fit(to: .chart))
  322. ) {
  323. MealSelectionPopover(date: selectedDate, meal: selectedMeal, selectedDuration: selectedDuration)
  324. }
  325. }
  326. }
  327. .chartForegroundStyleScale([
  328. "Carbs": Color.orange,
  329. "Fat": Color.green,
  330. "Protein": Color.blue
  331. ])
  332. .chartLegend(position: .bottom, alignment: .leading, spacing: 12)
  333. .chartYAxis {
  334. AxisMarks(position: .trailing) { value in
  335. if let amount = value.as(Double.self) {
  336. AxisValueLabel {
  337. Text(amount.formatted(.number.precision(.fractionLength(0))) + " g")
  338. .font(.footnote)
  339. }
  340. AxisGridLine()
  341. }
  342. }
  343. }
  344. .chartXAxis {
  345. AxisMarks(preset: .aligned, values: .stride(by: selectedDuration == .Day ? .hour : .day)) { value in
  346. if let date = value.as(Date.self) {
  347. let day = Calendar.current.component(.day, from: date)
  348. let hour = Calendar.current.component(.hour, from: date)
  349. switch selectedDuration {
  350. case .Day:
  351. if hour % 6 == 0 {
  352. AxisValueLabel(format: dateFormat, centered: true)
  353. .font(.footnote)
  354. AxisGridLine()
  355. }
  356. case .Month:
  357. if day % 5 == 0 {
  358. AxisValueLabel(format: dateFormat, centered: true)
  359. .font(.footnote)
  360. AxisGridLine()
  361. }
  362. case .Total:
  363. if day == 1 && Calendar.current.component(.month, from: date) % 3 == 1 {
  364. AxisValueLabel(format: dateFormat, centered: true)
  365. .font(.footnote)
  366. AxisGridLine()
  367. }
  368. default:
  369. AxisValueLabel(format: dateFormat, centered: true)
  370. .font(.footnote)
  371. AxisGridLine()
  372. }
  373. }
  374. }
  375. }
  376. .chartScrollableAxes(.horizontal)
  377. .chartXSelection(value: $selectedDate.animation(.easeInOut))
  378. .chartScrollPosition(x: $scrollPosition)
  379. .chartScrollTargetBehavior(
  380. .valueAligned(
  381. matching: selectedDuration == .Day ?
  382. DateComponents(minute: 0) :
  383. DateComponents(hour: 0),
  384. majorAlignment: .matching(alignmentComponents)
  385. )
  386. )
  387. .chartXVisibleDomain(length: visibleDomainLength)
  388. .frame(height: 250)
  389. }
  390. }
  391. /// A view that displays detailed meal information in a popover
  392. ///
  393. /// This view shows a formatted display of meal macronutrients including:
  394. /// - Date of the meal
  395. /// - Carbohydrates in grams
  396. /// - Fat in grams
  397. /// - Protein in grams
  398. private struct MealSelectionPopover: View {
  399. // The date when the meal was logged
  400. let date: Date
  401. // The meal statistics to display
  402. let meal: MealStats
  403. // The selected duration in the time picker
  404. let selectedDuration: Stat.StateModel.StatsTimeInterval
  405. private var timeText: String {
  406. if selectedDuration == .Day {
  407. let hour = Calendar.current.component(.hour, from: date)
  408. return "\(hour):00-\(hour + 1):00"
  409. } else {
  410. return date.formatted(.dateTime.month().day())
  411. }
  412. }
  413. var body: some View {
  414. VStack(alignment: .leading, spacing: 4) {
  415. // Display formatted date header
  416. Text(timeText)
  417. .font(.footnote)
  418. .fontWeight(.bold)
  419. // Grid layout for macronutrient values
  420. Grid(alignment: .leading) {
  421. // Carbohydrates row
  422. GridRow {
  423. Text("Carbs:")
  424. Text(meal.carbs.formatted(.number.precision(.fractionLength(1))))
  425. .gridColumnAlignment(.trailing)
  426. Text("g")
  427. }
  428. // Fat row
  429. GridRow {
  430. Text("Fat:")
  431. Text(meal.fat.formatted(.number.precision(.fractionLength(1))))
  432. .gridColumnAlignment(.trailing)
  433. Text("g")
  434. }
  435. // Protein row
  436. GridRow {
  437. Text("Protein:")
  438. Text(meal.protein.formatted(.number.precision(.fractionLength(1))))
  439. .gridColumnAlignment(.trailing)
  440. Text("g")
  441. }
  442. }
  443. .font(.headline.bold())
  444. }
  445. .foregroundStyle(.white)
  446. .padding(20)
  447. .background(
  448. RoundedRectangle(cornerRadius: 10)
  449. .fill(Color.orange.gradient)
  450. )
  451. }
  452. }