HomeRootView+BottomControls.swift 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667
  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. @ViewBuilder func remainingBar(_ fraction: Double?, tint: Color) -> some View {
  269. GeometryReader { barGeo in
  270. if let fraction {
  271. Capsule()
  272. .fill(tint.opacity(0.85))
  273. .frame(width: barGeo.size.width * fraction, height: 3)
  274. .frame(maxHeight: .infinity, alignment: .bottom)
  275. }
  276. }
  277. .frame(height: 3)
  278. }
  279. @ViewBuilder func adjustmentView() -> some View {
  280. let tint = adjustmentTint
  281. // concurrent override + temp target: halved tint, one remaining bar per half
  282. let isConcurrent = overrideString != nil && tempTargetString != nil
  283. ZStack {
  284. RoundedRectangle(cornerRadius: 17, style: .continuous)
  285. .fill(.ultraThinMaterial)
  286. .overlay(
  287. Group {
  288. if isConcurrent {
  289. HStack(spacing: 0) {
  290. Color.purple.opacity(0.12)
  291. Color.loopGreen.opacity(0.12)
  292. }
  293. .clipShape(RoundedRectangle(cornerRadius: 17, style: .continuous))
  294. } else {
  295. RoundedRectangle(cornerRadius: 17, style: .continuous)
  296. .fill((tint ?? Color.clear).opacity(0.12))
  297. }
  298. }
  299. )
  300. .overlay(
  301. RoundedRectangle(cornerRadius: 17, style: .continuous)
  302. .strokeBorder(
  303. isConcurrent
  304. ? AnyShapeStyle(LinearGradient(
  305. colors: [Color.purple.opacity(0.30), Color.loopGreen.opacity(0.30)],
  306. startPoint: .leading,
  307. endPoint: .trailing
  308. ))
  309. : AnyShapeStyle((tint ?? Color.primary).opacity(tint == nil ? 0.08 : 0.30)),
  310. lineWidth: 1
  311. )
  312. )
  313. .frame(height: HomeLayout.bottomPanelHeight)
  314. .overlay(alignment: .bottom) {
  315. // inset clears the corner curve so the bar stays inside the shape
  316. Group {
  317. if isConcurrent {
  318. HStack(spacing: 6) {
  319. remainingBar(overrideRemainingFraction, tint: .purple)
  320. remainingBar(tempTargetRemainingFraction, tint: .loopGreen)
  321. }
  322. } else if let tint = tint {
  323. remainingBar(overrideRemainingFraction ?? tempTargetRemainingFraction, tint: tint)
  324. }
  325. }
  326. .padding(.horizontal, 14)
  327. .padding(.bottom, 3)
  328. }
  329. .clipShape(RoundedRectangle(cornerRadius: 17, style: .continuous))
  330. .shadow(color: Color.black.opacity(colorScheme == .dark ? 0.25 : 0.10), radius: 3, y: 1)
  331. HStack {
  332. if let overrideString = overrideString, let tempTargetString = tempTargetString {
  333. // content halves match the tint halves so icons clear the seam
  334. HStack(spacing: 0) {
  335. HStack {
  336. adjustmentsOverrideView(overrideString)
  337. Spacer(minLength: 0)
  338. }
  339. .frame(maxWidth: .infinity)
  340. HStack {
  341. adjustmentsTempTargetView(tempTargetString)
  342. .padding(.leading, 8)
  343. Spacer(minLength: 0)
  344. adjustmentsCancelView({
  345. if !latestTempTarget.isEmpty, !latestOverride.isEmpty {
  346. showCancelConfirmDialog = true
  347. } else if !latestOverride.isEmpty {
  348. showCancelAlert = true
  349. } else if !latestTempTarget.isEmpty {
  350. showCancelAlert = true
  351. }
  352. })
  353. }
  354. .frame(maxWidth: .infinity)
  355. }
  356. } else if let overrideString = overrideString {
  357. adjustmentsOverrideView(overrideString)
  358. Spacer()
  359. adjustmentsCancelOverrideView()
  360. } else if let tempTargetString = tempTargetString {
  361. HStack {
  362. adjustmentsTempTargetView(tempTargetString)
  363. Spacer()
  364. adjustmentsCancelTempTargetView()
  365. }
  366. } else {
  367. noActiveAdjustmentsView()
  368. }
  369. }.padding(.horizontal, 10)
  370. .confirmationDialog("Adjustment to Stop", isPresented: $showCancelConfirmDialog) {
  371. Button("Stop Override", role: .destructive) {
  372. Task {
  373. guard let objectID = latestOverride.first?.objectID else { return }
  374. await state.cancelOverride(withID: objectID)
  375. }
  376. }
  377. Button("Stop Temp Target", role: .destructive) {
  378. Task {
  379. guard let objectID = latestTempTarget.first?.objectID else { return }
  380. await state.cancelTempTarget(withID: objectID)
  381. }
  382. }
  383. Button("Stop All Adjustments", role: .destructive) {
  384. Task {
  385. guard let overrideObjectID = latestOverride.first?.objectID else { return }
  386. await state.cancelOverride(withID: overrideObjectID)
  387. guard let tempTargetObjectID = latestTempTarget.first?.objectID else { return }
  388. await state.cancelTempTarget(withID: tempTargetObjectID)
  389. }
  390. }
  391. } message: {
  392. Text("Select Adjustment")
  393. }
  394. }
  395. // whole panel navigates; the cancel buttons' own gestures take precedence
  396. .contentShape(Rectangle())
  397. .onTapGesture {
  398. selectedTab = 2
  399. }
  400. .padding(.horizontal, 10)
  401. }
  402. @ViewBuilder func bolusView(_ progress: Decimal) -> some View {
  403. /// ensure that state.lastPumpBolus has a value, i.e. there is a last bolus done by the pump and not an external bolus
  404. /// - TRUE: show the pump bolus
  405. /// - FALSE: do not show a progress bar at all
  406. if let bolusTotal = state.lastPumpBolus?.bolus?.amount {
  407. let bolusFraction = progress * (bolusTotal as Decimal)
  408. let bolusString =
  409. (bolusProgressFormatter.string(from: bolusFraction as NSNumber) ?? "0")
  410. + String(localized: " of ", comment: "Bolus string partial message: 'x U of y U' in home view") +
  411. (Formatter.decimalFormatterWithThreeFractionDigits.string(from: bolusTotal as NSNumber) ?? "0")
  412. + String(localized: " U", comment: "Insulin unit")
  413. let bolusLabel = state
  414. .bolusStatus == .inProgress ? String(localized: "Bolusing") : String(localized: "Initiating…")
  415. ZStack {
  416. /// rectangle as background
  417. RoundedRectangle(cornerRadius: 15)
  418. .fill(
  419. colorScheme == .dark ? Color(red: 0.03921568627, green: 0.133333333, blue: 0.2156862745) : Color
  420. .insulin
  421. .opacity(0.2)
  422. )
  423. .clipShape(RoundedRectangle(cornerRadius: 15))
  424. .frame(height: HomeLayout.bottomPanelHeight)
  425. .shadow(
  426. color: colorScheme == .dark ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) :
  427. Color.black.opacity(0.33),
  428. radius: 3
  429. )
  430. /// actual bolus view
  431. HStack {
  432. Image(systemName: "cross.vial.fill")
  433. .font(.system(size: 25))
  434. Spacer()
  435. VStack {
  436. Text(bolusLabel)
  437. .font(.subheadline)
  438. .frame(maxWidth: .infinity, alignment: .leading)
  439. Text(bolusString)
  440. .font(.caption)
  441. .frame(maxWidth: .infinity, alignment: .leading)
  442. }.padding(.leading, 5)
  443. Spacer()
  444. if state.bolusStatus == .inProgress {
  445. Button {
  446. state.showProgressView()
  447. state.cancelBolus()
  448. } label: {
  449. Image(systemName: "xmark.app")
  450. .font(.system(size: 25))
  451. }
  452. } else if state.bolusStatus == .initiating {
  453. ProgressView()
  454. }
  455. }.padding(.horizontal, 10)
  456. .padding(.trailing, 8)
  457. }
  458. .padding(.horizontal, 10)
  459. .overlay(alignment: .bottom) {
  460. // bar hugs the panel's bottom edge (the slot no longer has outer bottom padding)
  461. BolusProgressBar(progress: progress)
  462. .padding(.horizontal, 18)
  463. .padding(.bottom, 1)
  464. }.clipShape(RoundedRectangle(cornerRadius: 15))
  465. }
  466. }
  467. func statsDistributionBar(_ segments: [(color: Color, fraction: CGFloat)]) -> some View {
  468. GeometryReader { g in
  469. let spacing: CGFloat = 2
  470. let shown = segments.filter { $0.fraction > 0.005 }
  471. let available = max(g.size.width - spacing * CGFloat(max(shown.count - 1, 0)), 0)
  472. HStack(spacing: spacing) {
  473. ForEach(Array(shown.enumerated()), id: \.offset) { _, segment in
  474. Capsule()
  475. .fill(segment.color)
  476. .frame(width: available * segment.fraction)
  477. }
  478. }
  479. .frame(maxHeight: .infinity)
  480. }
  481. }
  482. /// Mean glucose (mg/dL) of today's readings, nil without data.
  483. private var todayMeanGlucose: Double? {
  484. let startOfDay = Calendar.current.startOfDay(for: Date())
  485. let values = state.glucoseFromPersistence
  486. .filter { ($0.date ?? .distantPast) >= startOfDay }
  487. .map { Double($0.glucose) }
  488. guard !values.isEmpty else { return nil }
  489. return values.reduce(0, +) / Double(values.count)
  490. }
  491. private var todayAverageString: String {
  492. guard let mean = todayMeanGlucose else { return "--" }
  493. if state.units == .mmolL {
  494. return Decimal(mean).asMmolL.formatted(.number.precision(.fractionLength(1))) + " " + GlucoseUnits.mmolL.rawValue
  495. }
  496. return "\(Int(mean.rounded())) " + GlucoseUnits.mgdL.rawValue
  497. }
  498. private var todayGMIString: String {
  499. guard let mean = todayMeanGlucose else { return "--" }
  500. let gmiPercentage = 3.31 + 0.02392 * mean
  501. // settingsManager is injected after first render; default until then
  502. if state.settingsManager?.settings.eA1cDisplayUnit == .mmolMol {
  503. let gmiMmolMol = (gmiPercentage - 2.152) * 10.929
  504. return "\(Int(gmiMmolMol.rounded())) mmol/mol"
  505. }
  506. return gmiPercentage.formatted(.number.precision(.fractionLength(1))) + " %"
  507. }
  508. @ViewBuilder func statsBanner() -> some View {
  509. let face = state.settingsManager?.settings.homeStatsPanelFace ?? .timeInRange
  510. let distribution = state.todayGlucoseDistribution
  511. let coveragePct = distribution.veryLowPct + distribution.lowPct + distribution.inRangePct + distribution
  512. .highPct + distribution.veryHighPct
  513. let hasData = coveragePct > 0
  514. let tirString = hasData
  515. ? distribution.inRangePct.formatted(.number.precision(.fractionLength(0 ... 1))) + " %"
  516. : "-- %"
  517. let segments: [(color: Color, fraction: CGFloat)] = hasData ? [
  518. (.red, CGFloat(distribution.veryLowPct / 100)),
  519. (.orange, CGFloat(distribution.lowPct / 100)),
  520. (.loopGreen, CGFloat(distribution.inRangePct / 100)),
  521. (.purple, CGFloat((distribution.highPct + distribution.veryHighPct) / 100))
  522. ] : [(Color.secondary.opacity(0.3), 1)]
  523. Button {
  524. state.showModal(for: .statistics)
  525. } label: {
  526. ZStack {
  527. RoundedRectangle(cornerRadius: 17, style: .continuous)
  528. .fill(.ultraThinMaterial)
  529. .overlay(
  530. RoundedRectangle(cornerRadius: 17, style: .continuous)
  531. .fill(Color.insulin.opacity(0.08))
  532. )
  533. .overlay(
  534. RoundedRectangle(cornerRadius: 17, style: .continuous)
  535. .strokeBorder(Color.insulin.opacity(0.35), lineWidth: 1)
  536. )
  537. .frame(height: HomeLayout.statsBannerHeight)
  538. .clipShape(RoundedRectangle(cornerRadius: 17, style: .continuous))
  539. .shadow(color: Color.black.opacity(colorScheme == .dark ? 0.25 : 0.10), radius: 3, y: 1)
  540. HStack(alignment: .center, spacing: 12) {
  541. switch face {
  542. case .timeInRange:
  543. VStack(alignment: .leading, spacing: 4) {
  544. HStack(alignment: .firstTextBaseline, spacing: 6) {
  545. Text(tirString)
  546. .font(.title2).fontWeight(.bold).fontDesign(.rounded)
  547. .foregroundStyle(.primary)
  548. Text("Time in Range", comment: "Stats banner subtitle")
  549. .font(.subheadline)
  550. .foregroundStyle(.secondary)
  551. }
  552. statsDistributionBar(segments)
  553. .frame(height: 6)
  554. }
  555. case .distributionBar:
  556. VStack(alignment: .leading, spacing: 6) {
  557. Text("Time in Range", comment: "Stats banner subtitle")
  558. .font(.subheadline).fontWeight(.semibold)
  559. .foregroundStyle(.secondary)
  560. statsDistributionBar(segments)
  561. .frame(height: 6)
  562. }
  563. case .averages:
  564. VStack(alignment: .leading, spacing: 1) {
  565. Text("\u{2300} \(todayAverageString) \u{00B7} GMI \(todayGMIString)")
  566. .font(.subheadline).fontWeight(.semibold)
  567. .foregroundStyle(.primary)
  568. Text("Today's average", comment: "Stats banner subtitle")
  569. .font(.caption)
  570. .foregroundStyle(.secondary)
  571. }
  572. }
  573. Spacer(minLength: 8)
  574. Image(systemName: "chevron.right")
  575. .font(.system(size: 15, weight: .semibold))
  576. .foregroundStyle(.secondary)
  577. }
  578. .padding(.horizontal, 16)
  579. }
  580. .padding(.horizontal, 10)
  581. .contentShape(Rectangle())
  582. }
  583. .buttonStyle(.plain)
  584. }
  585. /// Bottom-anchored fixed zone: adjustment/bolus panel above the stats banner.
  586. @ViewBuilder func bottomControls() -> some View {
  587. VStack(spacing: HomeLayout.bottomZonePadding) {
  588. Group {
  589. if let progress = state.bolusProgress {
  590. bolusView(progress)
  591. } else {
  592. adjustmentView()
  593. }
  594. }
  595. .frame(height: HomeLayout.bottomPanelHeight)
  596. .animation(.easeInOut(duration: 0.2), value: state.bolusProgress != nil)
  597. statsBanner()
  598. .frame(height: HomeLayout.statsBannerHeight)
  599. }
  600. .padding(.vertical, HomeLayout.bottomZonePadding)
  601. }
  602. }