Bladeren bron

Add multi-use panel with prioritized states

One bottom slot, highest priority wins: notifications disabled,
pump time mismatch, stale CGM (offers manual glucose entry),
Max IOB at zero (deep-links to the setting), stats default.
Replaces the top safeAreaInset banner and timezone capsule.
dnzxy 4 dagen geleden
bovenliggende
commit
362a149c28

+ 12 - 4
Trio.xcodeproj/project.pbxproj

@@ -68,8 +68,8 @@
 		3811DE3225C9D49500A708ED /* HomeDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3811DE2A25C9D49500A708ED /* HomeDataFlow.swift */; };
 		3811DE3525C9D49500A708ED /* HomeRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3811DE2E25C9D49500A708ED /* HomeRootView.swift */; };
 		7A11AC0DE000000000000111 /* HomeLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11AC0DE000000000000011 /* HomeLayout.swift */; };
+		7A11AC0DE000000000000113 /* MultiUsePanelState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11AC0DE000000000000013 /* MultiUsePanelState.swift */; };
 		7A11AC0DE000000000000110 /* HomeRootView+BottomControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11AC0DE000000000000010 /* HomeRootView+BottomControls.swift */; };
-		7A11AC0DE00000000000010F /* HomeRootView+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11AC0DE00000000000000F /* HomeRootView+Toolbar.swift */; };
 		7A11AC0DE00000000000010E /* HomeRootView+MealPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11AC0DE00000000000000E /* HomeRootView+MealPanel.swift */; };
 		7A11AC0DE00000000000010D /* HomeRootView+Header.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11AC0DE00000000000000D /* HomeRootView+Header.swift */; };
 		3811DE3F25C9D4A100A708ED /* SettingsStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3811DE3925C9D4A100A708ED /* SettingsStateModel.swift */; };
@@ -546,6 +546,7 @@
 		BD8FC0542D66186000B95AED /* TestError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0532D66186000B95AED /* TestError.swift */; };
 		FB9012AE66284A92A6531037 /* NightscoutUploadSerializerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C39FF1EC12CF4B1EBB13BE26 /* NightscoutUploadSerializerTests.swift */; };
 		BD8FC0572D66188700B95AED /* PumpHistoryStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0562D66188700B95AED /* PumpHistoryStorageTests.swift */; };
+		7A11AC0DE000000000000115 /* MultiUsePanelStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11AC0DE000000000000015 /* MultiUsePanelStateTests.swift */; };
 		BD8FC0592D66189700B95AED /* TestAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0582D66189700B95AED /* TestAssembly.swift */; };
 		BD8FC05B2D6618AF00B95AED /* DeterminationStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC05A2D6618AF00B95AED /* DeterminationStorageTests.swift */; };
 		BD8FC05E2D6618CE00B95AED /* BolusCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC05D2D6618CE00B95AED /* BolusCalculatorTests.swift */; };
@@ -759,6 +760,7 @@
 		DD4C581F2D73C43D001BFF2C /* LoopStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4C581E2D73C43D001BFF2C /* LoopStatsView.swift */; };
 		DD4FFF332D458EE600B6CFF9 /* GarminWatchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4FFF322D458EE600B6CFF9 /* GarminWatchState.swift */; };
 		DD500A712807000000000101 /* SnoozeAlertsSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD500A712807000000000100 /* SnoozeAlertsSheetView.swift */; };
+		7A11AC0DE000000000000114 /* ManualGlucoseEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11AC0DE000000000000014 /* ManualGlucoseEntryView.swift */; };
 		DD5DC9F12CF3D97C00AB8703 /* AdjustmentsStateModel+Overrides.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5DC9F02CF3D96E00AB8703 /* AdjustmentsStateModel+Overrides.swift */; };
 		DD5DC9F32CF3D9DD00AB8703 /* AdjustmentsStateModel+TempTargets.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5DC9F22CF3D9D600AB8703 /* AdjustmentsStateModel+TempTargets.swift */; };
 		DD5DC9F72CF3DA9300AB8703 /* TargetPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5DC9F62CF3DA9300AB8703 /* TargetPicker.swift */; };
@@ -1091,8 +1093,8 @@
 		3811DE2A25C9D49500A708ED /* HomeDataFlow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeDataFlow.swift; sourceTree = "<group>"; };
 		3811DE2E25C9D49500A708ED /* HomeRootView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeRootView.swift; sourceTree = "<group>"; };
 		7A11AC0DE000000000000011 /* HomeLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeLayout.swift; sourceTree = "<group>"; };
+		7A11AC0DE000000000000013 /* MultiUsePanelState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiUsePanelState.swift; sourceTree = "<group>"; };
 		7A11AC0DE000000000000010 /* HomeRootView+BottomControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeRootView+BottomControls.swift"; sourceTree = "<group>"; };
-		7A11AC0DE00000000000000F /* HomeRootView+Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeRootView+Toolbar.swift"; sourceTree = "<group>"; };
 		7A11AC0DE00000000000000E /* HomeRootView+MealPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeRootView+MealPanel.swift"; sourceTree = "<group>"; };
 		7A11AC0DE00000000000000D /* HomeRootView+Header.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeRootView+Header.swift"; sourceTree = "<group>"; };
 		3811DE3925C9D4A100A708ED /* SettingsStateModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsStateModel.swift; sourceTree = "<group>"; };
@@ -1559,6 +1561,7 @@
 		BD8FC0532D66186000B95AED /* TestError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestError.swift; sourceTree = "<group>"; };
 		C39FF1EC12CF4B1EBB13BE26 /* NightscoutUploadSerializerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutUploadSerializerTests.swift; sourceTree = "<group>"; };
 		BD8FC0562D66188700B95AED /* PumpHistoryStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpHistoryStorageTests.swift; sourceTree = "<group>"; };
+		7A11AC0DE000000000000015 /* MultiUsePanelStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiUsePanelStateTests.swift; sourceTree = "<group>"; };
 		BD8FC0582D66189700B95AED /* TestAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAssembly.swift; sourceTree = "<group>"; };
 		BD8FC05A2D6618AF00B95AED /* DeterminationStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeterminationStorageTests.swift; sourceTree = "<group>"; };
 		BD8FC05D2D6618CE00B95AED /* BolusCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusCalculatorTests.swift; sourceTree = "<group>"; };
@@ -1777,6 +1780,7 @@
 		DD4C581E2D73C43D001BFF2C /* LoopStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopStatsView.swift; sourceTree = "<group>"; };
 		DD4FFF322D458EE600B6CFF9 /* GarminWatchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminWatchState.swift; sourceTree = "<group>"; };
 		DD500A712807000000000100 /* SnoozeAlertsSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnoozeAlertsSheetView.swift; sourceTree = "<group>"; };
+		7A11AC0DE000000000000014 /* ManualGlucoseEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualGlucoseEntryView.swift; sourceTree = "<group>"; };
 		DD5DC9F02CF3D96E00AB8703 /* AdjustmentsStateModel+Overrides.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AdjustmentsStateModel+Overrides.swift"; sourceTree = "<group>"; };
 		DD5DC9F22CF3D9D600AB8703 /* AdjustmentsStateModel+TempTargets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AdjustmentsStateModel+TempTargets.swift"; sourceTree = "<group>"; };
 		DD5DC9F62CF3DA9300AB8703 /* TargetPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TargetPicker.swift; sourceTree = "<group>"; };
@@ -2392,8 +2396,8 @@
 			children = (
 				3811DE2E25C9D49500A708ED /* HomeRootView.swift */,
 				7A11AC0DE000000000000011 /* HomeLayout.swift */,
+				7A11AC0DE000000000000013 /* MultiUsePanelState.swift */,
 				7A11AC0DE000000000000010 /* HomeRootView+BottomControls.swift */,
-				7A11AC0DE00000000000000F /* HomeRootView+Toolbar.swift */,
 				7A11AC0DE00000000000000E /* HomeRootView+MealPanel.swift */,
 				7A11AC0DE00000000000000D /* HomeRootView+Header.swift */,
 				3833B51E260264AC003021B3 /* Chart */,
@@ -2691,6 +2695,7 @@
 				48B83503461B4F8D97B30115 /* SettingsSearchHighlight.swift */,
 				DD17452A2C556E8100211FAC /* SettingInputHintView.swift */,
 				DD500A712807000000000100 /* SnoozeAlertsSheetView.swift */,
+				7A11AC0DE000000000000014 /* ManualGlucoseEntryView.swift */,
 			);
 			path = Views;
 			sourceTree = "<group>";
@@ -3678,6 +3683,7 @@
 			isa = PBXGroup;
 			children = (
 				BD8FC0562D66188700B95AED /* PumpHistoryStorageTests.swift */,
+				7A11AC0DE000000000000015 /* MultiUsePanelStateTests.swift */,
 				BD8FC0582D66189700B95AED /* TestAssembly.swift */,
 				BD8FC05A2D6618AF00B95AED /* DeterminationStorageTests.swift */,
 				BD8FC05F2D6619DB00B95AED /* CarbsStorageTests.swift */,
@@ -5077,6 +5083,7 @@
 				AC19EF2C94084B5BA0175D1D /* SettingsSearchHighlight.swift in Sources */,
 				DD17452B2C556E8100211FAC /* SettingInputHintView.swift in Sources */,
 				DD500A712807000000000101 /* SnoozeAlertsSheetView.swift in Sources */,
+				7A11AC0DE000000000000114 /* ManualGlucoseEntryView.swift in Sources */,
 				38E44528274E401C00EC9A94 /* Protected.swift in Sources */,
 				DD3F1F8B2D9E08B600DCE7B3 /* NightscoutLoginStepView.swift in Sources */,
 				3811DEB625C9D88300A708ED /* UnlockManager.swift in Sources */,
@@ -5173,8 +5180,8 @@
 				CEE9A6592BBB418300EB5194 /* CalibrationsDataFlow.swift in Sources */,
 				3811DE3525C9D49500A708ED /* HomeRootView.swift in Sources */,
 				7A11AC0DE000000000000111 /* HomeLayout.swift in Sources */,
+				7A11AC0DE000000000000113 /* MultiUsePanelState.swift in Sources */,
 				7A11AC0DE000000000000110 /* HomeRootView+BottomControls.swift in Sources */,
-				7A11AC0DE00000000000010F /* HomeRootView+Toolbar.swift in Sources */,
 				7A11AC0DE00000000000010E /* HomeRootView+MealPanel.swift in Sources */,
 				7A11AC0DE00000000000010D /* HomeRootView+Header.swift in Sources */,
 				584C79A91D034426A1DE60CB /* SlideToConfirmView.swift in Sources */,
@@ -5668,6 +5675,7 @@
 				3BEF6AB32D97316F0076089D /* MealTotalTests.swift in Sources */,
 				3BEF6AB12D9731660076089D /* MealHistoryTests.swift in Sources */,
 				BD8FC0572D66188700B95AED /* PumpHistoryStorageTests.swift in Sources */,
+				7A11AC0DE000000000000115 /* MultiUsePanelStateTests.swift in Sources */,
 				BD8FC0642D6619EF00B95AED /* TempTargetStorageTests.swift in Sources */,
 				3BE2F1E82E030E2F009E2900 /* MealCobTests.swift in Sources */,
 				3BBC22632DF5B94100169236 /* AutosensTests.swift in Sources */,

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

@@ -33,6 +33,11 @@ extension Home.StateModel {
 }
 
 extension Home.StateModel {
+    func addManualGlucose(_ amount: Decimal) {
+        let glucose = units == .mmolL ? amount.asMgdL : amount
+        glucoseStorage.addManualGlucose(glucose: Int(glucose))
+    }
+
     /// Today's glucose range distribution for the stats banner.
     var todayGlucoseDistribution: GlucoseDailyDistributionStats {
         let startOfDay = Calendar.current.startOfDay(for: Date())

+ 117 - 1
Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift

@@ -646,6 +646,122 @@ extension Home.RootView {
         .buttonStyle(.plain)
     }
 
+    var multiUsePanelState: MultiUsePanelState {
+        MultiUsePanelState.resolve(
+            notificationsDisabled: notificationsDisabled,
+            pumpTimeMismatch: state.pumpStatusBadgeImage != nil,
+            lastGlucoseDate: state.glucoseFromPersistence.last?.date,
+            maxIOB: state.maxIOB,
+            now: state.timerDate
+        )
+    }
+
+    /// Shared chrome for the non-stats panel states.
+    @ViewBuilder func panelBanner(
+        systemImage: String,
+        title: String,
+        subtitle: String,
+        tint: Color,
+        isCritical: Bool = false,
+        action: @escaping () -> Void
+    ) -> some View {
+        Button(action: action) {
+            ZStack {
+                RoundedRectangle(cornerRadius: 17, style: .continuous)
+                    .fill(.ultraThinMaterial)
+                    .overlay(
+                        RoundedRectangle(cornerRadius: 17, style: .continuous)
+                            .fill(tint.opacity(isCritical ? 0.30 : 0.12))
+                    )
+                    .overlay(
+                        RoundedRectangle(cornerRadius: 17, style: .continuous)
+                            .strokeBorder(tint.opacity(isCritical ? 0.8 : 0.35), lineWidth: isCritical ? 1.5 : 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(spacing: 12) {
+                    adjustmentIcon(systemImage, tint: tint)
+
+                    VStack(alignment: .leading, spacing: 1) {
+                        Text(title)
+                            .font(.subheadline).fontWeight(.semibold)
+                            .foregroundStyle(.primary)
+                        Text(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)
+        }
+        .buttonStyle(.plain)
+    }
+
+    /// One slot, highest-priority state wins; stats is the default face.
+    @ViewBuilder func multiUsePanel() -> some View {
+        switch multiUsePanelState {
+        case .notificationsDisabled:
+            panelBanner(
+                systemImage: "bell.slash.fill",
+                title: String(localized: "Notifications Disabled"),
+                subtitle: String(localized: "Alarms cannot alert you. Tap to fix."),
+                tint: .red,
+                isCritical: true
+            ) {
+                UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)
+            }
+        case .pumpTimeMismatch:
+            panelBanner(
+                systemImage: "clock.badge.exclamationmark.fill",
+                title: String(localized: "Time Change Detected"),
+                subtitle: String(localized: "Pump clock differs from phone. Tap to review."),
+                tint: .orange
+            ) {
+                if state.pumpDisplayState != nil {
+                    state.shouldDisplayPumpSetupSheet.toggle()
+                }
+            }
+        case .cgmStale:
+            panelBanner(
+                systemImage: "drop.fill",
+                title: String(localized: "No Recent Glucose"),
+                subtitle: String(localized: "Tap to add a fingerstick reading."),
+                tint: .orange
+            ) {
+                showManualGlucose = true
+            }
+        case .maxIOBZero:
+            panelBanner(
+                systemImage: "exclamationmark.triangle.fill",
+                title: String(localized: "Max IOB is 0 U"),
+                subtitle: String(localized: "Automated dosing is limited. Tap to review."),
+                tint: .orange
+            ) {
+                openMaxIOBSetting()
+            }
+        case .stats:
+            statsBanner()
+        }
+    }
+
+    func openMaxIOBSetting() {
+        // same target search results push, so scroll + highlight wiggle match
+        selectedTab = 3
+        settingsPath.append(SearchResultTarget(
+            screen: .unitsAndLimits,
+            scrollLabel: "Maximum Insulin on Board (IOB)".localized
+        ))
+    }
+
     /// Bottom-anchored fixed zone: adjustment/bolus panel above the stats banner.
     @ViewBuilder func bottomControls() -> some View {
         VStack(spacing: HomeLayout.bottomZonePadding) {
@@ -659,7 +775,7 @@ extension Home.RootView {
             .frame(height: HomeLayout.bottomPanelHeight)
             .animation(.easeInOut(duration: 0.2), value: state.bolusProgress != nil)
 
-            statsBanner()
+            multiUsePanel()
                 .frame(height: HomeLayout.statsBannerHeight)
         }
         .padding(.vertical, HomeLayout.bottomZonePadding)

+ 0 - 77
Trio/Sources/Modules/Home/View/HomeRootView+Toolbar.swift

@@ -1,77 +0,0 @@
-import SwiftUI
-import UIKit
-
-// MARK: - Toolbar: conditional warning items (notifications off, pump timezone)
-
-extension Home.RootView {
-    @ViewBuilder func pumpTimezoneView(_ badgeImage: UIImage, _ badgeColor: Color) -> some View {
-        HStack {
-            Image(uiImage: badgeImage.withRenderingMode(.alwaysTemplate))
-                .font(.system(size: 14))
-                .colorMultiply(badgeColor)
-            Text(String(localized: "Time Change Detected", comment: ""))
-                .bold()
-                .font(.system(size: 14))
-                .foregroundStyle(badgeColor)
-        }
-        .onTapGesture {
-            if state.pumpDisplayState != nil {
-                // sends user to pump settings
-                state.shouldDisplayPumpSetupSheet.toggle()
-            }
-        }
-        .frame(maxWidth: .infinity, alignment: .center)
-        .padding(.vertical, 5)
-        .padding(.horizontal, 10)
-        .overlay(
-            Capsule()
-                .stroke(badgeColor.opacity(0.4), lineWidth: 2)
-        )
-    }
-
-    @ViewBuilder func alertSafetyNotificationsView(geo: GeometryProxy) -> some View {
-        ZStack {
-            /// rectangle as background
-            RoundedRectangle(cornerRadius: 15)
-                .fill(
-                    Color(
-                        red: 0.9,
-                        green: 0.133333333,
-                        blue: 0.2156862745
-                    )
-                )
-                .clipShape(RoundedRectangle(cornerRadius: 15))
-                .frame(height: geo.size.height * 0.08)
-                .coordinateSpace(name: "alertSafetyNotificationsView")
-                .shadow(
-                    color: colorScheme == .dark ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) :
-                        Color.black.opacity(0.33),
-                    radius: 3
-                )
-            HStack {
-                Spacer()
-                VStack {
-                    Text("⚠️ Safety Notifications are OFF")
-                        .font(.headline)
-                        .fontWeight(.bold)
-                        .fontDesign(.rounded)
-                        .foregroundStyle(.white.gradient)
-                        .frame(maxWidth: .infinity, alignment: .leading)
-                    Text("Fix now by turning Notifications ON.")
-                        .font(.footnote)
-                        .fontDesign(.rounded)
-                        .foregroundStyle(.white.gradient)
-                        .frame(maxWidth: .infinity, alignment: .leading)
-                }.padding(.leading, 5)
-                Spacer()
-                Image(systemName: "chevron.right").foregroundColor(.white)
-                    .font(.headline)
-            }.padding(.horizontal, 10)
-                .padding(.trailing, 8)
-                .onTapGesture {
-                    UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)
-                }
-        }.padding(.horizontal, 10)
-            .padding(.top, 0)
-    }
-}

+ 8 - 13
Trio/Sources/Modules/Home/View/HomeRootView.swift

@@ -30,6 +30,7 @@ extension Home {
         @State var showPumpSelection: Bool = false
         @State var showCGMSelection: Bool = false
         @State var showSnoozeSheet: Bool = false
+        @State var showManualGlucose: Bool = false
         @State var alarmsSnoozeUntil: Date = .distantPast
         @State var notificationsDisabled = false
 
@@ -148,18 +149,6 @@ extension Home {
                 }
                 // fixed slot: header state changes never reflow the zones below
                 .frame(height: HomeLayout.headerHeight)
-                // layout-inert warnings; the multi-use panel absorbs these later
-                .overlay(alignment: .top) {
-                    VStack(spacing: 4) {
-                        if notificationsDisabled {
-                            alertSafetyNotificationsView(geo: geo)
-                        }
-                        if let badgeImage = state.pumpStatusBadgeImage, let badgeColor = state.pumpStatusBadgeColor {
-                            pumpTimezoneView(badgeImage, badgeColor)
-                                .padding(.horizontal, 20)
-                        }
-                    }
-                }
 
                 mealPanel().frame(height: HomeLayout.mealSlotHeight)
 
@@ -211,6 +200,11 @@ extension Home {
             .sheet(isPresented: $showSnoozeSheet) {
                 SnoozeAlertsSheetView(resolver: resolver, isPresented: $showSnoozeSheet)
             }
+            .sheet(isPresented: $showManualGlucose) {
+                ManualGlucoseEntryView(units: state.units, isPresented: $showManualGlucose) { amount in
+                    state.addManualGlucose(amount)
+                }
+            }
             // PUMP RELATED
             .confirmationDialog("Pump Model", isPresented: $showPumpSelection) {
                 Button("Medtronic") { state.addPump(.minimed) }
@@ -349,7 +343,8 @@ extension Home {
                     }
             }.ignoresSafeArea(.keyboard, edges: .bottom).blur(radius: state.waitForSuggestion ? 8 : 0)
                 .onChange(of: selectedTab) {
-                    if !settingsPath.isEmpty {
+                    // reset only when leaving Settings; programmatic pushes survive the switch
+                    if selectedTab != 3, !settingsPath.isEmpty {
                         settingsPath = NavigationPath()
                     }
                 }

+ 27 - 0
Trio/Sources/Modules/Home/View/MultiUsePanelState.swift

@@ -0,0 +1,27 @@
+import Foundation
+
+/// Content states of the Home multi-use panel; highest priority wins.
+enum MultiUsePanelState: Equatable {
+    case notificationsDisabled
+    case pumpTimeMismatch
+    case cgmStale
+    case maxIOBZero
+    case stats
+
+    /// readings older than this offer manual glucose entry
+    static let cgmStaleAfter: TimeInterval = 12 * 60
+
+    static func resolve(
+        notificationsDisabled: Bool,
+        pumpTimeMismatch: Bool,
+        lastGlucoseDate: Date?,
+        maxIOB: Decimal,
+        now: Date
+    ) -> MultiUsePanelState {
+        if notificationsDisabled { return .notificationsDisabled }
+        if pumpTimeMismatch { return .pumpTimeMismatch }
+        if now.timeIntervalSince(lastGlucoseDate ?? .distantPast) > cgmStaleAfter { return .cgmStale }
+        if maxIOB <= 0 { return .maxIOBZero }
+        return .stats
+    }
+}

+ 79 - 0
Trio/Sources/Views/ManualGlucoseEntryView.swift

@@ -0,0 +1,79 @@
+import SwiftUI
+
+/// Manual (finger-stick) glucose entry sheet; mirrors History's add-glucose
+/// form so both entry points look identical.
+struct ManualGlucoseEntryView: View {
+    let units: GlucoseUnits
+    @Binding var isPresented: Bool
+    let onSave: (Decimal) -> Void
+
+    @State private var amount: Decimal = 0
+
+    @Environment(\.colorScheme) private var colorScheme
+    @Environment(AppState.self) private var appState
+
+    private var formatter: NumberFormatter {
+        let formatter = NumberFormatter()
+        formatter.numberStyle = .decimal
+        if units == .mgdL {
+            formatter.maximumIntegerDigits = 3
+            formatter.maximumFractionDigits = 0
+        } else {
+            formatter.maximumIntegerDigits = 2
+            formatter.minimumFractionDigits = 0
+            formatter.maximumFractionDigits = 1
+        }
+        formatter.roundingMode = .halfUp
+        return formatter
+    }
+
+    var body: some View {
+        let limitLow: Decimal = units == .mgdL ? Decimal(14) : 14.asMmolL
+        let limitHigh: Decimal = units == .mgdL ? Decimal(720) : 720.asMmolL
+
+        NavigationView {
+            VStack {
+                Form {
+                    Section {
+                        HStack {
+                            Text("New Glucose")
+                            TextFieldWithToolBar(
+                                text: $amount,
+                                placeholder: " ... ",
+                                keyboardType: units == .mgdL ? .numberPad : .decimalPad,
+                                numberFormatter: formatter,
+                                initialFocus: true,
+                                unitsText: units.rawValue
+                            )
+                        }
+                    }.listRowBackground(Color.chart)
+
+                    Section {
+                        HStack {
+                            Button {
+                                onSave(amount)
+                                isPresented = false
+                            }
+                            label: { Text("Save") }
+                                .frame(maxWidth: .infinity, alignment: .center)
+                                .disabled(amount < limitLow || amount > limitHigh)
+                        }
+                    }
+                    .listRowBackground(
+                        amount < limitLow || amount > limitHigh ? Color(.systemGray4) : Color(.systemBlue)
+                    )
+                    .tint(.white)
+                }.scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
+            }
+            .navigationTitle("Add Glucose")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .topBarLeading) {
+                    Button("Close") {
+                        isPresented = false
+                    }
+                }
+            }
+        }
+    }
+}

+ 58 - 0
TrioTests/CoreDataTests/MultiUsePanelStateTests.swift

@@ -0,0 +1,58 @@
+import Foundation
+import Testing
+
+@testable import Trio
+
+@Suite("Multi-Use Panel State Tests") struct MultiUsePanelStateTests {
+    private let now = Date(timeIntervalSinceReferenceDate: 800_000_000)
+    private var fresh: Date { now.addingTimeInterval(-5 * 60) }
+    private var stale: Date { now.addingTimeInterval(-20 * 60) }
+
+    private func resolve(
+        notificationsDisabled: Bool = false,
+        pumpTimeMismatch: Bool = false,
+        lastGlucoseDate: Date?,
+        maxIOB: Decimal = 10
+    ) -> MultiUsePanelState {
+        MultiUsePanelState.resolve(
+            notificationsDisabled: notificationsDisabled,
+            pumpTimeMismatch: pumpTimeMismatch,
+            lastGlucoseDate: lastGlucoseDate,
+            maxIOB: maxIOB,
+            now: now
+        )
+    }
+
+    @Test("All healthy shows stats") func testStatsDefault() {
+        #expect(resolve(lastGlucoseDate: fresh) == .stats)
+    }
+
+    @Test("Missing notifications outranks everything") func testNotificationsTop() {
+        #expect(resolve(
+            notificationsDisabled: true,
+            pumpTimeMismatch: true,
+            lastGlucoseDate: nil,
+            maxIOB: 0
+        ) == .notificationsDisabled)
+    }
+
+    @Test("Pump time mismatch outranks CGM and MaxIOB") func testTimeMismatchSecond() {
+        #expect(resolve(pumpTimeMismatch: true, lastGlucoseDate: nil, maxIOB: 0) == .pumpTimeMismatch)
+    }
+
+    @Test("Stale glucose outranks MaxIOB") func testCgmStaleThird() {
+        #expect(resolve(lastGlucoseDate: stale, maxIOB: 0) == .cgmStale)
+    }
+
+    @Test("No glucose at all counts as stale") func testNoGlucoseIsStale() {
+        #expect(resolve(lastGlucoseDate: nil) == .cgmStale)
+    }
+
+    @Test("Fresh glucose within threshold is not stale") func testFreshGlucose() {
+        #expect(resolve(lastGlucoseDate: now.addingTimeInterval(-11 * 60)) == .stats)
+    }
+
+    @Test("MaxIOB zero shows its warning") func testMaxIOBZero() {
+        #expect(resolve(lastGlucoseDate: fresh, maxIOB: 0) == .maxIOBZero)
+    }
+}