AggregatedStatsView.swift 17 KB

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