MealStatsView.swift 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  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 {
  237. ForEach(mealStats) { stat in
  238. // Carbs Bar
  239. BarMark(
  240. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  241. y: .value("Amount", stat.carbs)
  242. )
  243. .foregroundStyle(by: .value("Type", "Carbs"))
  244. .position(by: .value("Type", "Carbs"))
  245. // Fat Bar
  246. BarMark(
  247. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  248. y: .value("Amount", stat.fat)
  249. )
  250. .foregroundStyle(by: .value("Type", "Fat"))
  251. .position(by: .value("Type", "Fat"))
  252. // Protein Bar
  253. BarMark(
  254. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  255. y: .value("Amount", stat.protein)
  256. )
  257. .foregroundStyle(by: .value("Type", "Protein"))
  258. .position(by: .value("Type", "Protein"))
  259. }
  260. if let selectedDate,
  261. let selectedMeal = getMealForDate(selectedDate)
  262. {
  263. RuleMark(
  264. x: .value("Selected Date", selectedDate)
  265. )
  266. .foregroundStyle(.secondary.opacity(0.3))
  267. .annotation(
  268. position: .top,
  269. spacing: 0,
  270. overflowResolution: .init(x: .fit, y: .disabled)
  271. ) {
  272. MealSelectionPopover(date: selectedDate, meal: selectedMeal)
  273. }
  274. }
  275. }
  276. .chartForegroundStyleScale([
  277. "Carbs": Color.orange,
  278. "Fat": Color.blue,
  279. "Protein": Color.green
  280. ])
  281. .chartLegend(position: .bottom, alignment: .leading, spacing: 12)
  282. .chartYAxis {
  283. AxisMarks(position: .trailing) { value in
  284. if let amount = value.as(Double.self) {
  285. AxisValueLabel {
  286. Text(amount.formatted(.number.precision(.fractionLength(0))) + " g")
  287. }
  288. AxisGridLine()
  289. }
  290. }
  291. }
  292. .chartXAxis {
  293. AxisMarks(preset: .aligned, values: .stride(by: selectedDuration == .Day ? .hour : .day)) { value in
  294. if let date = value.as(Date.self) {
  295. let day = Calendar.current.component(.day, from: date)
  296. let hour = Calendar.current.component(.hour, from: date)
  297. switch selectedDuration {
  298. case .Day:
  299. if hour % 6 == 0 { // Show only every 6 hours (0, 6, 12, 18)
  300. AxisValueLabel(format: dateFormat, centered: true)
  301. AxisGridLine()
  302. }
  303. case .Month:
  304. if day % 5 == 0 { // Only show every 5th day
  305. AxisValueLabel(format: dateFormat, centered: true)
  306. AxisGridLine()
  307. }
  308. case .Total:
  309. // Only show January, April, July, October
  310. if day == 1 && Calendar.current.component(.month, from: date) % 3 == 1 {
  311. AxisValueLabel(format: dateFormat, centered: true)
  312. AxisGridLine()
  313. }
  314. default:
  315. AxisValueLabel(format: dateFormat, centered: true)
  316. AxisGridLine()
  317. }
  318. }
  319. }
  320. }
  321. .chartXSelection(value: $selectedDate)
  322. .chartScrollableAxes(.horizontal)
  323. .chartScrollPosition(x: $scrollPosition)
  324. .chartScrollTargetBehavior(
  325. .valueAligned(
  326. matching: selectedDuration == .Day ?
  327. DateComponents(minute: 0) : // Align to next hour for Day view
  328. DateComponents(hour: 0), // Align to start of day for other views
  329. majorAlignment: .matching(
  330. alignmentComponents
  331. )
  332. )
  333. )
  334. .chartXVisibleDomain(length: visibleDomainLength)
  335. .frame(height: 200)
  336. }
  337. }
  338. /// A view that displays detailed meal information in a popover
  339. ///
  340. /// This view shows a formatted display of meal macronutrients including:
  341. /// - Date of the meal
  342. /// - Carbohydrates in grams
  343. /// - Fat in grams
  344. /// - Protein in grams
  345. private struct MealSelectionPopover: View {
  346. // The date when the meal was logged
  347. let date: Date
  348. // The meal statistics to display
  349. let meal: MealStats
  350. var body: some View {
  351. VStack(alignment: .leading, spacing: 4) {
  352. // Display formatted date header
  353. Text(date.formatted(.dateTime.month().day()))
  354. .font(.caption)
  355. .foregroundStyle(.secondary)
  356. // Grid layout for macronutrient values
  357. Grid(alignment: .leading) {
  358. // Carbohydrates row
  359. GridRow {
  360. Text("Carbs:")
  361. Text(meal.carbs.formatted(.number.precision(.fractionLength(1))))
  362. .gridColumnAlignment(.trailing)
  363. Text("g")
  364. }
  365. // Fat row
  366. GridRow {
  367. Text("Fat:")
  368. Text(meal.fat.formatted(.number.precision(.fractionLength(1))))
  369. .gridColumnAlignment(.trailing)
  370. Text("g")
  371. }
  372. // Protein row
  373. GridRow {
  374. Text("Protein:")
  375. Text(meal.protein.formatted(.number.precision(.fractionLength(1))))
  376. .gridColumnAlignment(.trailing)
  377. Text("g")
  378. }
  379. }
  380. .font(.caption)
  381. }
  382. .padding(8)
  383. .background(
  384. // Add background styling with shadow
  385. RoundedRectangle(cornerRadius: 8)
  386. .fill(Color(.systemBackground))
  387. .shadow(radius: 2)
  388. )
  389. }
  390. }