AggregatedStatsView.swift 17 KB

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