MealStatsView.swift 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  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. var body: some View {
  169. VStack(alignment: .leading, spacing: 8) {
  170. statsView
  171. chartsView
  172. }
  173. .onAppear {
  174. scrollPosition = getInitialScrollPosition()
  175. updateAverages()
  176. }
  177. .onChange(of: scrollPosition) {
  178. updateTimer.scheduleUpdate {
  179. updateAverages()
  180. }
  181. }
  182. .onChange(of: selectedDuration) {
  183. Task {
  184. scrollPosition = getInitialScrollPosition()
  185. updateAverages()
  186. }
  187. }
  188. }
  189. private var statsView: some View {
  190. HStack {
  191. Grid(alignment: .leading) {
  192. GridRow {
  193. Text("Carbs:")
  194. .font(.headline)
  195. .foregroundStyle(.secondary)
  196. Text(currentAverages.carbs.formatted(.number.precision(.fractionLength(1))))
  197. .font(.headline)
  198. .foregroundStyle(.secondary)
  199. .gridColumnAlignment(.trailing)
  200. Text("g")
  201. .font(.headline)
  202. .foregroundStyle(.secondary)
  203. }
  204. GridRow {
  205. Text("Fat:")
  206. .font(.headline)
  207. .foregroundStyle(.secondary)
  208. Text(currentAverages.fat.formatted(.number.precision(.fractionLength(1))))
  209. .font(.headline)
  210. .foregroundStyle(.secondary)
  211. .gridColumnAlignment(.trailing)
  212. Text("g")
  213. .font(.headline)
  214. .foregroundStyle(.secondary)
  215. }
  216. GridRow {
  217. Text("Protein:")
  218. .font(.headline)
  219. .foregroundStyle(.secondary)
  220. Text(currentAverages.protein.formatted(.number.precision(.fractionLength(1))))
  221. .font(.headline)
  222. .foregroundStyle(.secondary)
  223. .gridColumnAlignment(.trailing)
  224. Text("g")
  225. .font(.headline)
  226. .foregroundStyle(.secondary)
  227. }
  228. }
  229. Spacer()
  230. Text(formatVisibleDateRange())
  231. .font(.subheadline)
  232. .foregroundStyle(.secondary)
  233. }
  234. }
  235. private var chartsView: some View {
  236. Chart(mealStats) { stat in
  237. // Carbs Bar (bottom)
  238. BarMark(
  239. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  240. y: .value("Amount", stat.carbs)
  241. )
  242. .foregroundStyle(by: .value("Type", "Carbs"))
  243. .position(by: .value("Type", "Macros"))
  244. // Fat Bar (middle)
  245. BarMark(
  246. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  247. y: .value("Amount", stat.fat)
  248. )
  249. .foregroundStyle(by: .value("Type", "Fat"))
  250. .position(by: .value("Type", "Macros"))
  251. // Protein Bar (top)
  252. BarMark(
  253. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  254. y: .value("Amount", stat.protein)
  255. )
  256. .foregroundStyle(by: .value("Type", "Protein"))
  257. .position(by: .value("Type", "Macros"))
  258. if let selectedDate,
  259. let selectedMeal = getMealForDate(selectedDate)
  260. {
  261. RuleMark(
  262. x: .value("Selected Date", selectedDate)
  263. )
  264. .foregroundStyle(.secondary.opacity(0.3))
  265. .annotation(
  266. position: .top,
  267. spacing: 0,
  268. overflowResolution: .init(x: .fit, y: .disabled)
  269. ) {
  270. MealSelectionPopover(date: selectedDate, meal: selectedMeal)
  271. }
  272. }
  273. }
  274. .chartForegroundStyleScale([
  275. "Carbs": Color.orange,
  276. "Fat": Color.green,
  277. "Protein": Color.blue
  278. ])
  279. .chartLegend(position: .bottom, alignment: .leading, spacing: 12)
  280. .chartYAxis {
  281. AxisMarks(position: .trailing) { value in
  282. if let amount = value.as(Double.self) {
  283. AxisValueLabel {
  284. Text(amount.formatted(.number.precision(.fractionLength(0))) + " g")
  285. }
  286. AxisGridLine()
  287. }
  288. }
  289. }
  290. .chartXAxis {
  291. AxisMarks(preset: .aligned, values: .stride(by: selectedDuration == .Day ? .hour : .day)) { value in
  292. if let date = value.as(Date.self) {
  293. let day = Calendar.current.component(.day, from: date)
  294. let hour = Calendar.current.component(.hour, from: date)
  295. switch selectedDuration {
  296. case .Day:
  297. if hour % 6 == 0 {
  298. AxisValueLabel(format: dateFormat, centered: true)
  299. AxisGridLine()
  300. }
  301. case .Month:
  302. if day % 5 == 0 {
  303. AxisValueLabel(format: dateFormat, centered: true)
  304. AxisGridLine()
  305. }
  306. case .Total:
  307. if day == 1 && Calendar.current.component(.month, from: date) % 3 == 1 {
  308. AxisValueLabel(format: dateFormat, centered: true)
  309. AxisGridLine()
  310. }
  311. default:
  312. AxisValueLabel(format: dateFormat, centered: true)
  313. AxisGridLine()
  314. }
  315. }
  316. }
  317. }
  318. .chartXSelection(value: $selectedDate)
  319. .chartScrollableAxes(.horizontal)
  320. .chartScrollPosition(x: $scrollPosition)
  321. .chartScrollTargetBehavior(
  322. .valueAligned(
  323. matching: selectedDuration == .Day ?
  324. DateComponents(minute: 0) :
  325. DateComponents(hour: 0),
  326. majorAlignment: .matching(alignmentComponents)
  327. )
  328. )
  329. .chartXVisibleDomain(length: visibleDomainLength)
  330. .frame(height: 200)
  331. }
  332. }
  333. /// A view that displays detailed meal information in a popover
  334. ///
  335. /// This view shows a formatted display of meal macronutrients including:
  336. /// - Date of the meal
  337. /// - Carbohydrates in grams
  338. /// - Fat in grams
  339. /// - Protein in grams
  340. private struct MealSelectionPopover: View {
  341. // The date when the meal was logged
  342. let date: Date
  343. // The meal statistics to display
  344. let meal: MealStats
  345. var body: some View {
  346. VStack(alignment: .leading, spacing: 4) {
  347. // Display formatted date header
  348. Text(date.formatted(.dateTime.month().day()))
  349. .font(.caption)
  350. .foregroundStyle(.secondary)
  351. // Grid layout for macronutrient values
  352. Grid(alignment: .leading) {
  353. // Carbohydrates row
  354. GridRow {
  355. Text("Carbs:")
  356. Text(meal.carbs.formatted(.number.precision(.fractionLength(1))))
  357. .gridColumnAlignment(.trailing)
  358. Text("g")
  359. }
  360. // Fat row
  361. GridRow {
  362. Text("Fat:")
  363. Text(meal.fat.formatted(.number.precision(.fractionLength(1))))
  364. .gridColumnAlignment(.trailing)
  365. Text("g")
  366. }
  367. // Protein row
  368. GridRow {
  369. Text("Protein:")
  370. Text(meal.protein.formatted(.number.precision(.fractionLength(1))))
  371. .gridColumnAlignment(.trailing)
  372. Text("g")
  373. }
  374. }
  375. .font(.caption)
  376. }
  377. .padding(8)
  378. .background(
  379. // Add background styling with shadow
  380. RoundedRectangle(cornerRadius: 8)
  381. .fill(Color(.systemBackground))
  382. .shadow(radius: 2)
  383. )
  384. }
  385. }