HomeRootView+BottomControls.swift 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785
  1. import CoreData
  2. import SwiftDate
  3. import SwiftUI
  4. // MARK: - Zone E: bottom controls (adjustment panel / bolus progress)
  5. extension Home.RootView {
  6. var bolusProgressFormatter: NumberFormatter {
  7. let fractionDigits: Int = switch state.settingsManager.preferences.bolusIncrement {
  8. case 0.1: 1
  9. case 0.025: 3
  10. default: 2
  11. }
  12. let formatter = NumberFormatter()
  13. formatter.numberStyle = .decimal
  14. formatter.minimum = 0
  15. formatter.maximumFractionDigits = fractionDigits
  16. formatter.minimumFractionDigits = fractionDigits
  17. formatter.allowsFloats = true
  18. formatter.roundingIncrement = Double(state.settingsManager.preferences.bolusIncrement) as NSNumber
  19. return formatter
  20. }
  21. private var fetchedTargetFormatter: NumberFormatter {
  22. let formatter = NumberFormatter()
  23. formatter.numberStyle = .decimal
  24. if state.units == .mmolL {
  25. formatter.maximumFractionDigits = 1
  26. } else { formatter.maximumFractionDigits = 0 }
  27. return formatter
  28. }
  29. func remainingFraction(start: Date?, durationMinutes: Decimal?, indefinite: Bool) -> Double? {
  30. guard !indefinite, let start = start, let durationMinutes = durationMinutes, durationMinutes > 0 else {
  31. return nil
  32. }
  33. let total = Double(truncating: durationMinutes as NSNumber) * 60
  34. let elapsed = Date().timeIntervalSince(start)
  35. guard total > 0 else { return nil }
  36. return min(max(1 - elapsed / total, 0), 1)
  37. }
  38. var overrideRemainingFraction: Double? {
  39. guard let o = latestOverride.first else { return nil }
  40. return remainingFraction(start: o.date, durationMinutes: o.duration as Decimal?, indefinite: o.indefinite)
  41. }
  42. var tempTargetRemainingFraction: Double? {
  43. guard let t = latestTempTarget.first else { return nil }
  44. return remainingFraction(start: t.date, durationMinutes: t.duration as Decimal?, indefinite: false)
  45. }
  46. @ViewBuilder func adjustmentIcon(_ systemName: String, tint: Color) -> some View {
  47. Image(systemName: systemName)
  48. .font(.system(size: 15, weight: .semibold))
  49. .foregroundStyle(tint)
  50. .frame(width: 30, height: 30)
  51. .background(Circle().fill(tint.opacity(0.18)))
  52. }
  53. var adjustmentTint: Color? {
  54. if overrideString != nil { return Color.purple }
  55. if tempTargetString != nil { return Color.loopGreen }
  56. return nil
  57. }
  58. var overrideString: String? {
  59. guard let latestOverride = latestOverride.first else {
  60. return nil
  61. }
  62. guard let settingsManager = state.settingsManager else {
  63. return nil
  64. }
  65. let percent = latestOverride.percentage
  66. let percentString = percent == 100 ? "" : "\(percent.formatted(.number)) %"
  67. let unit = state.units
  68. var target = (latestOverride.target ?? 0) as Decimal
  69. target = unit == .mmolL ? target.asMmolL : target
  70. var targetString = target == 0 ? "" : (fetchedTargetFormatter.string(from: target as NSNumber) ?? "") + " " + unit
  71. .rawValue
  72. if tempTargetString != nil {
  73. targetString = ""
  74. }
  75. let duration = latestOverride.duration ?? 0
  76. let addedMinutes = Int(truncating: duration)
  77. let date = latestOverride.date ?? Date()
  78. let newDuration = max(
  79. Decimal(Date().distance(to: date.addingTimeInterval(addedMinutes.minutes.timeInterval)).minutes),
  80. 0
  81. )
  82. let indefinite = latestOverride.indefinite
  83. var durationString = ""
  84. if !indefinite {
  85. if newDuration >= 1 {
  86. durationString = formatHrMin(Int(newDuration))
  87. } else if newDuration > 0 {
  88. durationString = "\(Int(newDuration * 60)) s"
  89. } else {
  90. /// Do not show the Override anymore
  91. Task {
  92. guard let objectID = self.latestOverride.first?.objectID else { return }
  93. await state.cancelOverride(withID: objectID)
  94. }
  95. }
  96. }
  97. let smbScheduleString = latestOverride
  98. .smbIsScheduledOff && ((latestOverride.start?.stringValue ?? "") != (latestOverride.end?.stringValue ?? ""))
  99. ? " \(formatTimeRange(start: latestOverride.start?.stringValue, end: latestOverride.end?.stringValue))"
  100. : ""
  101. let smbToggleString = latestOverride.smbIsOff || latestOverride
  102. .smbIsScheduledOff ? String(localized: "SMBs Off\(smbScheduleString)") : ""
  103. var smbMinuteString: String = ""
  104. var uamMinuteString: String = ""
  105. if !latestOverride.smbIsOff, latestOverride.advancedSettings {
  106. if let smbMinutes = latestOverride.smbMinutes,
  107. smbMinutes.decimalValue != settingsManager.preferences.maxSMBBasalMinutes
  108. {
  109. smbMinuteString = "SMB\u{00A0}\(smbMinutes)\u{00A0}" +
  110. String(localized: "m", comment: "Abbreviation for Minutes")
  111. }
  112. if let uamMinutes = latestOverride.uamMinutes,
  113. uamMinutes.decimalValue != settingsManager.preferences.maxUAMSMBBasalMinutes
  114. {
  115. uamMinuteString = "UAM\u{00A0}\(uamMinutes)\u{00A0}" +
  116. String(localized: "m", comment: "Abbreviation for Minutes")
  117. }
  118. }
  119. let components = [durationString, percentString, targetString, smbToggleString, smbMinuteString, uamMinuteString]
  120. .filter { !$0.isEmpty }
  121. return components.isEmpty ? nil : components.joined(separator: ", ")
  122. }
  123. var tempTargetString: String? {
  124. guard let latestTempTarget = latestTempTarget.first else {
  125. return nil
  126. }
  127. let duration = latestTempTarget.duration
  128. let addedMinutes = Int(truncating: duration ?? 0)
  129. let date = latestTempTarget.date ?? Date()
  130. let newDuration = max(
  131. Decimal(Date().distance(to: date.addingTimeInterval(addedMinutes.minutes.timeInterval)).minutes),
  132. 0
  133. )
  134. var durationString = ""
  135. var percentageString = ""
  136. var target = (latestTempTarget.target ?? 100) as Decimal
  137. // Use TempTargetCalculations to get effective HBT (handles both custom and auto-adjusted standard TT)
  138. let effectiveHBT = TempTargetCalculations.computeEffectiveHBT(
  139. tempTargetHalfBasalTarget: latestTempTarget.halfBasalTarget?.decimalValue,
  140. settingHalfBasalTarget: state.settingHalfBasalTarget,
  141. target: target,
  142. autosensMax: state.autosensMax
  143. ) ?? state.settingHalfBasalTarget
  144. var showPercentage = false
  145. if target > 100, state.isExerciseModeActive || state.highTTraisesSens { showPercentage = true }
  146. if target < 100, state.lowTTlowersSens, state.autosensMax > 1 { showPercentage = true }
  147. if showPercentage {
  148. percentageString =
  149. " \(Int(TempTargetCalculations.computeAdjustedPercentage(halfBasalTarget: effectiveHBT, target: target, autosensMax: state.autosensMax)))%"
  150. }
  151. target = state.units == .mmolL ? target.asMmolL : target
  152. let targetString = target == 0 ? "" : (fetchedTargetFormatter.string(from: target as NSNumber) ?? "") + " " +
  153. state.units.rawValue + percentageString
  154. if newDuration >= 1 {
  155. durationString =
  156. "\(newDuration.formatted(.number.grouping(.never).rounded().precision(.fractionLength(0)))) min"
  157. } else if newDuration > 0 {
  158. durationString =
  159. "\((newDuration * 60).formatted(.number.grouping(.never).rounded().precision(.fractionLength(0)))) s"
  160. } else {
  161. /// Do not show the Temp Target anymore
  162. Task {
  163. guard let objectID = self.latestTempTarget.first?.objectID else { return }
  164. await state.cancelTempTarget(withID: objectID)
  165. }
  166. }
  167. let components = [targetString, durationString].filter { !$0.isEmpty }
  168. return components.isEmpty ? nil : components.joined(separator: ", ")
  169. }
  170. @ViewBuilder func adjustmentsOverrideView(_ overrideString: String) -> some View {
  171. Group {
  172. adjustmentIcon("clock.arrow.2.circlepath", tint: Color.purple)
  173. VStack(alignment: .leading, spacing: 1) {
  174. Text(latestOverride.first?.name ?? String(localized: "Custom Override"))
  175. .font(.subheadline).fontWeight(.semibold)
  176. .frame(alignment: .leading)
  177. Text(overrideString)
  178. .font(.caption)
  179. .foregroundStyle(.secondary)
  180. }
  181. }
  182. }
  183. @ViewBuilder func adjustmentsTempTargetView(_ tempTargetString: String) -> some View {
  184. Group {
  185. adjustmentIcon("target", tint: Color.loopGreen)
  186. VStack(alignment: .leading, spacing: 1) {
  187. Text(latestTempTarget.first?.name ?? String(localized: "Temp Target"))
  188. .font(.subheadline).fontWeight(.semibold)
  189. Text(tempTargetString)
  190. .font(.caption)
  191. .foregroundStyle(.secondary)
  192. }
  193. }
  194. }
  195. @ViewBuilder func adjustmentsCancelView(_ cancelAction: @escaping () -> Void) -> some View {
  196. Image(systemName: "xmark.app")
  197. .font(.title)
  198. .onTapGesture {
  199. cancelAction()
  200. }
  201. }
  202. @ViewBuilder func adjustmentsCancelTempTargetView() -> some View {
  203. Image(systemName: "xmark.app")
  204. .font(.title)
  205. .confirmationDialog(
  206. "Stop the Temp Target \"\(latestTempTarget.first?.name ?? "")\"?",
  207. isPresented: $isConfirmStopTempTargetShown,
  208. titleVisibility: .visible
  209. ) {
  210. Button("Stop", role: .destructive) {
  211. Task {
  212. guard let objectID = latestTempTarget.first?.objectID else { return }
  213. await state.cancelTempTarget(withID: objectID)
  214. }
  215. }
  216. Button("Cancel", role: .cancel) {}
  217. }
  218. .padding(.trailing, 8)
  219. .onTapGesture {
  220. if !latestTempTarget.isEmpty {
  221. isConfirmStopTempTargetShown = true
  222. }
  223. }
  224. }
  225. @ViewBuilder func adjustmentsCancelOverrideView() -> some View {
  226. Image(systemName: "xmark.app")
  227. .font(.title)
  228. .confirmationDialog(
  229. "Stop the Override \"\(latestOverride.first?.name ?? "")\"?",
  230. isPresented: $isConfirmStopOverridePresented,
  231. titleVisibility: .visible
  232. ) {
  233. Button("Stop", role: .destructive) {
  234. Task {
  235. guard let objectID = latestOverride.first?.objectID else { return }
  236. await state.cancelOverride(withID: objectID)
  237. }
  238. }
  239. Button("Cancel", role: .cancel) {}
  240. }
  241. .padding(.trailing, 8)
  242. .onTapGesture {
  243. if !latestOverride.isEmpty {
  244. isConfirmStopOverridePresented = true
  245. }
  246. }
  247. }
  248. @ViewBuilder func noActiveAdjustmentsView() -> some View {
  249. Group {
  250. adjustmentIcon("slider.horizontal.2.gobackward", tint: Color.secondary)
  251. VStack(alignment: .leading, spacing: 1) {
  252. Text("No Active Adjustment")
  253. .font(.subheadline).fontWeight(.medium)
  254. .foregroundStyle(.secondary)
  255. .frame(maxWidth: .infinity, alignment: .leading)
  256. Text("Profile at 100 %")
  257. .font(.caption)
  258. .foregroundStyle(.tertiary)
  259. .frame(maxWidth: .infinity, alignment: .leading)
  260. }
  261. Spacer()
  262. // clear icon keeps text aligned with the cancel-button states
  263. Image(systemName: "xmark.app")
  264. .font(.title)
  265. .foregroundStyle(Color.clear)
  266. }
  267. }
  268. // same track pattern as BolusProgressBar, slightly slimmer
  269. @ViewBuilder func remainingBar(_ fraction: Double?, tint: Color) -> some View {
  270. GeometryReader { barGeo in
  271. if let fraction {
  272. RoundedRectangle(cornerRadius: 15)
  273. .fill(tint.opacity(0.85))
  274. .frame(width: barGeo.size.width * fraction, height: 4)
  275. .frame(maxHeight: .infinity, alignment: .bottom)
  276. }
  277. }
  278. .frame(height: 4)
  279. }
  280. @ViewBuilder func adjustmentView() -> some View {
  281. let tint = adjustmentTint
  282. // concurrent override + temp target: halved tint, one remaining bar per half
  283. let isConcurrent = overrideString != nil && tempTargetString != nil
  284. ZStack {
  285. RoundedRectangle(cornerRadius: 17, style: .continuous)
  286. .fill(.ultraThinMaterial)
  287. .overlay(
  288. Group {
  289. if isConcurrent {
  290. HStack(spacing: 0) {
  291. Color.purple.opacity(0.12)
  292. Color.loopGreen.opacity(0.12)
  293. }
  294. .clipShape(RoundedRectangle(cornerRadius: 17, style: .continuous))
  295. } else {
  296. RoundedRectangle(cornerRadius: 17, style: .continuous)
  297. .fill((tint ?? Color.clear).opacity(0.12))
  298. }
  299. }
  300. )
  301. .overlay(
  302. RoundedRectangle(cornerRadius: 17, style: .continuous)
  303. .strokeBorder(
  304. isConcurrent
  305. ? AnyShapeStyle(LinearGradient(
  306. colors: [Color.purple.opacity(0.30), Color.loopGreen.opacity(0.30)],
  307. startPoint: .leading,
  308. endPoint: .trailing
  309. ))
  310. : AnyShapeStyle((tint ?? Color.primary).opacity(tint == nil ? 0.08 : 0.30)),
  311. lineWidth: 1
  312. )
  313. )
  314. .frame(height: HomeLayout.bottomPanelHeight)
  315. .overlay(alignment: .bottom) {
  316. // anchored like the bolus progress bar so both panels match
  317. Group {
  318. if isConcurrent {
  319. HStack(spacing: 6) {
  320. remainingBar(overrideRemainingFraction, tint: .purple)
  321. remainingBar(tempTargetRemainingFraction, tint: .loopGreen)
  322. }
  323. } else if let tint = tint {
  324. remainingBar(overrideRemainingFraction ?? tempTargetRemainingFraction, tint: tint)
  325. }
  326. }
  327. .padding(.horizontal, 18)
  328. .padding(.bottom, 1)
  329. }
  330. .clipShape(RoundedRectangle(cornerRadius: 17, style: .continuous))
  331. .shadow(color: Color.black.opacity(colorScheme == .dark ? 0.25 : 0.10), radius: 3, y: 1)
  332. HStack {
  333. if let overrideString = overrideString, let tempTargetString = tempTargetString {
  334. // content halves match the tint halves so icons clear the seam
  335. HStack(spacing: 0) {
  336. HStack {
  337. adjustmentsOverrideView(overrideString)
  338. Spacer(minLength: 0)
  339. }
  340. .frame(maxWidth: .infinity)
  341. HStack {
  342. adjustmentsTempTargetView(tempTargetString)
  343. .padding(.leading, 8)
  344. Spacer(minLength: 0)
  345. adjustmentsCancelView({
  346. if !latestTempTarget.isEmpty, !latestOverride.isEmpty {
  347. showCancelConfirmDialog = true
  348. } else if !latestOverride.isEmpty {
  349. showCancelAlert = true
  350. } else if !latestTempTarget.isEmpty {
  351. showCancelAlert = true
  352. }
  353. })
  354. }
  355. .frame(maxWidth: .infinity)
  356. }
  357. } else if let overrideString = overrideString {
  358. adjustmentsOverrideView(overrideString)
  359. Spacer()
  360. adjustmentsCancelOverrideView()
  361. } else if let tempTargetString = tempTargetString {
  362. HStack {
  363. adjustmentsTempTargetView(tempTargetString)
  364. Spacer()
  365. adjustmentsCancelTempTargetView()
  366. }
  367. } else {
  368. noActiveAdjustmentsView()
  369. }
  370. }.padding(.horizontal, 10)
  371. .confirmationDialog("Adjustment to Stop", isPresented: $showCancelConfirmDialog) {
  372. Button("Stop Override", role: .destructive) {
  373. Task {
  374. guard let objectID = latestOverride.first?.objectID else { return }
  375. await state.cancelOverride(withID: objectID)
  376. }
  377. }
  378. Button("Stop Temp Target", role: .destructive) {
  379. Task {
  380. guard let objectID = latestTempTarget.first?.objectID else { return }
  381. await state.cancelTempTarget(withID: objectID)
  382. }
  383. }
  384. Button("Stop All Adjustments", role: .destructive) {
  385. Task {
  386. guard let overrideObjectID = latestOverride.first?.objectID else { return }
  387. await state.cancelOverride(withID: overrideObjectID)
  388. guard let tempTargetObjectID = latestTempTarget.first?.objectID else { return }
  389. await state.cancelTempTarget(withID: tempTargetObjectID)
  390. }
  391. }
  392. } message: {
  393. Text("Select Adjustment")
  394. }
  395. }
  396. // whole panel navigates; the cancel buttons' own gestures take precedence
  397. .contentShape(Rectangle())
  398. .onTapGesture {
  399. selectedTab = 2
  400. }
  401. .padding(.horizontal, 10)
  402. }
  403. @ViewBuilder func bolusView(_ progress: Decimal) -> some View {
  404. /// ensure that state.lastPumpBolus has a value, i.e. there is a last bolus done by the pump and not an external bolus
  405. /// - TRUE: show the pump bolus
  406. /// - FALSE: do not show a progress bar at all
  407. if let bolusTotal = state.lastPumpBolus?.bolus?.amount {
  408. let bolusFraction = progress * (bolusTotal as Decimal)
  409. let bolusString =
  410. (bolusProgressFormatter.string(from: bolusFraction as NSNumber) ?? "0")
  411. + String(localized: " of ", comment: "Bolus string partial message: 'x U of y U' in home view") +
  412. (Formatter.decimalFormatterWithThreeFractionDigits.string(from: bolusTotal as NSNumber) ?? "0")
  413. + String(localized: " U", comment: "Insulin unit")
  414. let bolusLabel = state
  415. .bolusStatus == .inProgress ? String(localized: "Bolusing") : String(localized: "Initiating…")
  416. ZStack {
  417. /// rectangle as background
  418. RoundedRectangle(cornerRadius: 15)
  419. .fill(
  420. colorScheme == .dark ? Color(red: 0.03921568627, green: 0.133333333, blue: 0.2156862745) : Color
  421. .insulin
  422. .opacity(0.2)
  423. )
  424. .clipShape(RoundedRectangle(cornerRadius: 15))
  425. .frame(height: HomeLayout.bottomPanelHeight)
  426. .shadow(
  427. color: colorScheme == .dark ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) :
  428. Color.black.opacity(0.33),
  429. radius: 3
  430. )
  431. /// actual bolus view
  432. HStack {
  433. Image(systemName: "cross.vial.fill")
  434. .font(.system(size: 25))
  435. Spacer()
  436. VStack {
  437. Text(bolusLabel)
  438. .font(.subheadline)
  439. .frame(maxWidth: .infinity, alignment: .leading)
  440. Text(bolusString)
  441. .font(.caption)
  442. .frame(maxWidth: .infinity, alignment: .leading)
  443. }.padding(.leading, 5)
  444. Spacer()
  445. if state.bolusStatus == .inProgress {
  446. Button {
  447. state.showProgressView()
  448. state.cancelBolus()
  449. } label: {
  450. Image(systemName: "xmark.app")
  451. .font(.system(size: 25))
  452. }
  453. } else if state.bolusStatus == .initiating {
  454. ProgressView()
  455. }
  456. }.padding(.horizontal, 10)
  457. .padding(.trailing, 8)
  458. }
  459. .padding(.horizontal, 10)
  460. .overlay(alignment: .bottom) {
  461. // bar hugs the panel's bottom edge (the slot no longer has outer bottom padding)
  462. BolusProgressBar(progress: progress)
  463. .padding(.horizontal, 18)
  464. .padding(.bottom, 1)
  465. }.clipShape(RoundedRectangle(cornerRadius: 15))
  466. }
  467. }
  468. func statsDistributionBar(_ segments: [(color: Color, fraction: CGFloat)]) -> some View {
  469. GeometryReader { g in
  470. let spacing: CGFloat = 2
  471. let shown = segments.filter { $0.fraction > 0.005 }
  472. let available = max(g.size.width - spacing * CGFloat(max(shown.count - 1, 0)), 0)
  473. HStack(spacing: spacing) {
  474. ForEach(Array(shown.enumerated()), id: \.offset) { _, segment in
  475. Capsule()
  476. .fill(segment.color)
  477. .frame(width: available * segment.fraction)
  478. }
  479. }
  480. .frame(maxHeight: .infinity)
  481. }
  482. }
  483. /// Mean glucose (mg/dL) of today's readings, nil without data.
  484. private var todayMeanGlucose: Double? {
  485. let startOfDay = Calendar.current.startOfDay(for: Date())
  486. let values = state.glucoseFromPersistence
  487. .filter { ($0.date ?? .distantPast) >= startOfDay }
  488. .map { Double($0.glucose) }
  489. guard !values.isEmpty else { return nil }
  490. return values.reduce(0, +) / Double(values.count)
  491. }
  492. private var todayAverageString: String {
  493. guard let mean = todayMeanGlucose else { return "--" }
  494. if state.units == .mmolL {
  495. return Decimal(mean).asMmolL.formatted(.number.precision(.fractionLength(1))) + " " + GlucoseUnits.mmolL.rawValue
  496. }
  497. return "\(Int(mean.rounded())) " + GlucoseUnits.mgdL.rawValue
  498. }
  499. private var todayGMIString: String {
  500. guard let mean = todayMeanGlucose else { return "--" }
  501. let gmiPercentage = 3.31 + 0.02392 * mean
  502. // settingsManager is injected after first render; default until then
  503. if state.settingsManager?.settings.eA1cDisplayUnit == .mmolMol {
  504. let gmiMmolMol = (gmiPercentage - 2.152) * 10.929
  505. return "\(Int(gmiMmolMol.rounded())) mmol/mol"
  506. }
  507. return gmiPercentage.formatted(.number.precision(.fractionLength(1))) + " %"
  508. }
  509. @ViewBuilder func statsBanner() -> some View {
  510. let face = state.settingsManager?.settings.homeStatsPanelFace ?? .timeInRange
  511. let distribution = state.todayGlucoseDistribution
  512. let coveragePct = distribution.veryLowPct + distribution.lowPct + distribution.inRangePct + distribution
  513. .highPct + distribution.veryHighPct
  514. let hasData = coveragePct > 0
  515. let tirString = hasData
  516. ? distribution.inRangePct.formatted(.number.precision(.fractionLength(0 ... 1))) + " %"
  517. : "-- %"
  518. let segments: [(color: Color, fraction: CGFloat)] = hasData ? [
  519. (.red, CGFloat(distribution.veryLowPct / 100)),
  520. (.orange, CGFloat(distribution.lowPct / 100)),
  521. (.loopGreen, CGFloat(distribution.inRangePct / 100)),
  522. (.purple, CGFloat((distribution.highPct + distribution.veryHighPct) / 100))
  523. ] : [(Color.secondary.opacity(0.3), 1)]
  524. Button {
  525. state.showModal(for: .statistics)
  526. } label: {
  527. ZStack {
  528. RoundedRectangle(cornerRadius: 17, style: .continuous)
  529. .fill(.ultraThinMaterial)
  530. .overlay(
  531. RoundedRectangle(cornerRadius: 17, style: .continuous)
  532. .fill(Color.insulin.opacity(0.08))
  533. )
  534. .overlay(
  535. RoundedRectangle(cornerRadius: 17, style: .continuous)
  536. .strokeBorder(Color.insulin.opacity(0.35), lineWidth: 1)
  537. )
  538. .frame(height: HomeLayout.statsBannerHeight)
  539. .clipShape(RoundedRectangle(cornerRadius: 17, style: .continuous))
  540. .shadow(color: Color.black.opacity(colorScheme == .dark ? 0.25 : 0.10), radius: 3, y: 1)
  541. HStack(alignment: .center, spacing: 12) {
  542. switch face {
  543. case .timeInRange:
  544. VStack(alignment: .leading, spacing: 4) {
  545. HStack(alignment: .firstTextBaseline, spacing: 6) {
  546. Text(tirString)
  547. .font(.title2).fontWeight(.bold).fontDesign(.rounded)
  548. .foregroundStyle(.primary)
  549. Text("Time in Range", comment: "Stats banner subtitle")
  550. .font(.subheadline)
  551. .foregroundStyle(.secondary)
  552. }
  553. statsDistributionBar(segments)
  554. .frame(height: 6)
  555. }
  556. case .distributionBar:
  557. VStack(alignment: .leading, spacing: 6) {
  558. Text("Time in Range", comment: "Stats banner subtitle")
  559. .font(.subheadline).fontWeight(.semibold)
  560. .foregroundStyle(.secondary)
  561. statsDistributionBar(segments)
  562. .frame(height: 6)
  563. }
  564. case .averages:
  565. VStack(alignment: .leading, spacing: 1) {
  566. Text("\u{2300} \(todayAverageString) \u{00B7} GMI \(todayGMIString)")
  567. .font(.subheadline).fontWeight(.semibold)
  568. .foregroundStyle(.primary)
  569. Text("Today's average", comment: "Stats banner subtitle")
  570. .font(.caption)
  571. .foregroundStyle(.secondary)
  572. }
  573. }
  574. Spacer(minLength: 8)
  575. Image(systemName: "chevron.right")
  576. .font(.system(size: 15, weight: .semibold))
  577. .foregroundStyle(.secondary)
  578. }
  579. .padding(.horizontal, 16)
  580. }
  581. .padding(.horizontal, 10)
  582. .contentShape(Rectangle())
  583. }
  584. .buttonStyle(.plain)
  585. }
  586. var multiUsePanelState: MultiUsePanelState {
  587. MultiUsePanelState.resolve(
  588. notificationsDisabled: notificationsDisabled,
  589. pumpTimeMismatch: state.pumpStatusBadgeImage != nil,
  590. lastGlucoseDate: state.glucoseFromPersistence.last?.date,
  591. maxIOB: state.maxIOB,
  592. now: state.timerDate
  593. )
  594. }
  595. /// Shared chrome for the non-stats panel states.
  596. @ViewBuilder func panelBanner(
  597. systemImage: String,
  598. title: String,
  599. subtitle: String,
  600. tint: Color,
  601. isCritical: Bool = false,
  602. action: @escaping () -> Void
  603. ) -> some View {
  604. Button(action: action) {
  605. ZStack {
  606. RoundedRectangle(cornerRadius: 17, style: .continuous)
  607. .fill(.ultraThinMaterial)
  608. .overlay(
  609. RoundedRectangle(cornerRadius: 17, style: .continuous)
  610. .fill(tint.opacity(isCritical ? 0.30 : 0.12))
  611. )
  612. .overlay(
  613. RoundedRectangle(cornerRadius: 17, style: .continuous)
  614. .strokeBorder(tint.opacity(isCritical ? 0.8 : 0.35), lineWidth: isCritical ? 1.5 : 1)
  615. )
  616. .frame(height: HomeLayout.statsBannerHeight)
  617. .clipShape(RoundedRectangle(cornerRadius: 17, style: .continuous))
  618. .shadow(color: Color.black.opacity(colorScheme == .dark ? 0.25 : 0.10), radius: 3, y: 1)
  619. HStack(spacing: 12) {
  620. adjustmentIcon(systemImage, tint: tint)
  621. VStack(alignment: .leading, spacing: 1) {
  622. Text(title)
  623. .font(.subheadline).fontWeight(.semibold)
  624. .foregroundStyle(.primary)
  625. Text(subtitle)
  626. .font(.caption)
  627. .foregroundStyle(.secondary)
  628. }
  629. Spacer(minLength: 8)
  630. Image(systemName: "chevron.right")
  631. .font(.system(size: 15, weight: .semibold))
  632. .foregroundStyle(.secondary)
  633. }
  634. .padding(.horizontal, 16)
  635. }
  636. .padding(.horizontal, 10)
  637. .contentShape(Rectangle())
  638. }
  639. .buttonStyle(.plain)
  640. }
  641. /// One slot, highest-priority state wins; stats is the default face.
  642. @ViewBuilder func multiUsePanel() -> some View {
  643. switch multiUsePanelState {
  644. case .notificationsDisabled:
  645. panelBanner(
  646. systemImage: "bell.slash.fill",
  647. title: String(localized: "Notifications Disabled"),
  648. subtitle: String(localized: "Alarms cannot alert you. Tap to fix."),
  649. tint: .red,
  650. isCritical: true
  651. ) {
  652. UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)
  653. }
  654. case .pumpTimeMismatch:
  655. panelBanner(
  656. systemImage: "clock.badge.exclamationmark.fill",
  657. title: String(localized: "Time Change Detected"),
  658. subtitle: String(localized: "Pump clock differs from phone. Tap to review."),
  659. tint: .orange
  660. ) {
  661. if state.pumpDisplayState != nil {
  662. state.shouldDisplayPumpSetupSheet.toggle()
  663. }
  664. }
  665. case .cgmStale:
  666. panelBanner(
  667. systemImage: "drop.fill",
  668. title: String(localized: "No Recent Glucose"),
  669. subtitle: String(localized: "Tap to add a fingerstick reading."),
  670. tint: .orange
  671. ) {
  672. showManualGlucose = true
  673. }
  674. case .maxIOBZero:
  675. panelBanner(
  676. systemImage: "exclamationmark.triangle.fill",
  677. title: String(localized: "Max IOB is 0 U"),
  678. subtitle: String(localized: "Automated dosing is limited. Tap to review."),
  679. tint: .orange
  680. ) {
  681. openMaxIOBSetting()
  682. }
  683. case .stats:
  684. statsBanner()
  685. }
  686. }
  687. func openMaxIOBSetting() {
  688. // same target search results push, so scroll + highlight wiggle match
  689. selectedTab = 3
  690. settingsPath.append(SearchResultTarget(
  691. screen: .unitsAndLimits,
  692. scrollLabel: "Maximum Insulin on Board (IOB)".localized
  693. ))
  694. }
  695. /// Bottom-anchored fixed zone: adjustment/bolus panel above the stats banner.
  696. @ViewBuilder func bottomControls() -> some View {
  697. VStack(spacing: HomeLayout.bottomZonePadding) {
  698. Group {
  699. if let progress = state.bolusProgress {
  700. bolusView(progress)
  701. } else {
  702. adjustmentView()
  703. }
  704. }
  705. .frame(height: HomeLayout.bottomPanelHeight)
  706. .animation(.easeInOut(duration: 0.2), value: state.bolusProgress != nil)
  707. multiUsePanel()
  708. .frame(height: HomeLayout.statsBannerHeight)
  709. }
  710. .padding(.vertical, HomeLayout.bottomZonePadding)
  711. }
  712. }