Просмотр исходного кода

Merge pull request #1327 from dnzxy/home-refactor-layout

[Part 2 of 7] Home Layout v2 – Rework Home layout: panels, TIR banner, meal row
marv-out 1 день назад
Родитель
Сommit
1762c7d238

+ 4 - 0
Trio.xcodeproj/project.pbxproj

@@ -866,6 +866,7 @@
 		DDE179702C910127003CDDB7 /* OverrideStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179502C910127003CDDB7 /* OverrideStored+CoreDataClass.swift */; };
 		DDE179712C910127003CDDB7 /* OverrideStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179512C910127003CDDB7 /* OverrideStored+CoreDataProperties.swift */; };
 		DDEBB05C2D89E9050032305D /* TimeInRangeType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDEBB05B2D89E9050032305D /* TimeInRangeType.swift */; };
+		7A11AC0DE000000000000020 /* HomeStatsPanelFace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11AC0DE000000000000120 /* HomeStatsPanelFace.swift */; };
 		DDF68FFC2D9ECF7F008BF16C /* OnboardingStateModel+Nightscout.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF68FFB2D9ECF77008BF16C /* OnboardingStateModel+Nightscout.swift */; };
 		DDF6902C2DA028D3008BF16C /* DiagnosticsStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF6902B2DA028D3008BF16C /* DiagnosticsStepView.swift */; };
 		DDF6905C2DA0AFC5008BF16C /* WelcomeStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF6905B2DA0AFC5008BF16C /* WelcomeStepView.swift */; };
@@ -1886,6 +1887,7 @@
 		DDE179502C910127003CDDB7 /* OverrideStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OverrideStored+CoreDataClass.swift"; sourceTree = "<group>"; };
 		DDE179512C910127003CDDB7 /* OverrideStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OverrideStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
 		DDEBB05B2D89E9050032305D /* TimeInRangeType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeInRangeType.swift; sourceTree = "<group>"; };
+		7A11AC0DE000000000000120 /* HomeStatsPanelFace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeStatsPanelFace.swift; sourceTree = "<group>"; };
 		DDF68FFB2D9ECF77008BF16C /* OnboardingStateModel+Nightscout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OnboardingStateModel+Nightscout.swift"; sourceTree = "<group>"; };
 		DDF6902B2DA028D3008BF16C /* DiagnosticsStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticsStepView.swift; sourceTree = "<group>"; };
 		DDF6905B2DA0AFC5008BF16C /* WelcomeStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeStepView.swift; sourceTree = "<group>"; };
@@ -2787,6 +2789,7 @@
 				3871F39B25ED892B0013ECB5 /* TempTarget.swift */,
 				BD54A9722D281A9C00F9C1EE /* TempTargetPresetWatch.swift */,
 				DDEBB05B2D89E9050032305D /* TimeInRangeType.swift */,
+				7A11AC0DE000000000000120 /* HomeStatsPanelFace.swift */,
 				1935363F28496F7D001E0B16 /* TrioCustomOrefVariables.swift */,
 				38AEE73C25F0200C0013F05B /* TrioSettings.swift */,
 				3811DE8E25C9D80400A708ED /* User.swift */,
@@ -5461,6 +5464,7 @@
 				BD47FD172D88AAF50043966B /* CompletedStepView.swift in Sources */,
 				DD4AFFF12DADB59100AB7387 /* AlgorithmSettingsContentsStepView.swift in Sources */,
 				DDEBB05C2D89E9050032305D /* TimeInRangeType.swift in Sources */,
+				7A11AC0DE000000000000020 /* HomeStatsPanelFace.swift in Sources */,
 				DD5DC9F32CF3D9DD00AB8703 /* AdjustmentsStateModel+TempTargets.swift in Sources */,
 				BD47FDDB2D8B659B0043966B /* BasalProfileStepView.swift in Sources */,
 				F5F7E6C1B7F098F59EB67EC5 /* TargetsEditorDataFlow.swift in Sources */,

+ 19 - 0
Trio/Sources/Models/HomeStatsPanelFace.swift

@@ -0,0 +1,19 @@
+import Foundation
+
+enum HomeStatsPanelFace: String, JSON, CaseIterable, Identifiable, Codable, Hashable {
+    var id: String { rawValue }
+    case timeInRange
+    case distributionBar
+    case averages
+
+    var displayName: String {
+        switch self {
+        case .timeInRange:
+            return String(localized: "Time in Range", comment: "Home stats panel face option")
+        case .distributionBar:
+            return String(localized: "Distribution Bar Only", comment: "Home stats panel face option")
+        case .averages:
+            return String(localized: "Today's Averages", comment: "Home stats panel face option")
+        }
+    }
+}

+ 5 - 0
Trio/Sources/Models/TrioSettings.swift

@@ -69,6 +69,7 @@ struct TrioSettings: JSON, Equatable, Encodable {
     var displayGlucoseForecasts: Bool = false
     var bolusShortcut: BolusShortcutLimit = .notAllowed
     var timeInRangeType: TimeInRangeType = .timeInTightRange
+    var homeStatsPanelFace: HomeStatsPanelFace = .timeInRange
     var requireAdjustmentsConfirmation: Bool = false
 
     /// Selected Garmin watchface (Trio or SwissAlpine)
@@ -323,6 +324,10 @@ extension TrioSettings: Decodable {
             settings.timeInRangeType = timeInRangeType
         }
 
+        if let homeStatsPanelFace = try? container.decode(HomeStatsPanelFace.self, forKey: .homeStatsPanelFace) {
+            settings.homeStatsPanelFace = homeStatsPanelFace
+        }
+
         if let requireAdjustmentsConfirmation = try? container.decode(Bool.self, forKey: .requireAdjustmentsConfirmation) {
             settings.requireAdjustmentsConfirmation = requireAdjustmentsConfirmation
         }

+ 3 - 1
Trio/Sources/Modules/Home/HomeStateModel+Setup/ChartAxisSetup.swift

@@ -34,7 +34,9 @@ extension Home.StateModel {
             maxYValue = 400
         }
 
-        minYAxisValue = minOverall
+        // keep the low threshold (and target) inside the domain: RuleMarks
+        // below the floor would clamp onto the plot edge
+        minYAxisValue = min(minOverall, lowGlucose - 20)
         maxYAxisValue = maxYValue
     }
 

+ 18 - 0
Trio/Sources/Modules/Home/HomeStateModel+Setup/GlucoseSetup.swift

@@ -31,3 +31,21 @@ extension Home.StateModel {
         }
     }
 }
+
+extension Home.StateModel {
+    /// Today's glucose range distribution for the stats banner.
+    var todayGlucoseDistribution: GlucoseDailyDistributionStats {
+        let startOfDay = Calendar.current.startOfDay(for: Date())
+        let readings = glucoseFromPersistence
+            .filter { ($0.date ?? .distantPast) >= startOfDay }
+            .map { GlucoseReading(value: Int($0.glucose), date: $0.date ?? startOfDay) }
+        // first render happens before service injection
+        let timeInRangeType = settingsManager?.settings.timeInRangeType ?? .timeInTightRange
+        return GlucoseDailyDistributionStats.compute(
+            date: startOfDay,
+            readings: readings,
+            highLimit: highGlucose,
+            timeInRangeType: timeInRangeType
+        )
+    }
+}

+ 4 - 0
Trio/Sources/Modules/Home/View/Chart/ChartElements/BasalChart.swift

@@ -32,6 +32,10 @@ extension MainChartView {
             .onChange(of: state.maxBasal) {
                 calculateBasals()
             }
+            // profile loads async after first appearance; redraw the dashed line
+            .onChange(of: state.basalProfile) {
+                calculateBasals()
+            }
             .frame(minHeight: basalHeight)
             .frame(width: fullWidth(viewWidth: screenSize.width))
             .chartXScale(domain: state.startMarker ... state.endMarker)

+ 6 - 0
Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift

@@ -92,6 +92,12 @@ struct CurrentGlucoseView: View {
 
     @ViewBuilder private func bobbleAndTag(triangleColor: Color) -> some View {
         ZStack {
+            Circle()
+                .fill(triangleColor)
+                .frame(width: 124, height: 124)
+                .blur(radius: 24)
+                .opacity(colorScheme == .dark ? 0.32 : 0.20)
+
             if let progress = cgmProgress, shouldShowArc {
                 SensorLifecycleArcView(
                     progress: progress.percentComplete,

+ 8 - 7
Trio/Sources/Modules/Home/View/HomeLayout.swift

@@ -4,16 +4,17 @@ import CoreGraphics
 enum HomeLayout {
     /// header slot: pump panel / glucose bobble / loop status; includes room
     /// for the sensor arc/tag overhanging the bobble
-    static let headerHeight: CGFloat = 166
+    static let headerHeight: CGFloat = 172
     /// meal panel slot (IOB / COB / delivery rate)
     static let mealSlotHeight: CGFloat = 44
     /// shared slot for adjustment panel and bolus progress (was 8% of screen height)
     static let bottomPanelHeight: CGFloat = 60
-    /// clear air above (chart x-axis labels) and below (tab bar) the bottom panel
+    /// stats banner slot below the adjustment panel (multi-use panel later)
+    static let statsBannerHeight: CGFloat = 60
+    /// clear air above, between, and below the bottom-zone panels
     static let bottomZonePadding: CGFloat = 10
-    static var bottomZoneHeight: CGFloat { bottomPanelHeight + 2 * bottomZonePadding }
-    /// interim slot for the time-interval buttons; removed with the layout rework
-    static let timeButtonsRowHeight: CGFloat = 44
-    /// minimum usable chart height (basal + glucose + COB/IOB panes)
-    static let chartMinHeight: CGFloat = 260
+    static var bottomZoneHeight: CGFloat { bottomPanelHeight + statsBannerHeight + 3 * bottomZonePadding }
+    /// minimum usable chart height; must stay below the natural SE allocation
+    /// (598 − header − meal slot − bottom zone = 238) or the bottom zone overflows
+    static let chartMinHeight: CGFloat = 220
 }

+ 285 - 75
Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift

@@ -31,6 +31,40 @@ extension Home.RootView {
         return formatter
     }
 
+    func remainingFraction(start: Date?, durationMinutes: Decimal?, indefinite: Bool) -> Double? {
+        guard !indefinite, let start = start, let durationMinutes = durationMinutes, durationMinutes > 0 else {
+            return nil
+        }
+        let total = Double(truncating: durationMinutes as NSNumber) * 60
+        let elapsed = Date().timeIntervalSince(start)
+        guard total > 0 else { return nil }
+        return min(max(1 - elapsed / total, 0), 1)
+    }
+
+    var overrideRemainingFraction: Double? {
+        guard let o = latestOverride.first else { return nil }
+        return remainingFraction(start: o.date, durationMinutes: o.duration as Decimal?, indefinite: o.indefinite)
+    }
+
+    var tempTargetRemainingFraction: Double? {
+        guard let t = latestTempTarget.first else { return nil }
+        return remainingFraction(start: t.date, durationMinutes: t.duration as Decimal?, indefinite: false)
+    }
+
+    @ViewBuilder func adjustmentIcon(_ systemName: String, tint: Color) -> some View {
+        Image(systemName: systemName)
+            .font(.system(size: 15, weight: .semibold))
+            .foregroundStyle(tint)
+            .frame(width: 30, height: 30)
+            .background(Circle().fill(tint.opacity(0.18)))
+    }
+
+    var adjustmentTint: Color? {
+        if overrideString != nil { return Color.purple }
+        if tempTargetString != nil { return Color.loopGreen }
+        return nil
+    }
+
     var overrideString: String? {
         guard let latestOverride = latestOverride.first else {
             return nil
@@ -162,38 +196,30 @@ extension Home.RootView {
 
     @ViewBuilder func adjustmentsOverrideView(_ overrideString: String) -> some View {
         Group {
-            Image(systemName: "clock.arrow.2.circlepath")
-                .font(.title2)
-                .foregroundStyle(Color.primary, Color.purple)
-            VStack(alignment: .leading) {
+            adjustmentIcon("clock.arrow.2.circlepath", tint: Color.purple)
+            VStack(alignment: .leading, spacing: 1) {
                 Text(latestOverride.first?.name ?? String(localized: "Custom Override"))
-                    .font(.subheadline)
+                    .font(.subheadline).fontWeight(.semibold)
                     .frame(alignment: .leading)
 
                 Text(overrideString)
                     .font(.caption)
+                    .foregroundStyle(.secondary)
             }
         }
-        .onTapGesture {
-            selectedTab = 2
-        }
     }
 
     @ViewBuilder func adjustmentsTempTargetView(_ tempTargetString: String) -> some View {
         Group {
-            Image(systemName: "target")
-                .font(.title2)
-                .foregroundStyle(Color.loopGreen)
-            VStack(alignment: .leading) {
+            adjustmentIcon("target", tint: Color.loopGreen)
+            VStack(alignment: .leading, spacing: 1) {
                 Text(latestTempTarget.first?.name ?? String(localized: "Temp Target"))
-                    .font(.subheadline)
+                    .font(.subheadline).fontWeight(.semibold)
                 Text(tempTargetString)
                     .font(.caption)
+                    .foregroundStyle(.secondary)
             }
         }
-        .onTapGesture {
-            selectedTab = 2
-        }
     }
 
     @ViewBuilder func adjustmentsCancelView(_ cancelAction: @escaping () -> Void) -> some View {
@@ -254,76 +280,121 @@ extension Home.RootView {
 
     @ViewBuilder func noActiveAdjustmentsView() -> some View {
         Group {
-            VStack {
+            adjustmentIcon("slider.horizontal.2.gobackward", tint: Color.secondary)
+
+            VStack(alignment: .leading, spacing: 1) {
                 Text("No Active Adjustment")
-                    .font(.subheadline)
+                    .font(.subheadline).fontWeight(.medium)
+                    .foregroundStyle(.secondary)
                     .frame(maxWidth: .infinity, alignment: .leading)
                 Text("Profile at 100 %")
                     .font(.caption)
+                    .foregroundStyle(.tertiary)
                     .frame(maxWidth: .infinity, alignment: .leading)
-            }.padding(.leading, 10)
+            }
 
             Spacer()
 
-            /// to ensure the same position....
+            // clear icon keeps text aligned with the cancel-button states
             Image(systemName: "xmark.app")
                 .font(.title)
-                // clear color for the icon
                 .foregroundStyle(Color.clear)
-        }.onTapGesture {
-            selectedTab = 2
         }
     }
 
+    // same track pattern as BolusProgressBar, slightly slimmer
+    @ViewBuilder func remainingBar(_ fraction: Double?, tint: Color) -> some View {
+        GeometryReader { barGeo in
+            if let fraction {
+                RoundedRectangle(cornerRadius: 15)
+                    .fill(tint.opacity(0.85))
+                    .frame(width: barGeo.size.width * fraction, height: 4)
+                    .frame(maxHeight: .infinity, alignment: .bottom)
+            }
+        }
+        .frame(height: 4)
+    }
+
     @ViewBuilder func adjustmentView() -> some View {
-//            let background = colorScheme == .dark ? Material.ultraThinMaterial.opacity(0.5) : Color.black.opacity(0.2)
+        let tint = adjustmentTint
+        // concurrent override + temp target: halved tint, one remaining bar per half
+        let isConcurrent = overrideString != nil && tempTargetString != nil
 
         ZStack {
-            /// rectangle as background
-            RoundedRectangle(cornerRadius: 15)
-                .fill(
-                    (overrideString != nil || tempTargetString != nil) ?
-                        (
-                            colorScheme == .dark ?
-                                Color(red: 0.03921568627, green: 0.133333333, blue: 0.2156862745) :
-                                Color.insulin.opacity(0.1)
-                        ) : Color.clear // Use clear and add the Material in the background
+            RoundedRectangle(cornerRadius: 17, style: .continuous)
+                .fill(.ultraThinMaterial)
+                .overlay(
+                    Group {
+                        if isConcurrent {
+                            HStack(spacing: 0) {
+                                Color.purple.opacity(0.12)
+                                Color.loopGreen.opacity(0.12)
+                            }
+                            .clipShape(RoundedRectangle(cornerRadius: 17, style: .continuous))
+                        } else {
+                            RoundedRectangle(cornerRadius: 17, style: .continuous)
+                                .fill((tint ?? Color.clear).opacity(0.12))
+                        }
+                    }
                 )
-                .background(colorScheme == .dark ? Color.chart.opacity(0.25) : Color.black.opacity(0.075))
-                .clipShape(RoundedRectangle(cornerRadius: 15))
-                .frame(height: HomeLayout.bottomPanelHeight)
-                .shadow(
-                    color: (overrideString != nil || tempTargetString != nil) ?
-                        (
-                            colorScheme == .dark ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) :
-                                Color.black.opacity(0.33)
-                        ) : Color.clear,
-                    radius: 3
+                .overlay(
+                    RoundedRectangle(cornerRadius: 17, style: .continuous)
+                        .strokeBorder(
+                            isConcurrent
+                                ? AnyShapeStyle(LinearGradient(
+                                    colors: [Color.purple.opacity(0.30), Color.loopGreen.opacity(0.30)],
+                                    startPoint: .leading,
+                                    endPoint: .trailing
+                                ))
+                                : AnyShapeStyle((tint ?? Color.primary).opacity(tint == nil ? 0.08 : 0.30)),
+                            lineWidth: 1
+                        )
                 )
+                .frame(height: HomeLayout.bottomPanelHeight)
+                .overlay(alignment: .bottom) {
+                    // anchored like the bolus progress bar so both panels match
+                    Group {
+                        if isConcurrent {
+                            HStack(spacing: 6) {
+                                remainingBar(overrideRemainingFraction, tint: .purple)
+                                remainingBar(tempTargetRemainingFraction, tint: .loopGreen)
+                            }
+                        } else if let tint = tint {
+                            remainingBar(overrideRemainingFraction ?? tempTargetRemainingFraction, tint: tint)
+                        }
+                    }
+                    .padding(.horizontal, 18)
+                    .padding(.bottom, 1)
+                }
+                .clipShape(RoundedRectangle(cornerRadius: 17, style: .continuous))
+                .shadow(color: Color.black.opacity(colorScheme == .dark ? 0.25 : 0.10), radius: 3, y: 1)
             HStack {
                 if let overrideString = overrideString, let tempTargetString = tempTargetString {
-                    HStack {
-                        adjustmentsOverrideView(overrideString)
-
-                        Spacer()
-
-                        Divider()
-                            .frame(height: HomeLayout.bottomPanelHeight - 24)
-                            .padding(.horizontal, 2)
-
-                        adjustmentsTempTargetView(tempTargetString)
-
-                        Spacer()
-
-                        adjustmentsCancelView({
-                            if !latestTempTarget.isEmpty, !latestOverride.isEmpty {
-                                showCancelConfirmDialog = true
-                            } else if !latestOverride.isEmpty {
-                                showCancelAlert = true
-                            } else if !latestTempTarget.isEmpty {
-                                showCancelAlert = true
-                            }
-                        })
+                    // content halves match the tint halves so icons clear the seam
+                    HStack(spacing: 0) {
+                        HStack {
+                            adjustmentsOverrideView(overrideString)
+                            Spacer(minLength: 0)
+                        }
+                        .frame(maxWidth: .infinity)
+
+                        HStack {
+                            adjustmentsTempTargetView(tempTargetString)
+                                .padding(.leading, 8)
+
+                            Spacer(minLength: 0)
+
+                            adjustmentsCancelView({
+                                if !latestTempTarget.isEmpty, !latestOverride.isEmpty {
+                                    showCancelConfirmDialog = true
+                                } else if !latestOverride.isEmpty {
+                                    showCancelAlert = true
+                                } else if !latestTempTarget.isEmpty {
+                                    showCancelAlert = true
+                                }
+                            })
+                        }
+                        .frame(maxWidth: .infinity)
                     }
                 } else if let overrideString = overrideString {
                     adjustmentsOverrideView(overrideString)
@@ -365,7 +436,13 @@ extension Home.RootView {
                 } message: {
                     Text("Select Adjustment")
                 }
-        }.padding(.horizontal, 10)
+        }
+        // whole panel navigates; the cancel buttons' own gestures take precedence
+        .contentShape(Rectangle())
+        .onTapGesture {
+            selectedTab = 2
+        }
+        .padding(.horizontal, 10)
     }
 
     @ViewBuilder func bolusView(_ progress: Decimal) -> some View {
@@ -440,18 +517,151 @@ extension Home.RootView {
         }
     }
 
-    /// Bottom-anchored fixed slot shared by adjustment panel and bolus progress.
+    func statsDistributionBar(_ segments: [(color: Color, fraction: CGFloat)]) -> some View {
+        GeometryReader { g in
+            let spacing: CGFloat = 2
+            let shown = segments.filter { $0.fraction > 0.005 }
+            let available = max(g.size.width - spacing * CGFloat(max(shown.count - 1, 0)), 0)
+            HStack(spacing: spacing) {
+                ForEach(Array(shown.enumerated()), id: \.offset) { _, segment in
+                    Capsule()
+                        .fill(segment.color)
+                        .frame(width: available * segment.fraction)
+                }
+            }
+            .frame(maxHeight: .infinity)
+        }
+    }
+
+    /// Mean glucose (mg/dL) of today's readings, nil without data.
+    private var todayMeanGlucose: Double? {
+        let startOfDay = Calendar.current.startOfDay(for: Date())
+        let values = state.glucoseFromPersistence
+            .filter { ($0.date ?? .distantPast) >= startOfDay }
+            .map { Double($0.glucose) }
+        guard !values.isEmpty else { return nil }
+        return values.reduce(0, +) / Double(values.count)
+    }
+
+    private var todayAverageString: String {
+        guard let mean = todayMeanGlucose else { return "--" }
+        if state.units == .mmolL {
+            return Decimal(mean).asMmolL.formatted(.number.precision(.fractionLength(1))) + " " + GlucoseUnits.mmolL.rawValue
+        }
+        return "\(Int(mean.rounded())) " + GlucoseUnits.mgdL.rawValue
+    }
+
+    private var todayGMIString: String {
+        guard let mean = todayMeanGlucose else { return "--" }
+        let gmiPercentage = 3.31 + 0.02392 * mean
+        // settingsManager is injected after first render; default until then
+        if state.settingsManager?.settings.eA1cDisplayUnit == .mmolMol {
+            let gmiMmolMol = (gmiPercentage - 2.152) * 10.929
+            return "\(Int(gmiMmolMol.rounded())) mmol/mol"
+        }
+        return gmiPercentage.formatted(.number.precision(.fractionLength(1))) + " %"
+    }
+
+    @ViewBuilder func statsBanner() -> some View {
+        let face = state.settingsManager?.settings.homeStatsPanelFace ?? .timeInRange
+        let distribution = state.todayGlucoseDistribution
+        let coveragePct = distribution.veryLowPct + distribution.lowPct + distribution.inRangePct + distribution
+            .highPct + distribution.veryHighPct
+        let hasData = coveragePct > 0
+        let tirString = hasData
+            ? distribution.inRangePct.formatted(.number.precision(.fractionLength(0 ... 1))) + " %"
+            : "-- %"
+        let segments: [(color: Color, fraction: CGFloat)] = hasData ? [
+            (.red, CGFloat(distribution.veryLowPct / 100)),
+            (.orange, CGFloat(distribution.lowPct / 100)),
+            (.loopGreen, CGFloat(distribution.inRangePct / 100)),
+            (.purple, CGFloat((distribution.highPct + distribution.veryHighPct) / 100))
+        ] : [(Color.secondary.opacity(0.3), 1)]
+
+        Button {
+            state.showModal(for: .statistics)
+        } label: {
+            ZStack {
+                RoundedRectangle(cornerRadius: 17, style: .continuous)
+                    .fill(.ultraThinMaterial)
+                    .overlay(
+                        RoundedRectangle(cornerRadius: 17, style: .continuous)
+                            .fill(Color.insulin.opacity(0.08))
+                    )
+                    .overlay(
+                        RoundedRectangle(cornerRadius: 17, style: .continuous)
+                            .strokeBorder(Color.insulin.opacity(0.35), lineWidth: 1)
+                    )
+                    .frame(height: HomeLayout.statsBannerHeight)
+                    .clipShape(RoundedRectangle(cornerRadius: 17, style: .continuous))
+                    .shadow(color: Color.black.opacity(colorScheme == .dark ? 0.25 : 0.10), radius: 3, y: 1)
+
+                HStack(alignment: .center, spacing: 12) {
+                    switch face {
+                    case .timeInRange:
+                        VStack(alignment: .leading, spacing: 4) {
+                            HStack(alignment: .firstTextBaseline, spacing: 6) {
+                                Text(tirString)
+                                    .font(.title2).fontWeight(.bold).fontDesign(.rounded)
+                                    .foregroundStyle(.primary)
+                                Text("Time in Range", comment: "Stats banner subtitle")
+                                    .font(.subheadline)
+                                    .foregroundStyle(.secondary)
+                            }
+
+                            statsDistributionBar(segments)
+                                .frame(height: 6)
+                        }
+                    case .distributionBar:
+                        VStack(alignment: .leading, spacing: 6) {
+                            Text("Time in Range", comment: "Stats banner subtitle")
+                                .font(.subheadline).fontWeight(.semibold)
+                                .foregroundStyle(.secondary)
+
+                            statsDistributionBar(segments)
+                                .frame(height: 6)
+                        }
+                    case .averages:
+                        VStack(alignment: .leading, spacing: 1) {
+                            Text("\u{2300} \(todayAverageString) \u{00B7} GMI \(todayGMIString)")
+                                .font(.subheadline).fontWeight(.semibold)
+                                .foregroundStyle(.primary)
+                            Text("Today's average", comment: "Stats banner subtitle")
+                                .font(.caption)
+                                .foregroundStyle(.secondary)
+                        }
+                    }
+
+                    Spacer(minLength: 8)
+
+                    Image(systemName: "chevron.right")
+                        .font(.system(size: 15, weight: .semibold))
+                        .foregroundStyle(.secondary)
+                }
+                .padding(.horizontal, 16)
+            }
+            .padding(.horizontal, 10)
+            .contentShape(Rectangle())
+        }
+        .buttonStyle(.plain)
+    }
+
+    /// Bottom-anchored fixed zone: adjustment/bolus panel above the stats banner.
     @ViewBuilder func bottomControls() -> some View {
-        Group {
-            if let progress = state.bolusProgress {
-                bolusView(progress)
-            } else {
-                adjustmentView()
+        VStack(spacing: HomeLayout.bottomZonePadding) {
+            Group {
+                if let progress = state.bolusProgress {
+                    bolusView(progress)
+                } else {
+                    adjustmentView()
+                }
             }
+            .frame(height: HomeLayout.bottomPanelHeight)
+            .animation(.easeInOut(duration: 0.2), value: state.bolusProgress != nil)
+
+            statsBanner()
+                .frame(height: HomeLayout.statsBannerHeight)
         }
-        .frame(height: HomeLayout.bottomPanelHeight)
-        .animation(.easeInOut(duration: 0.2), value: state.bolusProgress != nil)
-        // clear air below the chart's x-axis labels and above the tab bar
         .padding(.vertical, HomeLayout.bottomZonePadding)
     }
 }

+ 71 - 109
Trio/Sources/Modules/Home/View/HomeRootView+MealPanel.swift

@@ -4,126 +4,88 @@ import SwiftUI
 // MARK: - Zone C: meal panel (IOB / COB / delivery rate)
 
 extension Home.RootView {
-    var basalString: String? {
-        var rate: NSNumber = 0
-        var manualBasalString = ""
-
-        guard let apsManager = state.apsManager else {
-            return nil
-        }
-
-        if apsManager.isScheduledBasal == true {
-            guard let scheduledRate = scheduledBasalDeliveryRate(at: Date()) else {
-                return nil
-            }
-            rate = scheduledRate
-        } else {
-            guard let lastTempBasal = state.tempBasals.last?.tempBasal, let tempRate = lastTempBasal.rate else {
-                return nil
-            }
-            if apsManager.isManualTempBasal {
-                manualBasalString = String(
-                    localized: " - Manual Basal ⚠️",
-                    comment: "Manual Temp basal"
-                )
-            }
-            rate = tempRate
-        }
-
-        let rateString = Formatter.decimalFormatterWithThreeFractionDigits.string(from: rate) ?? "0"
-        return rateString + String(localized: " U/hr", comment: "Unit per hour with space") +
-            manualBasalString
-    }
-
-    func scheduledBasalDeliveryRate(at when: Date) -> NSNumber? {
-        let calendar = Calendar(identifier: .gregorian)
-        // calendar.timeZone = timeZone /// should come from pumpManager in case it's different!
-
-        let hours = calendar.component(.hour, from: when)
-        let minutes = calendar.component(.minute, from: when)
-        let totalMinutes = hours * 60 + minutes
-
-        if let rate = findBasalRateForOffset(for: totalMinutes, in: state.basalProfile) {
-            return NSDecimalNumber(decimal: rate)
-        }
-        return nil
-    }
-
     @ViewBuilder func mealPanel() -> some View {
-        HStack {
-            HStack {
-                Image(systemName: "syringe.fill")
+        ZStack {
+            // the carb value itself sits on the panel's midline; the icon hangs off it
+            Text(
+                (
+                    Formatter.decimalFormatterWithTwoFractionDigits.string(
+                        from: NSNumber(value: state.enactedAndNonEnactedDeterminations.first?.cob ?? 0)
+                    ) ?? "0"
+                ) +
+                    String(localized: " g", comment: "gram of carbs")
+            )
+            .font(.callout).fontWeight(.bold).fontDesign(.rounded)
+            .overlay(alignment: .leading) {
+                Image(systemName: "fork.knife")
                     .font(.callout)
-                    .foregroundColor(Color.insulin)
-                Text(
-                    (
-                        Formatter.decimalFormatterWithTwoFractionDigits
-                            .string(from: state.currentIOB as NSNumber) ?? "0"
-                    ) +
-                        String(localized: " U", comment: "Insulin unit")
-                )
-                .font(.callout).fontWeight(.bold).fontDesign(.rounded)
+                    .foregroundColor(.loopYellow)
+                    .alignmentGuide(.leading) { $0.width + 5 }
             }
 
-            Spacer()
-
             HStack {
-                Image(systemName: "fork.knife")
-                    .font(.callout)
-                    .foregroundColor(.loopYellow)
-                Text(
-                    (
-                        Formatter.decimalFormatterWithTwoFractionDigits.string(
-                            from: NSNumber(value: state.enactedAndNonEnactedDeterminations.first?.cob ?? 0)
-                        ) ?? "0"
-                    ) +
-                        String(localized: " g", comment: "gram of carbs")
-                )
-                .font(.callout).fontWeight(.bold).fontDesign(.rounded)
+                HStack {
+                    Image(systemName: "syringe.fill")
+                        .font(.callout)
+                        .foregroundColor(Color.insulin)
+                    Text(
+                        (
+                            Formatter.decimalFormatterWithTwoFractionDigits
+                                .string(from: state.currentIOB as NSNumber) ?? "0"
+                        ) +
+                            String(localized: " U", comment: "Insulin unit")
+                    )
+                    .font(.callout).fontWeight(.bold).fontDesign(.rounded)
+                }
+
+                Spacer()
+
+                alarmsPill
             }
+        }.padding(.horizontal)
+    }
 
-            Spacer()
+    func refreshAlarmsSnooze() {
+        alarmsSnoozeUntil = UserDefaults.standard
+            .object(forKey: "UserNotificationsManager.snoozeUntilDate") as? Date ?? .distantPast
+    }
 
-            if state.maxIOB == 0.0 {
-                HStack {
-                    Image(systemName: "exclamationmark.circle.fill")
-                    Text("MaxIOB: 0 U")
-                }.bold()
-                    .foregroundStyle(Color.red)
-                    .font(.callout)
-            } else {
-                HStack {
-                    /// Only display the insulin delivery rate info if the pump is not
-                    /// suspended and is available (e.g., pod is paired & not faulted).
-                    let pumpAvailable = state.apsManager.isScheduledBasal != nil
-                    if !state.apsManager.isSuspended && pumpAvailable {
-                        Image(systemName: "drop.circle")
+    /// Bell pill matching the header pills; countdown replaces the label while snoozed.
+    @ViewBuilder var alarmsPill: some View {
+        // timerDate keeps the countdown ticking
+        let isSnoozed = alarmsSnoozeUntil > state.timerDate
+        let remainingMinutes = max(Int(ceil(alarmsSnoozeUntil.timeIntervalSince(state.timerDate) / 60)), 0)
+
+        Button {
+            showSnoozeSheet = true
+        } label: {
+            Group {
+                if isSnoozed {
+                    HStack(spacing: 5) {
+                        Image(systemName: "bell.slash.fill")
                             .font(.callout)
-                            .foregroundColor(.insulinTintColor)
-                        if let basalString = self.basalString {
-                            /// Adjust opacity when displaying a scheduled basal rate
-                            let opacity = state.apsManager?.isScheduledBasal == true ? 0.6 : 1.0
-                            if basalString.count > 5 {
-                                Text(basalString)
-                                    .font(.callout).fontWeight(.bold).fontDesign(.rounded)
-                                    .lineLimit(1)
-                                    .minimumScaleFactor(0.85)
-                                    .truncationMode(.tail)
-                                    .allowsTightening(true)
-                                    .opacity(opacity)
-                            } else {
-                                // Short strings can just display normally
-                                Text(basalString)
-                                    .font(.callout).fontWeight(.bold).fontDesign(.rounded)
-                                    .opacity(opacity)
-                            }
-                        } else {
-                            Text("No Data")
-                                .font(.callout).fontWeight(.bold).fontDesign(.rounded)
-                        }
+                        Text("\(remainingMinutes) m")
+                            .font(.callout).fontWeight(.bold).fontDesign(.rounded)
                     }
+                    .padding(.vertical, 5)
+                    .padding(.horizontal, 10)
+                    .foregroundStyle(.secondary)
+                    .overlay(
+                        Capsule()
+                            .stroke(Color.primary.opacity(0.4), lineWidth: 2)
+                    )
+                } else {
+                    Image(systemName: "bell.fill")
+                        .font(.callout)
+                        .foregroundStyle(.primary)
+                        .frame(width: 32, height: 32)
+                        .overlay(
+                            Circle()
+                                .stroke(Color.primary.opacity(0.4), lineWidth: 2)
+                        )
                 }
             }
-        }.padding(.horizontal)
+        }
+        .buttonStyle(.plain)
     }
 }

+ 48 - 120
Trio/Sources/Modules/Home/View/HomeRootView.swift

@@ -4,12 +4,6 @@ import SwiftDate
 import SwiftUI
 import Swinject
 
-struct TimePicker: Identifiable {
-    var active: Bool
-    let hours: Int16
-    var id: String { hours.description }
-}
-
 extension Home {
     struct RootView: BaseView {
         let resolver: Resolver
@@ -36,13 +30,8 @@ extension Home {
         @State var showPumpSelection: Bool = false
         @State var showCGMSelection: Bool = false
         @State var showSnoozeSheet: Bool = false
+        @State var alarmsSnoozeUntil: Date = .distantPast
         @State var notificationsDisabled = false
-        @State var timeButtons: [TimePicker] = [
-            TimePicker(active: false, hours: 4),
-            TimePicker(active: false, hours: 6),
-            TimePicker(active: false, hours: 12),
-            TimePicker(active: false, hours: 24)
-        ]
 
         @FetchRequest(fetchRequest: OverrideStored.fetch(
             NSPredicate.lastActiveOverride,
@@ -56,7 +45,7 @@ extension Home {
             fetchLimit: 1
         )) var latestTempTarget: FetchedResults<TempTargetStored>
 
-        private var historySFSymbol: String {
+        var historySFSymbol: String {
             if #available(iOS 17.0, *) {
                 return "book.pages"
             } else {
@@ -64,83 +53,10 @@ extension Home {
             }
         }
 
-        // Returns the scheduled basal rate for the current time based on the saved basal scheduled.
-        // Would be better if in the future BasalDeliveryStatus could be updated to include this info.
-
-        var timeIntervalButtons: some View {
-            let buttonColor = (colorScheme == .dark ? Color.white : Color.black).opacity(0.8)
-
-            return HStack(alignment: .center) {
-                ForEach(timeButtons) { button in
-                    Button(action: {
-                        state.hours = button.hours
-                    }) {
-                        Group {
-                            if button.active {
-                                Text(
-                                    button.hours.description + "\u{00A0}" +
-                                        String(localized: "h", comment: "h")
-                                )
-                            } else {
-                                Text(button.hours.description)
-                            }
-                        }
-                        .font(.footnote)
-                        .fontWeight(button.active ? .semibold : .regular)
-                        .padding(.vertical, 5)
-                        .padding(.horizontal, 10)
-                        .foregroundColor(
-                            button
-                                .active ? (colorScheme == .dark ? Color.bgDarkerDarkBlue : Color.white) : buttonColor
-                        )
-                        .background(button.active ? buttonColor.opacity(colorScheme == .dark ? 1 : 0.8) : Color.clear)
-                        .clipShape(Capsule())
-                        .overlay(
-                            Capsule()
-                                .stroke(button.active ? buttonColor.opacity(0.4) : Color.clear, lineWidth: 2)
-                        )
-                    }
-                }
-            }
-        }
-
-        var statsIconString: String {
-            if #available(iOS 18, *) {
-                return "chart.line.text.clipboard"
-            } else {
-                return "list.clipboard"
-            }
-        }
-
-        @ViewBuilder private func tappableButton(
-            buttonColor: Color,
-            label: String,
-            iconString: String,
-            action: @escaping () -> Void
-        ) -> some View {
-            Button(action: {
-                action()
-            }) {
-                HStack {
-                    Image(systemName: iconString)
-                    Text(label)
-                }
-                .font(.footnote)
-                .padding(.vertical, 5)
-                .padding(.horizontal, 10)
-                .foregroundStyle(buttonColor)
-                .overlay(
-                    Capsule()
-                        .stroke(buttonColor.opacity(0.4), lineWidth: 2)
-                )
-            }
-        }
-
         @ViewBuilder func mainChart(geo: GeometryProxy) -> some View {
             // the chart is the only flexible zone: it takes what the fixed slots leave over
             let chartHeight = max(
-                geo.size.height - HomeLayout.headerHeight - HomeLayout.mealSlotHeight - HomeLayout
-                    .timeButtonsRowHeight - HomeLayout.bottomZoneHeight,
+                geo.size.height - HomeLayout.headerHeight - HomeLayout.mealSlotHeight - HomeLayout.bottomZoneHeight,
                 HomeLayout.chartMinHeight
             )
             ZStack {
@@ -162,12 +78,48 @@ extension Home {
             }
             // enforce the zone budget; panes flex within it
             .frame(height: chartHeight)
+            .overlay(alignment: .bottomTrailing) {
+                chartInfoButton
+                    .offset(x: 0, y: -10)
+            }
+            .overlay(alignment: .topTrailing) {
+                // borderless capsule (not a control); centered in the basal
+                // pane band so it clears the y-axis labels on every device size
+                if let rate = currentBasalRateLabel {
+                    Text(rate)
+                        .font(.system(size: 14, weight: .semibold))
+                        .fontDesign(.rounded)
+                        .foregroundStyle(Color.insulin)
+                        .padding(.horizontal, 8)
+                        .padding(.vertical, 3)
+                        .background(Capsule().fill(.ultraThinMaterial))
+                        .frame(height: chartHeight * 0.10)
+                        .padding(.trailing, 16)
+                }
+            }
+        }
+
+        private var currentBasalRateLabel: String? {
+            guard let rate = state.tempBasals.last?.tempBasal?.rate else { return nil }
+            let value = Formatter.decimalFormatterWithTwoFractionDigits.string(from: rate) ?? "\(rate)"
+            return value + String(localized: " U/hr", comment: "Unit per hour with space")
         }
 
-        func highlightButtons() {
-            for i in 0 ..< timeButtons.count {
-                timeButtons[i].active = timeButtons[i].hours == state.hours
+        @ViewBuilder private var chartInfoButton: some View {
+            Button {
+                state.isLegendPresented.toggle()
+            } label: {
+                Image(systemName: "info")
+                    .font(.system(size: 15, weight: .semibold))
+                    .foregroundStyle(.secondary)
+                    .frame(width: 32, height: 32)
+                    .background(Circle().fill(.ultraThinMaterial))
+                    .overlay(Circle().strokeBorder(Color.primary.opacity(0.12), lineWidth: 1))
             }
+            .contentShape(Circle())
+            .padding(.bottom, 6)
+            // same trailing inset as the alarm bell in the meal row
+            .padding(.trailing, 16)
         }
 
         @ViewBuilder func mainViewElements(_ geo: GeometryProxy) -> some View {
@@ -212,30 +164,6 @@ extension Home {
                 mealPanel().frame(height: HomeLayout.mealSlotHeight)
 
                 mainChart(geo: geo)
-
-                HStack {
-                    tappableButton(
-                        buttonColor: (colorScheme == .dark ? Color.white : Color.black).opacity(0.8),
-                        label: String(localized: "Stats", comment: "Stats icon in main view"),
-                        iconString: statsIconString,
-                        action: { state.showModal(for: .statistics) }
-                    )
-
-                    Spacer()
-
-                    timeIntervalButtons
-
-                    Spacer()
-
-                    tappableButton(
-                        buttonColor: (colorScheme == .dark ? Color.white : Color.black).opacity(0.8),
-                        label: String(localized: "Info", comment: "Info icon in main view"),
-                        iconString: "info",
-                        action: { state.isLegendPresented.toggle() }
-                    )
-                }
-                .padding(.horizontal)
-                .frame(height: HomeLayout.timeButtonsRowHeight)
             }
             // fill the screen; zones stay top-aligned, chart takes the slack
             .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
@@ -263,13 +191,13 @@ extension Home {
                     // fixed zones bust beyond XXL; cap dashboard type size
                     .dynamicTypeSize(...DynamicTypeSize.xxLarge)
             }
-            .onChange(of: state.hours) {
-                highlightButtons()
-            }
             .onAppear {
-                configureView {
-                    highlightButtons()
-                }
+                configureView()
+                refreshAlarmsSnooze()
+            }
+            // UserDefaults changes don't invalidate views; refresh on sheet dismissal
+            .onChange(of: showSnoozeSheet) {
+                if !showSnoozeSheet { refreshAlarmsSnooze() }
             }
             .navigationTitle("Home")
             .navigationBarHidden(true)

+ 44 - 32
Trio/Sources/Modules/Stat/StatStateModel+Setup/GlucoseStatsSetup.swift

@@ -52,6 +52,46 @@ struct GlucoseDailyDistributionStats: Identifiable {
     }
 }
 
+extension GlucoseDailyDistributionStats {
+    /// Pure range-distribution computation shared by Stat and the Home stats banner.
+    static func compute(
+        date: Date,
+        readings: [GlucoseReading],
+        highLimit: Decimal,
+        timeInRangeType: TimeInRangeType
+    ) -> GlucoseDailyDistributionStats {
+        let totalReadings = Double(readings.count)
+
+        let veryHighReadings = readings.filter { $0.value > 250 }.count
+        let highReadings = readings.filter { $0.value > Int(highLimit) && $0.value <= 250 }.count
+        let inRangeReadings = readings.filter { $0.value >= timeInRangeType.bottomThreshold && $0.value <= Int(highLimit) }
+            .count
+        let inSmallRangeReadings = readings
+            .filter { $0.value >= timeInRangeType.bottomThreshold && $0.value <= timeInRangeType.topThreshold }.count
+        let lowReadings = readings.filter { $0.value < timeInRangeType.bottomThreshold && $0.value >= 54 }.count
+        let veryLowReadings = readings.filter { $0.value < 54 }.count
+
+        let veryLowPct = totalReadings > 0 ? Double(veryLowReadings) / totalReadings * 100 : 0
+        let lowPct = totalReadings > 0 ? Double(lowReadings) / totalReadings * 100 : 0
+        let inSmallRangePct = totalReadings > 0 ? Double(inSmallRangeReadings) / totalReadings * 100 : 0
+        let inRangePct = totalReadings > 0 ? Double(inRangeReadings) / totalReadings * 100 : 0
+        let highPct = totalReadings > 0 ? Double(highReadings) / totalReadings * 100 : 0
+        let veryHighPct = totalReadings > 0 ? Double(veryHighReadings) / totalReadings * 100 : 0
+
+        return GlucoseDailyDistributionStats(
+            date: date,
+            timeInRangeType: timeInRangeType,
+            readings: [],
+            veryLowPct: veryLowPct,
+            lowPct: lowPct,
+            inSmallRangePct: inSmallRangePct,
+            inRangePct: inRangePct,
+            highPct: highPct,
+            veryHighPct: veryHighPct
+        )
+    }
+}
+
 /// Represents percentile-based statistical data for daily glucose metrics
 struct GlucoseDailyPercentileStats: Identifiable {
     let id = UUID()
@@ -217,39 +257,11 @@ extension Stat.StateModel {
         highLimit: Decimal,
         timeInRangeType: TimeInRangeType
     ) -> GlucoseDailyDistributionStats {
-        let totalReadings = Double(readings.count)
-
-        // Count readings in each range
-        let veryHighReadings = readings.filter { $0.value > 250 }.count
-        let highReadings = readings.filter { $0.value > Int(highLimit) && $0.value <= 250 }.count
-        let inRangeReadings = readings.filter { $0.value >= timeInRangeType.bottomThreshold && $0.value <= Int(highLimit) }
-            .count
-        let inSmallRangeReadings = readings
-            .filter { $0.value >= timeInRangeType.bottomThreshold && $0.value <= timeInRangeType.topThreshold }.count
-        let lowReadings = readings.filter { $0.value < timeInRangeType.bottomThreshold && $0.value >= 54 }.count
-        let veryLowReadings = readings.filter { $0.value < 54 }.count
-
-        // Calculate percentages
-        let veryLowPct = totalReadings > 0 ? Double(veryLowReadings) / totalReadings * 100 : 0
-        let lowPct = totalReadings > 0 ? Double(lowReadings) / totalReadings * 100 : 0
-        let inSmallRangePct = totalReadings > 0 ? Double(inSmallRangeReadings) / totalReadings * 100 : 0
-        let inRangePct = totalReadings > 0 ? Double(inRangeReadings) / totalReadings * 100 : 0
-        let highPct = totalReadings > 0 ? Double(highReadings) / totalReadings * 100 : 0
-        let veryHighPct = totalReadings > 0 ? Double(veryHighReadings) / totalReadings * 100 : 0
-
-        // Create empty managed object array since we don't need the actual Core Data objects
-        let emptyStoredArray: [GlucoseStored] = []
-
-        return GlucoseDailyDistributionStats(
+        GlucoseDailyDistributionStats.compute(
             date: date,
-            timeInRangeType: timeInRangeType,
-            readings: emptyStoredArray,
-            veryLowPct: veryLowPct,
-            lowPct: lowPct,
-            inSmallRangePct: inSmallRangePct,
-            inRangePct: inRangePct,
-            highPct: highPct,
-            veryHighPct: veryHighPct
+            readings: readings,
+            highLimit: highLimit,
+            timeInRangeType: timeInRangeType
         )
     }
 

+ 2 - 0
Trio/Sources/Modules/UserInterfaceSettings/UserInterfaceSettingsStateModel.swift

@@ -14,6 +14,7 @@ extension UserInterfaceSettings {
         @Published var glucoseColorScheme: GlucoseColorScheme = .staticColor
         @Published var eA1cDisplayUnit: EstimatedA1cDisplayUnit = .percent
         @Published var timeInRangeType: TimeInRangeType = .timeInTightRange
+        @Published var homeStatsPanelFace: HomeStatsPanelFace = .timeInRange
         @Published var requireAdjustmentsConfirmation: Bool = false
 
         var units: GlucoseUnits = .mgdL
@@ -45,6 +46,7 @@ extension UserInterfaceSettings {
             subscribeSetting(\.eA1cDisplayUnit, on: $eA1cDisplayUnit) { eA1cDisplayUnit = $0 }
 
             subscribeSetting(\.timeInRangeType, on: $timeInRangeType) { timeInRangeType = $0 }
+            subscribeSetting(\.homeStatsPanelFace, on: $homeStatsPanelFace) { homeStatsPanelFace = $0 }
 
             subscribeSetting(\.requireAdjustmentsConfirmation, on: $requireAdjustmentsConfirmation) {
                 requireAdjustmentsConfirmation = $0 }

+ 40 - 0
Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift

@@ -543,6 +543,46 @@ extension UserInterfaceSettings {
                     }.padding(.bottom)
                 }.settingsSearchTarget(label: String(localized: "Time in Range Type"))
 
+                Section {
+                    VStack(alignment: .leading) {
+                        Picker(
+                            selection: $state.homeStatsPanelFace,
+                            label: Text("Home Statistics Panel").multilineTextAlignment(.leading)
+                        ) {
+                            ForEach(HomeStatsPanelFace.allCases) { selection in
+                                Text(selection.displayName).tag(selection)
+                            }
+                        }.padding(.top)
+
+                        HStack(alignment: .center) {
+                            Text(
+                                "Choose what the statistics panel on the home screen displays."
+                            )
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                            .lineLimit(nil)
+                            Spacer()
+                            Button(
+                                action: {
+                                    hintLabel = String(localized: "Home Statistics Panel")
+                                    selectedVerboseHint =
+                                        AnyView(
+                                            Text(
+                                                "Choose what the statistics panel on the home screen shows by default. Tapping the panel always opens the full statistics view.\n\nTime in Range: today's time in range percentage with a glucose distribution bar.\n\nDistribution Bar Only: just the glucose distribution bar, without the percentage.\n\nToday's Averages: today's average glucose and GMI (Glucose Management Index)."
+                                            )
+                                        )
+                                    shouldDisplayHint.toggle()
+                                },
+                                label: {
+                                    HStack {
+                                        Image(systemName: "questionmark.circle")
+                                    }
+                                }
+                            ).buttonStyle(BorderlessButtonStyle())
+                        }.padding(.top)
+                    }.padding(.bottom)
+                }.settingsSearchTarget(label: String(localized: "Home Statistics Panel"))
+
                 SettingInputSection(
                     decimalValue: $state.carbsRequiredThreshold,
                     booleanValue: $state.showCarbsRequiredBadge,