AggregatedStatsView.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. // LoopFollow
  2. // AggregatedStatsView.swift
  3. import SwiftUI
  4. import UIKit
  5. struct AggregatedStatsView: View {
  6. @ObservedObject var viewModel: AggregatedStatsViewModel
  7. @Environment(\.dismiss) var dismiss
  8. var onDismiss: (() -> Void)?
  9. @State private var showGMI: Bool
  10. @State private var showStdDev: Bool
  11. @State private var startDate: Date
  12. @State private var endDate: Date
  13. @State private var isLoadingData = false
  14. @State private var showLoadingMessage = false
  15. @State private var loadingError = false
  16. @State private var loadingTimer: Timer?
  17. @State private var timeoutTimer: Timer?
  18. init(viewModel: AggregatedStatsViewModel, onDismiss: (() -> Void)? = nil) {
  19. self.viewModel = viewModel
  20. self.onDismiss = onDismiss
  21. _showGMI = State(initialValue: UnitSettingsStore.shared.glycemicMetricMode == .gmi)
  22. _showStdDev = State(initialValue: UnitSettingsStore.shared.variabilityMetricMode == .stdDeviation)
  23. let calendar = dateTimeUtils.displayCalendar()
  24. let startOfToday = calendar.startOfDay(for: Date())
  25. let end = calendar.date(byAdding: .second, value: -1, to: startOfToday) ?? Date()
  26. let endDay = calendar.startOfDay(for: end)
  27. let startDay = calendar.date(byAdding: .day, value: -7, to: endDay) ?? endDay
  28. let start = calendar.startOfDay(for: startDay)
  29. _startDate = State(initialValue: start)
  30. _endDate = State(initialValue: end)
  31. }
  32. var body: some View {
  33. ScrollView {
  34. VStack(spacing: 20) {
  35. VStack(spacing: 12) {
  36. Text("Statistics")
  37. .font(.largeTitle)
  38. .fontWeight(.bold)
  39. DateRangePicker(
  40. startDate: $startDate,
  41. endDate: $endDate,
  42. availability: viewModel.dataAvailability,
  43. onDateChange: {
  44. loadingError = false
  45. isLoadingData = true
  46. viewModel.updateDateRange(start: startDate, end: endDate) {
  47. isLoadingData = false
  48. }
  49. }
  50. )
  51. .padding(.horizontal)
  52. }
  53. .padding(.top)
  54. if loadingError {
  55. VStack(spacing: 8) {
  56. Text("#WeAreNotWaitingAnymore")
  57. .font(.subheadline)
  58. .fontWeight(.medium)
  59. .foregroundColor(.orange)
  60. Text("...because the data could not be loaded")
  61. .font(.caption)
  62. .foregroundColor(.secondary)
  63. }
  64. .padding()
  65. } else if isLoadingData {
  66. VStack(spacing: 12) {
  67. ProgressView("Loading data...")
  68. if showLoadingMessage {
  69. Text("#WeAreWaitingForData")
  70. .font(.caption)
  71. .foregroundColor(.secondary)
  72. }
  73. }
  74. .padding()
  75. }
  76. StatsGridView(
  77. simpleStats: viewModel.simpleStats,
  78. showGMI: $showGMI,
  79. showStdDev: $showStdDev
  80. )
  81. .padding(.horizontal)
  82. .opacity(isLoadingData ? 0.4 : 1.0)
  83. .disabled(isLoadingData)
  84. AGPView(viewModel: viewModel.agpStats)
  85. .padding(.horizontal)
  86. .opacity(isLoadingData ? 0.4 : 1.0)
  87. TIRView(viewModel: viewModel.tirStats)
  88. .padding(.horizontal)
  89. .opacity(isLoadingData ? 0.4 : 1.0)
  90. GRIView(viewModel: viewModel.griStats)
  91. .padding(.horizontal)
  92. .opacity(isLoadingData ? 0.4 : 1.0)
  93. }
  94. .padding(.bottom)
  95. .frame(maxWidth: .infinity)
  96. }
  97. .navigationBarTitleDisplayMode(.inline)
  98. .toolbar {
  99. if let onDismiss {
  100. ToolbarItem(placement: .navigationBarLeading) {
  101. Button("Done", action: onDismiss)
  102. }
  103. }
  104. ToolbarItem(placement: .navigationBarTrailing) {
  105. Button("Refresh") {
  106. loadingError = false
  107. isLoadingData = true
  108. viewModel.updateDateRange(start: startDate, end: endDate) {
  109. isLoadingData = false
  110. }
  111. }
  112. }
  113. }
  114. .onAppear {
  115. loadingError = false
  116. isLoadingData = true
  117. viewModel.updateDateRange(start: startDate, end: endDate) {
  118. isLoadingData = false
  119. }
  120. }
  121. .onChange(of: isLoadingData) { newValue in
  122. if newValue {
  123. showLoadingMessage = false
  124. loadingError = false
  125. // Show "still waiting" message after 3 seconds
  126. loadingTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in
  127. showLoadingMessage = true
  128. }
  129. // Timeout after 30 seconds
  130. timeoutTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: false) { _ in
  131. if self.isLoadingData {
  132. self.isLoadingData = false
  133. self.loadingError = true
  134. self.viewModel.clearAllStats()
  135. }
  136. }
  137. } else {
  138. loadingTimer?.invalidate()
  139. loadingTimer = nil
  140. timeoutTimer?.invalidate()
  141. timeoutTimer = nil
  142. showLoadingMessage = false
  143. }
  144. }
  145. .onDisappear {
  146. loadingTimer?.invalidate()
  147. loadingTimer = nil
  148. timeoutTimer?.invalidate()
  149. timeoutTimer = nil
  150. }
  151. .onChange(of: showGMI) { newValue in
  152. UnitSettingsStore.shared.glycemicMetricMode = newValue ? .gmi : .ehba1c
  153. }
  154. .onChange(of: showStdDev) { newValue in
  155. UnitSettingsStore.shared.variabilityMetricMode = newValue ? .stdDeviation : .cv
  156. }
  157. .onReceive(Storage.shared.showGMI.$value) { newValue in
  158. showGMI = newValue
  159. }
  160. .onReceive(Storage.shared.showStdDev.$value) { newValue in
  161. showStdDev = newValue
  162. }
  163. .onReceive(Storage.shared.units.$value) { _ in
  164. refreshVisibleStats()
  165. }
  166. .onReceive(Storage.shared.useIFCC.$value) { _ in
  167. refreshVisibleStats()
  168. }
  169. .onReceive(Storage.shared.timeInRangeModeRaw.$value) { _ in
  170. viewModel.tirStats.calculateTIR()
  171. }
  172. }
  173. private func refreshVisibleStats() {
  174. guard !isLoadingData else { return }
  175. viewModel.calculateStats()
  176. }
  177. }
  178. struct AggregatedStatsContentView: View {
  179. @StateObject private var viewModel: AggregatedStatsViewModel
  180. private let onDismiss: (() -> Void)?
  181. init(mainViewController: MainViewController?, onDismiss: (() -> Void)? = nil) {
  182. _viewModel = StateObject(wrappedValue: AggregatedStatsViewModel(mainViewController: mainViewController))
  183. self.onDismiss = onDismiss
  184. }
  185. var body: some View {
  186. AggregatedStatsView(viewModel: viewModel, onDismiss: onDismiss)
  187. .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
  188. }
  189. }
  190. struct AggregatedStatsModalView: View {
  191. @Environment(\.dismiss) private var dismiss
  192. let mainViewController: MainViewController?
  193. var body: some View {
  194. NavigationView {
  195. AggregatedStatsContentView(
  196. mainViewController: mainViewController,
  197. onDismiss: { dismiss() }
  198. )
  199. .navigationBarTitleDisplayMode(.inline)
  200. }
  201. .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
  202. }
  203. }
  204. struct StatCard: View {
  205. let title: String
  206. let value: String
  207. let unit: String?
  208. let color: Color
  209. var isInteractive: Bool = false
  210. var body: some View {
  211. ZStack(alignment: .topTrailing) {
  212. VStack(alignment: .leading, spacing: 8) {
  213. Text(title)
  214. .font(.caption)
  215. .foregroundColor(.secondary)
  216. HStack(alignment: .firstTextBaseline, spacing: 4) {
  217. Text(value)
  218. .font(.title2)
  219. .fontWeight(.semibold)
  220. .foregroundColor(color)
  221. if let unit = unit {
  222. Text(unit)
  223. .font(.caption)
  224. .foregroundColor(.secondary)
  225. }
  226. }
  227. }
  228. .frame(maxWidth: .infinity, alignment: .leading)
  229. .padding()
  230. if isInteractive {
  231. Image(systemName: "chevron.up.chevron.down")
  232. .font(.caption2)
  233. .foregroundColor(.secondary.opacity(0.5))
  234. .padding(8)
  235. }
  236. }
  237. .background(Color(.systemGray6))
  238. .cornerRadius(12)
  239. }
  240. }
  241. struct InsulinTotalsCard: View {
  242. let totalNegativeBasal: Double?
  243. let totalPositiveBasal: Double?
  244. let programmedBasal: Double?
  245. let actualBasal: Double?
  246. let avgBolus: Double?
  247. let totalDailyDose: Double?
  248. var body: some View {
  249. VStack(alignment: .leading, spacing: 8) {
  250. Text("Insulin Totals")
  251. .font(.caption)
  252. .foregroundColor(.secondary)
  253. VStack(spacing: 10) {
  254. metricRow(title: "Programmed Basal", value: programmedBasal, color: .indigo)
  255. metricRow(title: "Total Negative Basal", value: totalNegativeBasal, color: .red)
  256. metricRow(title: "Total Positive Basal", value: totalPositiveBasal, color: .green)
  257. metricRow(title: "Actual Basal", value: actualBasal, color: .indigo)
  258. metricRow(title: "Avg Bolus", value: avgBolus, color: .purple)
  259. metricRow(title: "Total Daily Dose", value: totalDailyDose, color: .pink)
  260. }
  261. }
  262. .frame(maxWidth: .infinity, alignment: .leading)
  263. .padding()
  264. .background(Color(.systemGray6))
  265. .cornerRadius(12)
  266. }
  267. @ViewBuilder
  268. private func metricRow(title: String, value: Double?, color: Color) -> some View {
  269. HStack(alignment: .firstTextBaseline) {
  270. Text(title)
  271. .font(.subheadline)
  272. .foregroundColor(.secondary)
  273. Spacer()
  274. Text(formatBasal(value))
  275. .font(.headline)
  276. .fontWeight(.semibold)
  277. .foregroundColor(color)
  278. + Text(" U")
  279. .font(.caption)
  280. .foregroundColor(.secondary)
  281. }
  282. }
  283. private func formatBasal(_ value: Double?) -> String {
  284. guard let value = value else { return "---" }
  285. return String(format: "%.2f", value)
  286. }
  287. }
  288. struct StatsGridView: View {
  289. @ObservedObject var simpleStats: SimpleStatsViewModel
  290. @Binding var showGMI: Bool
  291. @Binding var showStdDev: Bool
  292. @State private var showInsulinBreakdown = false
  293. private var hasInsulinData: Bool {
  294. simpleStats.totalDailyDose != nil || simpleStats.avgBolus != nil || simpleStats.actualBasal != nil || simpleStats.programmedBasal != nil
  295. }
  296. private var hasCarbData: Bool {
  297. simpleStats.avgCarbs != nil
  298. }
  299. var body: some View {
  300. VStack(spacing: 16) {
  301. LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
  302. Button(action: {
  303. showGMI.toggle()
  304. }) {
  305. StatCard(
  306. title: showGMI ? "GMI" : "Est. A1C",
  307. value: showGMI ? formatGMI(simpleStats.avgGlucose) : formatEhbA1c(simpleStats.avgGlucose),
  308. unit: UnitSettingsStore.shared.glycemicOutputUnit.rawValue,
  309. color: .blue,
  310. isInteractive: true
  311. )
  312. }
  313. .buttonStyle(PlainButtonStyle())
  314. StatCard(
  315. title: "Avg Glucose",
  316. value: formatGlucose(simpleStats.avgGlucose),
  317. unit: UnitSettingsStore.shared.glucoseUnit.rawValue,
  318. color: Color(red: 0.20, green: 0.99, blue: 0.70)
  319. )
  320. Button(action: {
  321. showStdDev.toggle()
  322. }) {
  323. StatCard(
  324. title: showStdDev ? "Std Deviation" : "CV",
  325. value: showStdDev ? formatStdDev(simpleStats.stdDeviation) : formatCV(simpleStats.coefficientOfVariation),
  326. unit: showStdDev ? UnitSettingsStore.shared.glucoseUnit.rawValue : "%",
  327. color: .orange,
  328. isInteractive: true
  329. )
  330. }
  331. .buttonStyle(PlainButtonStyle())
  332. if hasCarbData {
  333. StatCard(
  334. title: "Avg Carbs",
  335. value: formatCarbs(simpleStats.avgCarbs),
  336. unit: "g/day",
  337. color: .yellow
  338. )
  339. }
  340. if hasInsulinData {
  341. StatCard(
  342. title: "Programmed Basal",
  343. value: formatInsulin(simpleStats.programmedBasal),
  344. unit: "U/day",
  345. color: .indigo
  346. )
  347. .onTapGesture(count: 3) {
  348. showInsulinBreakdown = true
  349. }
  350. StatCard(
  351. title: "Avg Bolus",
  352. value: formatInsulin(simpleStats.avgBolus),
  353. unit: "U/day",
  354. color: .purple
  355. )
  356. .onTapGesture(count: 3) {
  357. showInsulinBreakdown = true
  358. }
  359. StatCard(
  360. title: "Total Daily Dose",
  361. value: formatInsulin(simpleStats.totalDailyDose),
  362. unit: "U",
  363. color: .pink
  364. )
  365. .onTapGesture(count: 3) {
  366. showInsulinBreakdown = true
  367. }
  368. }
  369. }
  370. if hasInsulinData && showInsulinBreakdown {
  371. InsulinTotalsCard(
  372. totalNegativeBasal: simpleStats.totalNegativeBasal,
  373. totalPositiveBasal: simpleStats.totalPositiveBasal,
  374. programmedBasal: simpleStats.programmedBasal,
  375. actualBasal: simpleStats.actualBasal,
  376. avgBolus: simpleStats.avgBolus,
  377. totalDailyDose: simpleStats.totalDailyDose
  378. )
  379. }
  380. }
  381. }
  382. private func formatGMI(_ avgGlucose: Double?) -> String {
  383. guard let value = GlycemicMetricCalculator.calculateGMI(avgGlucoseInDisplayUnits: avgGlucose) else { return "---" }
  384. if UnitSettingsStore.shared.glycemicOutputUnit == .mmolMol {
  385. return String(format: "%.0f", value)
  386. }
  387. return String(format: "%.1f", value)
  388. }
  389. private func formatEhbA1c(_ avgGlucose: Double?) -> String {
  390. guard let value = GlycemicMetricCalculator.calculateEhba1c(avgGlucoseInDisplayUnits: avgGlucose) else { return "---" }
  391. if UnitSettingsStore.shared.glycemicOutputUnit == .mmolMol {
  392. return String(format: "%.0f", value)
  393. }
  394. return String(format: "%.1f", value)
  395. }
  396. private func formatGlucose(_ value: Double?) -> String {
  397. guard let value = value else { return "---" }
  398. if UnitSettingsStore.shared.glucoseUnit == .mgdL {
  399. return String(format: "%.0f", value)
  400. } else {
  401. return String(format: "%.1f", value)
  402. }
  403. }
  404. private func formatStdDev(_ value: Double?) -> String {
  405. guard let value = value else { return "---" }
  406. if UnitSettingsStore.shared.glucoseUnit == .mgdL {
  407. return String(format: "%.0f", value)
  408. } else {
  409. return String(format: "%.1f", value)
  410. }
  411. }
  412. private func formatInsulin(_ value: Double?) -> String {
  413. guard let value = value else { return "---" }
  414. return String(format: "%.2f", value)
  415. }
  416. private func formatCarbs(_ value: Double?) -> String {
  417. guard let value = value else { return "---" }
  418. return String(format: "%.0f", value)
  419. }
  420. private func formatCV(_ value: Double?) -> String {
  421. guard let value = value else { return "---" }
  422. return String(format: "%.1f", value)
  423. }
  424. }