MealStatsView.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. import Charts
  2. import SwiftUI
  3. struct MealStatsView: View {
  4. @Binding var selectedDuration: Stat.StateModel.StatsTimeInterval
  5. let mealStats: [MealStats]
  6. let calculateAverages: @Sendable(Date, Date) async -> (carbs: Double, fat: Double, protein: Double)
  7. @State private var scrollPosition = Date()
  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. @State private var isScrolling = false
  12. private var visibleDomainLength: TimeInterval {
  13. switch selectedDuration {
  14. case .Day: return 24 * 3600 // 1 day
  15. case .Week: return 7 * 24 * 3600 // 1 week
  16. case .Month: return 30 * 24 * 3600 // 1 month
  17. case .Total: return 90 * 24 * 3600 // 3 months
  18. }
  19. }
  20. private var visibleDateRange: (start: Date, end: Date) {
  21. let halfDomain = visibleDomainLength / 2
  22. let start = scrollPosition.addingTimeInterval(-halfDomain)
  23. let end = scrollPosition.addingTimeInterval(halfDomain)
  24. return (start, end)
  25. }
  26. private var dateFormat: Date.FormatStyle {
  27. switch selectedDuration {
  28. case .Day:
  29. return .dateTime.hour()
  30. case .Week:
  31. return .dateTime.weekday(.abbreviated)
  32. case .Month:
  33. return .dateTime.day()
  34. case .Total:
  35. return .dateTime.month(.abbreviated)
  36. }
  37. }
  38. private var alignmentComponents: DateComponents {
  39. switch selectedDuration {
  40. case .Day:
  41. return DateComponents(hour: 0) // Align to start of day
  42. case .Week:
  43. return DateComponents(weekday: 2) // 2 = Monday in Calendar
  44. case .Month,
  45. .Total:
  46. return DateComponents(day: 1) // Align to first day of month
  47. }
  48. }
  49. private func getMealForDate(_ date: Date) -> MealStats? {
  50. mealStats.first { stat in
  51. Calendar.current.isDate(stat.date, inSameDayAs: date)
  52. }
  53. }
  54. private func updateAverages() {
  55. Task.detached(priority: .userInitiated) {
  56. let dateRange = await MainActor.run { visibleDateRange }
  57. let averages = await calculateAverages(dateRange.start, dateRange.end)
  58. await MainActor.run {
  59. currentAverages = averages
  60. }
  61. }
  62. }
  63. private func formatVisibleDateRange(showTimeRange: Bool = false) -> String {
  64. let start = visibleDateRange.start
  65. let end = visibleDateRange.end
  66. let calendar = Calendar.current
  67. switch selectedDuration {
  68. case .Day:
  69. let today = Date()
  70. let isToday = calendar.isDate(start, inSameDayAs: today)
  71. let isYesterday = calendar.isDate(start, inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!)
  72. if isToday || isYesterday, !showTimeRange {
  73. return isToday ? "Today" : "Yesterday"
  74. }
  75. let timeRange =
  76. "\(start.formatted(.dateTime.hour(.twoDigits(amPM: .wide)))) - \(end.formatted(.dateTime.hour(.twoDigits(amPM: .wide))))"
  77. if isToday {
  78. return "Today, \(timeRange)"
  79. } else if isYesterday {
  80. return "Yesterday, \(timeRange)"
  81. } else {
  82. return "\(start.formatted(.dateTime.month().day())), \(timeRange)"
  83. }
  84. default:
  85. return "\(start.formatted(.dateTime.month().day())) - \(end.formatted(.dateTime.month().day()))"
  86. }
  87. }
  88. var body: some View {
  89. VStack(alignment: .leading, spacing: 8) {
  90. statsView
  91. chartsView
  92. }
  93. .onAppear {
  94. updateAverages()
  95. }
  96. .onChange(of: scrollPosition) {
  97. isScrolling = true
  98. updateTimer.scheduleUpdate {
  99. updateAverages()
  100. isScrolling = false
  101. }
  102. }
  103. .onChange(of: selectedDuration) {
  104. updateAverages()
  105. scrollPosition = Date()
  106. }
  107. }
  108. private var statsView: some View {
  109. HStack {
  110. Grid(alignment: .leading) {
  111. GridRow {
  112. Text("Carbs:")
  113. .font(.headline)
  114. .foregroundStyle(.secondary)
  115. Text(currentAverages.carbs.formatted(.number.precision(.fractionLength(1))))
  116. .font(.headline)
  117. .foregroundStyle(.secondary)
  118. .gridColumnAlignment(.trailing)
  119. Text("g")
  120. .font(.headline)
  121. .foregroundStyle(.secondary)
  122. }
  123. GridRow {
  124. Text("Fat:")
  125. .font(.headline)
  126. .foregroundStyle(.secondary)
  127. Text(currentAverages.fat.formatted(.number.precision(.fractionLength(1))))
  128. .font(.headline)
  129. .foregroundStyle(.secondary)
  130. .gridColumnAlignment(.trailing)
  131. Text("g")
  132. .font(.headline)
  133. .foregroundStyle(.secondary)
  134. }
  135. GridRow {
  136. Text("Protein:")
  137. .font(.headline)
  138. .foregroundStyle(.secondary)
  139. Text(currentAverages.protein.formatted(.number.precision(.fractionLength(1))))
  140. .font(.headline)
  141. .foregroundStyle(.secondary)
  142. .gridColumnAlignment(.trailing)
  143. Text("g")
  144. .font(.headline)
  145. .foregroundStyle(.secondary)
  146. }
  147. }
  148. Spacer()
  149. Text(formatVisibleDateRange(showTimeRange: isScrolling))
  150. .font(.subheadline)
  151. .foregroundStyle(.secondary)
  152. }
  153. }
  154. private var chartsView: some View {
  155. Chart {
  156. ForEach(mealStats) { stat in
  157. // Carbs Bar
  158. BarMark(
  159. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  160. y: .value("Amount", stat.carbs)
  161. )
  162. .foregroundStyle(by: .value("Type", "Carbs"))
  163. .position(by: .value("Type", "Carbs"))
  164. // Fat Bar
  165. BarMark(
  166. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  167. y: .value("Amount", stat.fat)
  168. )
  169. .foregroundStyle(by: .value("Type", "Fat"))
  170. .position(by: .value("Type", "Fat"))
  171. // Protein Bar
  172. BarMark(
  173. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  174. y: .value("Amount", stat.protein)
  175. )
  176. .foregroundStyle(by: .value("Type", "Protein"))
  177. .position(by: .value("Type", "Protein"))
  178. }
  179. if let selectedDate,
  180. let selectedMeal = getMealForDate(selectedDate)
  181. {
  182. RuleMark(
  183. x: .value("Selected Date", selectedDate)
  184. )
  185. .foregroundStyle(.secondary.opacity(0.3))
  186. .annotation(
  187. position: .top,
  188. spacing: 0,
  189. overflowResolution: .init(x: .fit, y: .disabled)
  190. ) {
  191. MealSelectionPopover(date: selectedDate, meal: selectedMeal)
  192. }
  193. }
  194. }
  195. .chartForegroundStyleScale([
  196. "Carbs": Color.orange,
  197. "Fat": Color.blue,
  198. "Protein": Color.green
  199. ])
  200. .chartLegend(position: .bottom, alignment: .leading, spacing: 12)
  201. .chartYAxis {
  202. AxisMarks(position: .trailing) { value in
  203. if let amount = value.as(Double.self) {
  204. AxisValueLabel {
  205. Text(amount.formatted(.number.precision(.fractionLength(0))) + " g")
  206. }
  207. AxisGridLine()
  208. }
  209. }
  210. }
  211. .chartXAxis {
  212. AxisMarks(preset: .aligned, values: .stride(by: selectedDuration == .Day ? .hour : .day)) { value in
  213. if let date = value.as(Date.self) {
  214. let day = Calendar.current.component(.day, from: date)
  215. let hour = Calendar.current.component(.hour, from: date)
  216. switch selectedDuration {
  217. case .Day:
  218. if hour % 6 == 0 { // Show only every 6 hours (0, 6, 12, 18)
  219. AxisValueLabel(format: dateFormat, centered: true)
  220. AxisGridLine()
  221. }
  222. case .Month:
  223. if day % 5 == 0 { // Only show every 5th day
  224. AxisValueLabel(format: dateFormat, centered: true)
  225. AxisGridLine()
  226. }
  227. case .Total:
  228. // Only show January, April, July, October
  229. if day == 1 && Calendar.current.component(.month, from: date) % 3 == 1 {
  230. AxisValueLabel(format: dateFormat, centered: true)
  231. AxisGridLine()
  232. }
  233. default:
  234. AxisValueLabel(format: dateFormat, centered: true)
  235. AxisGridLine()
  236. }
  237. }
  238. }
  239. }
  240. .chartXSelection(value: $selectedDate)
  241. .chartScrollableAxes(.horizontal)
  242. .chartScrollPosition(x: $scrollPosition)
  243. .chartScrollTargetBehavior(
  244. .valueAligned(
  245. matching: selectedDuration == .Day ?
  246. DateComponents(minute: 0) : // Align to next hour for Day view
  247. DateComponents(hour: 0), // Align to start of day for other views
  248. majorAlignment: .matching(
  249. alignmentComponents
  250. )
  251. )
  252. )
  253. .chartXVisibleDomain(length: visibleDomainLength)
  254. .frame(height: 200)
  255. }
  256. }
  257. private struct MealSelectionPopover: View {
  258. let date: Date
  259. let meal: MealStats
  260. var body: some View {
  261. VStack(alignment: .leading, spacing: 4) {
  262. Text(date.formatted(.dateTime.month().day()))
  263. .font(.caption)
  264. .foregroundStyle(.secondary)
  265. Grid(alignment: .leading) {
  266. GridRow {
  267. Text("Carbs:")
  268. Text(meal.carbs.formatted(.number.precision(.fractionLength(1))))
  269. .gridColumnAlignment(.trailing)
  270. Text("g")
  271. }
  272. GridRow {
  273. Text("Fat:")
  274. Text(meal.fat.formatted(.number.precision(.fractionLength(1))))
  275. .gridColumnAlignment(.trailing)
  276. Text("g")
  277. }
  278. GridRow {
  279. Text("Protein:")
  280. Text(meal.protein.formatted(.number.precision(.fractionLength(1))))
  281. .gridColumnAlignment(.trailing)
  282. Text("g")
  283. }
  284. }
  285. .font(.caption)
  286. }
  287. .padding(8)
  288. .background(
  289. RoundedRectangle(cornerRadius: 8)
  290. .fill(Color(.systemBackground))
  291. .shadow(radius: 2)
  292. )
  293. }
  294. }