Преглед изворни кода

Merge branch 'dev' into feat/dev-omnipodkit

Deniz Cengiz пре 3 дана
родитељ
комит
2544894369
58 измењених фајлова са 1980 додато и 137 уклоњено
  1. 1 1
      Config.xcconfig
  2. 63 2
      PRIVACY_POLICY.md
  3. 88 0
      Trio.xcodeproj/project.pbxproj
  4. 1 1
      Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved
  5. 12 0
      Trio/Sources/Application/AppDelegate.swift
  6. 30 0
      Trio/Sources/Application/TrioApp.swift
  7. 1 0
      Trio/Sources/Assemblies/ServiceAssembly.swift
  8. 26 0
      Trio/Sources/Helpers/PropertyPersistentFlags.swift
  9. 9 1
      Trio/Sources/Helpers/PropertyWrappers/PersistedProperty.swift
  10. 32 0
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  11. 4 0
      Trio/Sources/Logger/Logger.swift
  12. 3 3
      Trio/Sources/Models/TrioSettings.swift
  13. 1 0
      Trio/Sources/Modules/AlgorithmAdvancedSettings/View/AlgorithmAdvancedSettingsRootView.swift
  14. 30 11
      Trio/Sources/Modules/AppDiagnostics/AppDiagnosticsStateModel.swift
  15. 19 8
      Trio/Sources/Modules/AppDiagnostics/View/AppDiagnosticsRootView.swift
  16. 1 0
      Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift
  17. 1 0
      Trio/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift
  18. 1 0
      Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift
  19. 1 0
      Trio/Sources/Modules/CalendarEventSettings/View/CalendarEventSettingsRootView.swift
  20. 9 2
      Trio/Sources/Modules/DynamicSettings/View/DynamicSettingsRootView.swift
  21. 1 0
      Trio/Sources/Modules/GeneralSettings/View/UnitsLimitsSettingsRootView.swift
  22. 1 0
      Trio/Sources/Modules/GlucoseNotificationSettings/View/GlucoseNotificationSettingsRootView.swift
  23. 2 0
      Trio/Sources/Modules/Home/View/HomeRootView.swift
  24. 1 0
      Trio/Sources/Modules/LiveActivitySettings/View/LiveActivitySettingsRootView.swift
  25. 1 0
      Trio/Sources/Modules/MealSettings/View/MealSettingsRootView.swift
  26. 1 0
      Trio/Sources/Modules/NightscoutConfig/View/NightscoutFetchView.swift
  27. 1 0
      Trio/Sources/Modules/NightscoutConfig/View/NightscoutUploadView.swift
  28. 27 12
      Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift
  29. 1 1
      Trio/Sources/Modules/Onboarding/View/OnboardingRootView.swift
  30. 22 11
      Trio/Sources/Modules/Onboarding/View/OnboardingSteps/DiagnosticsStepView.swift
  31. 47 3
      Trio/Sources/Modules/Onboarding/View/OnboardingView+Util.swift
  32. 10 2
      Trio/Sources/Modules/Onboarding/View/TherapySettingEditorView.swift
  33. 1 0
      Trio/Sources/Modules/RemoteControlConfig/View/RemoteControlConfig.swift
  34. 1 0
      Trio/Sources/Modules/SMBSettings/View/SMBSettingsRootView.swift
  35. 66 2
      Trio/Sources/Modules/Settings/SettingItems.swift
  36. 19 8
      Trio/Sources/Modules/Settings/View/SettingsRootView.swift
  37. 2 2
      Trio/Sources/Modules/ShortcutsConfig/ShortcutsConfigStateModel.swift
  38. 1 0
      Trio/Sources/Modules/ShortcutsConfig/View/ShortcutsConfigView.swift
  39. 1 0
      Trio/Sources/Modules/TargetBehavoir/View/TargetBehavoirRootView.swift
  40. 5 0
      Trio/Sources/Modules/Telemetry/TelemetryDataFlow.swift
  41. 3 0
      Trio/Sources/Modules/Telemetry/TelemetryProvider.swift
  42. 9 0
      Trio/Sources/Modules/Telemetry/TelemetryStateModel.swift
  43. 145 0
      Trio/Sources/Modules/Telemetry/View/TelemetryMigrationSheetView.swift
  44. 84 0
      Trio/Sources/Modules/Telemetry/View/TelemetryPreviewView.swift
  45. 54 0
      Trio/Sources/Modules/Telemetry/View/TelemetryPrivacyView.swift
  46. 9 8
      Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift
  47. 1 0
      Trio/Sources/Modules/WatchConfig/View/WatchConfigAppleWatchView.swift
  48. 106 0
      Trio/Sources/Services/BolusSafety/BolusSafetyValidator.swift
  49. 22 45
      Trio/Sources/Services/RemoteControl/TrioRemoteControl+Bolus.swift
  50. 1 3
      Trio/Sources/Services/RemoteControl/TrioRemoteControl.swift
  51. 338 0
      Trio/Sources/Services/Telemetry/TelemetryAttestor.swift
  52. 369 0
      Trio/Sources/Services/Telemetry/TelemetryClient.swift
  53. 1 0
      Trio/Sources/Shortcuts/BaseIntentsRequest.swift
  54. 37 10
      Trio/Sources/Shortcuts/Bolus/BolusIntentRequest.swift
  55. 2 1
      Trio/Sources/Views/SettingInputSection.swift
  56. 73 0
      Trio/Sources/Views/SettingsSearchHighlight.swift
  57. 113 0
      TrioTests/BolusSafetyTests/BolusSafetyValidatorTests.swift
  58. 69 0
      TrioTests/SettingsSearchTests.swift

+ 1 - 1
Config.xcconfig

@@ -19,7 +19,7 @@ TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group
 
 // The developers set the version numbers, please leave them alone
 APP_VERSION = 0.7.0
-APP_DEV_VERSION = 0.7.0.17
+APP_DEV_VERSION = 0.7.0.24
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 

+ 63 - 2
PRIVACY_POLICY.md

@@ -34,6 +34,60 @@ The following information may be sent to Crashlytics when Trio crashes:
 - Device model and OS version (example: "iPhone 14 Pro running iOS 17.4.1")
 - A generated unique identifier (a random code like "A7B2C9D3" that doesn't identify you personally)
 
+### Anonymous Usage Telemetry (Opt-In by default, with ability to Opt-Out)
+
+Trio can periodically send a small anonymous usage report to a
+self-hosted telemetry endpoint operated by the Trio team. No
+third-party analytics service is involved. You are asked about this
+choice during onboarding (alongside crash reporting); existing users
+upgrading from a pre-telemetry build are prompted once on the first
+app launch after the update. You can change your choice at any time
+in Settings → App Diagnostics, and you can inspect the exact JSON
+that would be sent under "What's sent" on that same screen.
+
+Telemetry requests are authenticated with Apple App Attest. This
+means Apple cryptographically vouches for the fact that the request
+came from a genuine, unmodified copy of Trio running on a real
+Apple device. App Attest does not transmit any personal data,
+device identifiers, or location information; it produces a one-way
+attestation that the server validates with Apple. Devices that do
+not support App Attest (e.g. the iOS Simulator) silently skip
+sending telemetry.
+
+The diagnostics-sharing selection offers three options:
+
+- **Enable Full Sharing** — crash reports AND anonymous usage telemetry.
+- **Crash Reports Only** — crash reports, no usage telemetry.
+- **Disable Sharing** — neither.
+
+The following information is included in the telemetry payload:
+
+- App version, build date, branch, and commit SHA
+- Whether the build is a TestFlight or App Store / sideload build
+- An Apple-supplied per-vendor identifier (IDFV) and a per-install UUID
+- Device hardware identifier (e.g. "iPhone15,2"), platform, and iOS version
+- The paired pump model (when a pump is configured)
+- The paired CGM type and model (when a CGM is configured)
+- Whether Nightscout, Tidepool, and Apple Health are configured (yes/no — no URLs, tokens, or credentials)
+- A small set of preference flags: units (mg/dL or mmol/L), closed-loop
+  on/off, Live Activity enabled, calendar integration enabled
+- A rolling 7-day count of how often the app was cold-launched
+- The commit SHAs of pinned submodules (e.g. LoopKit, OmniBLE)
+
+The payload sends once every 24 hours while the app is running, plus
+once after a new build is installed. Sending failures simply retry on
+the next launch or scheduler tick — there is no continued retry.
+
+### What Telemetry Does NOT Include
+
+- Glucose readings, insulin doses, carb entries, or any therapy data
+- Therapy settings (basal rates, ISF, carb ratio, glucose targets, max bolus, max basal)
+- Your Nightscout URL or API token
+- Your Tidepool email, password, or session token
+- Remote-command secrets or APNS keys
+- Time zone or location
+- App logs — log sharing remains a separate, user-initiated flow under Settings
+
 ### Debug Symbols (dSYMs)
 
 When we build the Trio app, we create special files called debug
@@ -77,12 +131,19 @@ and handle any data responsibly.
 
 ## Opting Out and Data Retention
 
-You can opt out of crash reporting at any time through the Trio
-settings. If you opt out:
+You can opt out of crash reporting and/or anonymous usage telemetry
+at any time through Settings → App Diagnostics in Trio. The three
+options ("Enable Full Sharing", "Crash Reports Only", "Disable
+Sharing") apply to both data streams. If you opt out of crash
+reporting:
 
 - No new crash data will be collected or sent to us
 - Previously collected crash data will still be retained for approximately 90 days
 
+If you opt out of anonymous usage telemetry, no new telemetry data
+will be collected or sent. Previously sent telemetry rows are retained
+on the Trio team's telemetry endpoint per its own retention policy.
+
 To avoid sending dSYMs to Crashlytics, you can delete the Trio target
 Build Phase script, titled "Copy dSYMs to Crashlytics".
 

+ 88 - 0
Trio.xcodeproj/project.pbxproj

@@ -260,6 +260,7 @@
 		3BD9687C2D8DDD4600899469 /* SlideButton in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687B2D8DDD4600899469 /* SlideButton */; };
 		3BD9687F2D8DDD8800899469 /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687E2D8DDD8800899469 /* CryptoSwift */; };
 		3BF85FE32E427312000D7351 /* IOBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF85FE12E427312000D7351 /* IOBService.swift */; };
+		B015AFE32E500000000D7351 /* BolusSafetyValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B015AFE12E500000000D7351 /* BolusSafetyValidator.swift */; };
 		3E28F2AB2EB5337F00FB9EEB /* ConnectIQ in Frameworks */ = {isa = PBXBuildFile; productRef = 3E28F2AA2EB5337F00FB9EEB /* ConnectIQ */; };
 		3E54EF2C2E476DA40006F54D /* MedtrumKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */; };
 		3E54EF2D2E476DA40006F54D /* MedtrumKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
@@ -346,6 +347,7 @@
 		9702FF92A09C53942F20D7EA /* TargetsEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DD795BA46B193644D48138C /* TargetsEditorRootView.swift */; };
 		9825E5E923F0B8FA80C8C7C7 /* NightscoutConfigStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0A48AE3AC813A49A517846A /* NightscoutConfigStateModel.swift */; };
 		98641AF4F92123DA668AB931 /* CarbRatioEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BDC6993C1087310EDFC428 /* CarbRatioEditorRootView.swift */; };
+		41740E936552456AAC0EDAC3 /* SettingsSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */; };
 		A33352ED40476125EBAC6EE0 /* CarbRatioEditorDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E22146D3DF4853786C78132 /* CarbRatioEditorDataFlow.swift */; };
 		AD3D2CD42CD01B9EB8F26522 /* PumpConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF65DA88F972B56090AD6AC3 /* PumpConfigDataFlow.swift */; };
 		B6E925132EB3932A0076D719 /* OmnipodKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B6E925122EB3932A0076D719 /* OmnipodKit.framework */; };
@@ -416,6 +418,7 @@
 		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 */; };
+		B015AFE52E500000000D7351 /* BolusSafetyValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B015AFE42E500000000D7351 /* BolusSafetyValidatorTests.swift */; };
 		BD8FC0602D6619DB00B95AED /* CarbsStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC05F2D6619DB00B95AED /* CarbsStorageTests.swift */; };
 		BD8FC0622D6619E600B95AED /* OverrideStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0612D6619E600B95AED /* OverrideStorageTests.swift */; };
 		BD8FC0642D6619EF00B95AED /* TempTargetStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0632D6619EF00B95AED /* TempTargetStorageTests.swift */; };
@@ -549,6 +552,7 @@
 		DD1745242C55526000211FAC /* SMBSettingsStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1745232C55526000211FAC /* SMBSettingsStateModel.swift */; };
 		DD1745262C55526F00211FAC /* SMBSettingsRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1745252C55526F00211FAC /* SMBSettingsRootView.swift */; };
 		DD1745292C55642100211FAC /* SettingInputSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1745282C55642100211FAC /* SettingInputSection.swift */; };
+		AC19EF2C94084B5BA0175D1D /* SettingsSearchHighlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48B83503461B4F8D97B30115 /* SettingsSearchHighlight.swift */; };
 		DD17452B2C556E8100211FAC /* SettingInputHintView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD17452A2C556E8100211FAC /* SettingInputHintView.swift */; };
 		DD17452E2C55AE4800211FAC /* TargetBehavoirDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD17452D2C55AE4800211FAC /* TargetBehavoirDataFlow.swift */; };
 		DD1745302C55AE5300211FAC /* TargetBehaviorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD17452F2C55AE5300211FAC /* TargetBehaviorProvider.swift */; };
@@ -621,6 +625,14 @@
 		DD73FA0F2D74F58E00D19D1E /* BackgroundTask+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD73FA0E2D74F57300D19D1E /* BackgroundTask+Helper.swift */; };
 		DD8262CB2D289297009F6F62 /* BolusConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8262CA2D289297009F6F62 /* BolusConfirmationView.swift */; };
 		DD82D4B82DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD82D4B72DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift */; };
+		DD7E1E300000000000000002 /* TelemetryClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000001 /* TelemetryClient.swift */; };
+		DD7E1E300000000000000014 /* TelemetryAttestor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000013 /* TelemetryAttestor.swift */; };
+		DD7E1E300000000000000004 /* TelemetryPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000003 /* TelemetryPreviewView.swift */; };
+		DD7E1E300000000000000006 /* TelemetryPrivacyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000005 /* TelemetryPrivacyView.swift */; };
+		DD7E1E300000000000000008 /* TelemetryMigrationSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000007 /* TelemetryMigrationSheetView.swift */; };
+		DD7E1E30000000000000000E /* TelemetryDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E30000000000000000D /* TelemetryDataFlow.swift */; };
+		DD7E1E300000000000000010 /* TelemetryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E30000000000000000F /* TelemetryProvider.swift */; };
+		DD7E1E300000000000000012 /* TelemetryStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000011 /* TelemetryStateModel.swift */; };
 		DD868FD82E381A54005D3308 /* APNSJWTClaims.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD868FD72E381A54005D3308 /* APNSJWTClaims.swift */; };
 		DD88C8E22C50420800F2D558 /* DefinitionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD88C8E12C50420800F2D558 /* DefinitionRow.swift */; };
 		DD906BF42EA6AA0100262772 /* NightscoutUploadPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD906BF32EA6AA0100262772 /* NightscoutUploadPipeline.swift */; };
@@ -1119,6 +1131,7 @@
 		3BDEA2DC60EDE0A3CA54DC73 /* TargetsEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorProvider.swift; sourceTree = "<group>"; };
 		3BF768BD6264FF7D71D66767 /* NightscoutConfigProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NightscoutConfigProvider.swift; sourceTree = "<group>"; };
 		3BF85FE12E427312000D7351 /* IOBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOBService.swift; sourceTree = "<group>"; };
+		B015AFE12E500000000D7351 /* BolusSafetyValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusSafetyValidator.swift; sourceTree = "<group>"; };
 		3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MedtrumKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		3E62C7812F54CC1600433237 /* BolusDisplayThreshold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusDisplayThreshold.swift; sourceTree = "<group>"; };
 		3F60E97100041040446F44E7 /* PumpConfigStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigStateModel.swift; sourceTree = "<group>"; };
@@ -1269,6 +1282,7 @@
 		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>"; };
+		B015AFE42E500000000D7351 /* BolusSafetyValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusSafetyValidatorTests.swift; sourceTree = "<group>"; };
 		BD8FC05F2D6619DB00B95AED /* CarbsStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbsStorageTests.swift; sourceTree = "<group>"; };
 		BD8FC0612D6619E600B95AED /* OverrideStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideStorageTests.swift; sourceTree = "<group>"; };
 		BD8FC0632D6619EF00B95AED /* TempTargetStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetStorageTests.swift; sourceTree = "<group>"; };
@@ -1412,6 +1426,7 @@
 		DD1745232C55526000211FAC /* SMBSettingsStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMBSettingsStateModel.swift; sourceTree = "<group>"; };
 		DD1745252C55526F00211FAC /* SMBSettingsRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMBSettingsRootView.swift; sourceTree = "<group>"; };
 		DD1745282C55642100211FAC /* SettingInputSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingInputSection.swift; sourceTree = "<group>"; };
+		48B83503461B4F8D97B30115 /* SettingsSearchHighlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchHighlight.swift; sourceTree = "<group>"; };
 		DD17452A2C556E8100211FAC /* SettingInputHintView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingInputHintView.swift; sourceTree = "<group>"; };
 		DD17452D2C55AE4800211FAC /* TargetBehavoirDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TargetBehavoirDataFlow.swift; sourceTree = "<group>"; };
 		DD17452F2C55AE5300211FAC /* TargetBehaviorProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TargetBehaviorProvider.swift; sourceTree = "<group>"; };
@@ -1480,6 +1495,14 @@
 		DD73FA0E2D74F57300D19D1E /* BackgroundTask+Helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BackgroundTask+Helper.swift"; sourceTree = "<group>"; };
 		DD8262CA2D289297009F6F62 /* BolusConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusConfirmationView.swift; sourceTree = "<group>"; };
 		DD82D4B72DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertyPersistentFlags.swift; sourceTree = "<group>"; };
+		DD7E1E300000000000000001 /* TelemetryClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryClient.swift; sourceTree = "<group>"; };
+		DD7E1E300000000000000013 /* TelemetryAttestor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryAttestor.swift; sourceTree = "<group>"; };
+		DD7E1E300000000000000003 /* TelemetryPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryPreviewView.swift; sourceTree = "<group>"; };
+		DD7E1E300000000000000005 /* TelemetryPrivacyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryPrivacyView.swift; sourceTree = "<group>"; };
+		DD7E1E300000000000000007 /* TelemetryMigrationSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryMigrationSheetView.swift; sourceTree = "<group>"; };
+		DD7E1E30000000000000000D /* TelemetryDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryDataFlow.swift; sourceTree = "<group>"; };
+		DD7E1E30000000000000000F /* TelemetryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryProvider.swift; sourceTree = "<group>"; };
+		DD7E1E300000000000000011 /* TelemetryStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryStateModel.swift; sourceTree = "<group>"; };
 		DD868FD72E381A54005D3308 /* APNSJWTClaims.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSJWTClaims.swift; sourceTree = "<group>"; };
 		DD88C8E12C50420800F2D558 /* DefinitionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefinitionRow.swift; sourceTree = "<group>"; };
 		DD906BF32EA6AA0100262772 /* NightscoutUploadPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutUploadPipeline.swift; sourceTree = "<group>"; };
@@ -1594,6 +1617,7 @@
 		E592A3742CEEC038009A472C /* ContactImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactImageProvider.swift; sourceTree = "<group>"; };
 		E592A3752CEEC038009A472C /* ContactImageStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactImageStateModel.swift; sourceTree = "<group>"; };
 		E625985B47742D498CB1681A /* GlucoseNotificationSettingsProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GlucoseNotificationSettingsProvider.swift; sourceTree = "<group>"; };
+		B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsSearchTests.swift; sourceTree = "<group>"; };
 		F816825D28DB441200054060 /* HeartBeatManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HeartBeatManager.swift; sourceTree = "<group>"; };
 		F816825F28DB441800054060 /* BluetoothTransmitter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BluetoothTransmitter.swift; sourceTree = "<group>"; };
 		F90692A9274B7AAE0037068D /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitManager.swift; sourceTree = "<group>"; };
@@ -1956,6 +1980,7 @@
 				DDD163032C4C67B400CD525A /* Adjustments */,
 				DD1745382C55BF8B00211FAC /* AlgorithmAdvancedSettings */,
 				DDF690FE2DA2C9EE008BF16C /* AppDiagnostics */,
+				DD7E1E30000000000000000B /* Telemetry */,
 				DD1745422C55C5C400211FAC /* AutosensSettings */,
 				A42F1FEDFFD0DDE00AAD54D3 /* BasalProfileEditor */,
 				3811DE0425C9D32E00A708ED /* Base */,
@@ -2111,6 +2136,7 @@
 				DDA9AC072D67291600E6F1A9 /* AppVersionChecker */,
 				CEB434E128B8F9BC00B70274 /* Bluetooth */,
 				BD7DB88C2D2C49FF003D3155 /* BolusCalculator */,
+				B015AFE22E500000000D7351 /* BolusSafety */,
 				3862CC2C2743F9DC00BF832C /* Calendar */,
 				E592A37E2CEEC046009A472C /* ContactImage */,
 				F90692A8274B7A980037068D /* HealthKit */,
@@ -2122,6 +2148,7 @@
 				DD9ECB662CA99EFE00AA7C45 /* RemoteControl */,
 				38AEE75025F021F10013F05B /* SettingsManager */,
 				3811DE9825C9D88300A708ED /* Storage */,
+				DD7E1E30000000000000000A /* Telemetry */,
 				3811DEA525C9D88300A708ED /* UnlockManager */,
 				38E87406274F9AA500975559 /* UserNotifications */,
 				38E8754D275556E100975559 /* WatchManager */,
@@ -2129,6 +2156,36 @@
 			path = Services;
 			sourceTree = "<group>";
 		};
+		DD7E1E30000000000000000A /* Telemetry */ = {
+			isa = PBXGroup;
+			children = (
+				DD7E1E300000000000000013 /* TelemetryAttestor.swift */,
+				DD7E1E300000000000000001 /* TelemetryClient.swift */,
+			);
+			path = Telemetry;
+			sourceTree = "<group>";
+		};
+		DD7E1E30000000000000000B /* Telemetry */ = {
+			isa = PBXGroup;
+			children = (
+				DD7E1E30000000000000000D /* TelemetryDataFlow.swift */,
+				DD7E1E30000000000000000F /* TelemetryProvider.swift */,
+				DD7E1E300000000000000011 /* TelemetryStateModel.swift */,
+				DD7E1E30000000000000000C /* View */,
+			);
+			path = Telemetry;
+			sourceTree = "<group>";
+		};
+		DD7E1E30000000000000000C /* View */ = {
+			isa = PBXGroup;
+			children = (
+				DD7E1E300000000000000003 /* TelemetryPreviewView.swift */,
+				DD7E1E300000000000000005 /* TelemetryPrivacyView.swift */,
+				DD7E1E300000000000000007 /* TelemetryMigrationSheetView.swift */,
+			);
+			path = View;
+			sourceTree = "<group>";
+		};
 		3811DE9225C9D88200A708ED /* Appearance */ = {
 			isa = PBXGroup;
 			children = (
@@ -2368,6 +2425,7 @@
 				38DF1785276A73D400B3528F /* TagCloudView.swift */,
 				DD88C8E12C50420800F2D558 /* DefinitionRow.swift */,
 				DD1745282C55642100211FAC /* SettingInputSection.swift */,
+				48B83503461B4F8D97B30115 /* SettingsSearchHighlight.swift */,
 				DD17452A2C556E8100211FAC /* SettingInputHintView.swift */,
 			);
 			path = Views;
@@ -2696,6 +2754,7 @@
 				DDC6CA6C2DD90A2A0060EE25 /* LocalizationTests.swift */,
 				3B997DD22DC02AEF006B6BB2 /* JSONImporterData */,
 				BD8FC05C2D6618BE00B95AED /* BolusCalculatorTests */,
+				B015AFE62E500000000D7351 /* BolusSafetyTests */,
 				BD8FC0552D66187700B95AED /* CoreDataTests */,
 				38FCF3F125E9028E0078B0D1 /* Info.plist */,
 				CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */,
@@ -2703,6 +2762,7 @@
 				38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */,
 				3B997DCE2DC00A3A006B6BB2 /* JSONImporterTests.swift */,
 				CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */,
+				B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */,
 				BD8FC0532D66186000B95AED /* TestError.swift */,
 				BD8FC0702D661B0000B95AED /* TidepoolTherapySettingsTests.swift */,
 			);
@@ -2731,6 +2791,14 @@
 			path = IOB;
 			sourceTree = "<group>";
 		};
+		B015AFE22E500000000D7351 /* BolusSafety */ = {
+			isa = PBXGroup;
+			children = (
+				B015AFE12E500000000D7351 /* BolusSafetyValidator.swift */,
+			);
+			path = BolusSafety;
+			sourceTree = "<group>";
+		};
 		4E8C7B59F8065047ECE20965 /* View */ = {
 			isa = PBXGroup;
 			children = (
@@ -3085,6 +3153,14 @@
 			path = BolusCalculatorTests;
 			sourceTree = "<group>";
 		};
+		B015AFE62E500000000D7351 /* BolusSafetyTests */ = {
+			isa = PBXGroup;
+			children = (
+				B015AFE42E500000000D7351 /* BolusSafetyValidatorTests.swift */,
+			);
+			path = BolusSafetyTests;
+			sourceTree = "<group>";
+		};
 		BDA25F1A2D26BCE800035F34 /* Views */ = {
 			isa = PBXGroup;
 			children = (
@@ -4373,6 +4449,14 @@
 				BD249D862D42FBEC00412DEB /* GlucoseMetricsView.swift in Sources */,
 				58645BA32CA2D325008AFCE7 /* BatterySetup.swift in Sources */,
 				DD82D4B82DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift in Sources */,
+				DD7E1E300000000000000002 /* TelemetryClient.swift in Sources */,
+				DD7E1E300000000000000014 /* TelemetryAttestor.swift in Sources */,
+				DD7E1E300000000000000004 /* TelemetryPreviewView.swift in Sources */,
+				DD7E1E300000000000000006 /* TelemetryPrivacyView.swift in Sources */,
+				DD7E1E300000000000000008 /* TelemetryMigrationSheetView.swift in Sources */,
+				DD7E1E30000000000000000E /* TelemetryDataFlow.swift in Sources */,
+				DD7E1E300000000000000010 /* TelemetryProvider.swift in Sources */,
+				DD7E1E300000000000000012 /* TelemetryStateModel.swift in Sources */,
 				388E5A5C25B6F0770019842D /* JSON.swift in Sources */,
 				C263D59F2E4267F400CBF08C /* NightscoutUploadGlucoseStepView.swift in Sources */,
 				3811DF0225CA9FEA00A708ED /* Credentials.swift in Sources */,
@@ -4385,6 +4469,7 @@
 				BDF34F832C10C5B600D51995 /* DataManager.swift in Sources */,
 				38B4F3C625E5017E00E76A18 /* NotificationCenter.swift in Sources */,
 				19D466A729AA2C22004D5F33 /* MealSettingsStateModel.swift in Sources */,
+				AC19EF2C94084B5BA0175D1D /* SettingsSearchHighlight.swift in Sources */,
 				DD17452B2C556E8100211FAC /* SettingInputHintView.swift in Sources */,
 				38E44528274E401C00EC9A94 /* Protected.swift in Sources */,
 				DD3F1F8B2D9E08B600DCE7B3 /* NightscoutLoginStepView.swift in Sources */,
@@ -4511,6 +4596,7 @@
 				3811DEEA25CA063400A708ED /* SyncAccess.swift in Sources */,
 				190EBCC829FF13AA00BA767D /* UserInterfaceSettingsStateModel.swift in Sources */,
 				3BF85FE32E427312000D7351 /* IOBService.swift in Sources */,
+				B015AFE32E500000000D7351 /* BolusSafetyValidator.swift in Sources */,
 				DDF847EA2C5DABAC0049BB3B /* WatchConfigGarminView.swift in Sources */,
 				38BF021F25E7F0DE00579895 /* DeviceDataManager.swift in Sources */,
 				BD4E1A7A2D3681B700D21626 /* GlucoseTargetSetup.swift in Sources */,
@@ -4889,6 +4975,8 @@
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
 				BD8FC0602D6619DB00B95AED /* CarbsStorageTests.swift in Sources */,
 				BD8FC05E2D6618CE00B95AED /* BolusCalculatorTests.swift in Sources */,
+				41740E936552456AAC0EDAC3 /* SettingsSearchTests.swift in Sources */,
+				B015AFE52E500000000D7351 /* BolusSafetyValidatorTests.swift in Sources */,
 				BD8FC0712D661B0000B95AED /* TidepoolTherapySettingsTests.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;

+ 1 - 1
Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved

@@ -1,5 +1,5 @@
 {
-  "originHash" : "598841ae6fe892058ca678f5672f34299df2d62843330367c207648003263ccd",
+  "originHash" : "1e72c1cdf8ea5ec9fe527ebfab01ea55fca9e8651fe3252338fd3d4ea2cb327a",
   "pins" : [
     {
       "identity" : "abseil-cpp-binary",

+ 12 - 0
Trio/Sources/Application/AppDelegate.swift

@@ -20,6 +20,18 @@ class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject, UNUserNoti
         Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(crashReportingEnabled)
         Crashlytics.crashlytics().setCustomValue(Bundle.main.appDevVersion ?? "unknown", forKey: "app_dev_version")
 
+        // Telemetry: record this cold launch into the sliding 7-day window. If
+        // consent is set and the build SHA changed since the last successful
+        // send, fire an immediate ping — the 24h scheduler can't notice a
+        // build update on its own. Then arm the recurring 24h timer.
+        TelemetryClient.shared.recordColdLaunch()
+        Task.detached {
+            if TelemetryClient.shared.buildShaChangedSinceLastSend() {
+                await TelemetryClient.shared.maybeSend()
+            }
+            TelemetryClient.shared.scheduleRecurring()
+        }
+
         return true
     }
 

+ 30 - 0
Trio/Sources/Application/TrioApp.swift

@@ -40,6 +40,11 @@ extension Notification.Name {
     @State private var showOnboardingCompletedSplash = false
     @State private var showMigrationError: Bool = false
 
+    // Telemetry: one-shot guard so the consent migration sheet is presented
+    // at most once per process even if scene activates repeatedly.
+    @State private var showTelemetryMigrationSheet = false
+    @State private var hasCheckedTelemetryMigration = false
+
     // Dependencies Assembler
     // contain all dependencies Assemblies
     // TODO: Remove static key after update "Use Dependencies" logic
@@ -340,6 +345,10 @@ extension Notification.Name {
                     self.showOnboardingCompletedSplash = true
                 }
             }
+            .sheet(isPresented: $showTelemetryMigrationSheet) {
+                TelemetryMigrationSheetView()
+                    .interactiveDismissDisabled(true)
+            }
         }
         .onChange(of: scenePhase) { _, newScenePhase in
             debug(.default, "APPLICATION PHASE: \(newScenePhase)")
@@ -358,10 +367,31 @@ extension Notification.Name {
                 if initState.complete {
                     performCleanupIfNecessary()
                 }
+                presentTelemetryMigrationSheetIfNeeded()
             }
         }
     }
 
+    /// Presents the one-time telemetry consent sheet for users who completed
+    /// onboarding before telemetry existed. The condition (`onboardingCompleted
+    /// == true` and no telemetry decision yet) is checked once per process —
+    /// the in-app dismiss handler sets `telemetryConsentDecisionMade`, so a
+    /// re-foreground after the user picks will no longer match.
+    private func presentTelemetryMigrationSheetIfNeeded() {
+        guard !hasCheckedTelemetryMigration else { return }
+        hasCheckedTelemetryMigration = true
+
+        let onboarded = PropertyPersistentFlags.shared.onboardingCompleted == true
+        let telemetryDecided = PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true
+        guard onboarded, !telemetryDecided else { return }
+
+        // Defer one runloop so SwiftUI has finished settling on whatever root
+        // view was just shown (loading screen, splash, main view).
+        DispatchQueue.main.async {
+            showTelemetryMigrationSheet = true
+        }
+    }
+
     func configureTabBarAppearance() {
         let appearance = UITabBarAppearance()
         appearance.configureWithDefaultBackground()

+ 1 - 0
Trio/Sources/Assemblies/ServiceAssembly.swift

@@ -29,5 +29,6 @@ final class ServiceAssembly: Assembly {
             }
         }
         container.register(IOBService.self) { r in BaseIOBService(resolver: r) }
+        container.register(BolusSafetyValidator.self) { r in BaseBolusSafetyValidator(resolver: r) }
     }
 }

+ 26 - 0
Trio/Sources/Helpers/PropertyPersistentFlags.swift

@@ -26,4 +26,30 @@ final class PropertyPersistentFlags {
 
     // TODO: This flag can be deleted in March 2027. Check the commit for other places to cleanup.
     @PersistedProperty(key: "hasSeenFatProteinOrderChange") var hasSeenFatProteinOrderChange: Bool?
+
+    // MARK: - Telemetry
+
+    //
+    // See Trio/Sources/Services/Telemetry/TelemetryClient.swift.
+    // `telemetryEnabled` gates the anonymous-usage POST. `diagnosticsSharingEnabled`
+    // remains the Crashlytics gate. Both flags `nil` means the user has not yet
+    // chosen — used to surface the one-time migration sheet to existing users.
+    @PersistedProperty(key: "telemetryEnabled") var telemetryEnabled: Bool?
+    @PersistedProperty(key: "telemetryConsentDecisionMade") var telemetryConsentDecisionMade: Bool?
+    @PersistedProperty(key: "telemetryLastSentAt") var telemetryLastSentAt: Date?
+    @PersistedProperty(key: "telemetryLastSentSha") var telemetryLastSentSha: String?
+    // Sliding 7-day window of cold-launch timestamps; count is sent as `coldLaunches7d`.
+    @PersistedProperty(key: "telemetryColdLaunchTimes") var telemetryColdLaunchTimes: [Date]?
+    // Stable per-install UUID. IDFV resets when the user removes all Trio-team apps;
+    // this survives independently and is wiped only by deleting Trio itself.
+    @PersistedProperty(key: "telemetryInstallId") var telemetryInstallId: String?
+
+    // App Attest "give up" signal — set on a 403 from /api/attest/register, meaning
+    // the server has rejected this app_id and there's no point retrying.
+    @PersistedProperty(key: "telemetryAttestForbidden") var telemetryAttestForbidden: Bool?
+
+    // Debug override for the telemetry server base URL. Empty/unset → use the
+    // production constant in TelemetryClient. Surfaced as a hidden field in
+    // App Diagnostics for local testing against a dev server.
+    @PersistedProperty(key: "telemetryDebugServerURL") var telemetryDebugServerURL: String?
 }

+ 9 - 1
Trio/Sources/Helpers/PropertyWrappers/PersistedProperty.swift

@@ -123,7 +123,15 @@ enum FileProtectionFixer {
             "onboardingCompleted.plist",
             "diagnosticsSharing.plist",
             "lastCleanupDate.plist",
-            "hasSeenFatProteinOrderChange.plist"
+            "hasSeenFatProteinOrderChange.plist",
+            "telemetryEnabled.plist",
+            "telemetryConsentDecisionMade.plist",
+            "telemetryLastSentAt.plist",
+            "telemetryLastSentSha.plist",
+            "telemetryColdLaunchTimes.plist",
+            "telemetryInstallId.plist",
+            "telemetryAttestForbidden.plist",
+            "telemetryDebugServerURL.plist"
         ]
 
         let fileManager = FileManager.default

+ 32 - 0
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -58330,6 +58330,26 @@
         }
       }
     },
+    "Bolus blocked: a %@ U bolus would exceed max IOB (%@ U). Current IOB: %@ U." : {
+      "comment" : "The string that appears when a bolus is blocked because it would exceed the maximum IOB. The argument is the requested bolus amount, the second argument is the maximum IOB, and the third argument is the current IOB.",
+      "isCommentAutoGenerated" : true,
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "new",
+            "value" : "Bolus blocked: a %1$@ U bolus would exceed max IOB (%2$@ U). Current IOB: %3$@ U."
+          }
+        }
+      }
+    },
+    "Bolus blocked: a significant bolus was delivered within the last %lld minutes." : {
+      "comment" : "The message displayed when a bolus is blocked because a significant bolus was delivered within the last 5 minutes.",
+      "isCommentAutoGenerated" : true
+    },
+    "Bolus blocked: current IOB is not available." : {
+      "comment" : "Message displayed when a bolus is blocked due to the IOB not being available.",
+      "isCommentAutoGenerated" : true
+    },
     "Bolus Calculator" : {
       "localizations" : {
         "bg" : {
@@ -72177,6 +72197,9 @@
         }
       }
     },
+    "Clears the local App Attest key, registered flag, and forbidden flag. The next telemetry send will re-attest from scratch. Use only if telemetry is stuck." : {
+
+    },
     "Click to Snooze Alerts" : {
       "localizations" : {
         "bg" : {
@@ -209868,6 +209891,15 @@
         }
       }
     },
+    "Reset and retry send" : {
+
+    },
+    "Reset App Attest state" : {
+
+    },
+    "Reset App Attest state?" : {
+
+    },
     "Reset to Defaults" : {
       "localizations" : {
         "bg" : {

+ 4 - 0
Trio/Sources/Logger/Logger.swift

@@ -140,6 +140,7 @@ final class Logger {
     static let watchManager = Logger(category: .watchManager, reporter: baseReporter)
     static let coreData = Logger(category: .coreData, reporter: baseReporter)
     static let storage = Logger(category: .storage, reporter: baseReporter)
+    static let telemetry = Logger(category: .telemetry, reporter: baseReporter)
 
     enum Category: String {
         case `default`
@@ -154,6 +155,7 @@ final class Logger {
         case watchManager
         case coreData
         case storage
+        case telemetry
 
         var name: String {
             rawValue.capitalizingFirstLetter()
@@ -173,6 +175,7 @@ final class Logger {
             case .watchManager: return .watchManager
             case .coreData: return .coreData
             case .storage: return .storage
+            case .telemetry: return .telemetry
             }
         }
 
@@ -190,6 +193,7 @@ final class Logger {
                  .remoteControl,
                  .service,
                  .storage,
+                 .telemetry,
                  .watchManager:
                 return OSLog(subsystem: subsystem, category: name)
             }

+ 3 - 3
Trio/Sources/Models/TrioSettings.swift

@@ -3,14 +3,14 @@ import Foundation
 enum BolusShortcutLimit: String, JSON, CaseIterable, Identifiable {
     var id: String { rawValue }
     case notAllowed
-    case limitBolusMax
+    case limitWithSafetyChecks
 
     var displayName: String {
         switch self {
         case .notAllowed:
             return String(localized: "Not allowed")
-        case .limitBolusMax:
-            return String(localized: "Max bolus")
+        case .limitWithSafetyChecks:
+            return String(localized: "Limit with Safety Checks")
         }
     }
 }

+ 1 - 0
Trio/Sources/Modules/AlgorithmAdvancedSettings/View/AlgorithmAdvancedSettingsRootView.swift

@@ -406,6 +406,7 @@ extension AlgorithmAdvancedSettings {
             .onAppear(perform: configureView)
             .navigationTitle("Additionals")
             .navigationBarTitleDisplayMode(.automatic)
+            .settingsHighlightScroll()
             .onDisappear {
                 state.saveIfChanged()
             }

+ 30 - 11
Trio/Sources/Modules/AppDiagnostics/AppDiagnosticsStateModel.swift

@@ -6,26 +6,45 @@ extension AppDiagnostics {
     @Observable final class StateModel: BaseStateModel<Provider> {
         // MARK: - Diagnostics Sharing Option
 
-        var diagnosticsSharingOption: DiagnosticsSharingOption = .enabled
+        var diagnosticsSharingOption: DiagnosticsSharingOption = .full
 
         override func subscribe() {
             loadDiagnostics()
         }
 
-        /// Loads the diagnostics sharing option from UserDefaults as a boolean.
+        /// Derives the 3-state option from the two underlying flags. Defaults
+        /// to `.full` for fresh installs (opt-out). For pre-telemetry users
+        /// who have Crashlytics on but haven't seen the migration sheet, we
+        /// surface `.crashOnly` until they pick — never auto-upgrade to
+        /// `.full` without an explicit decision.
         func loadDiagnostics() {
-            if let storedDiagnosticsSharingOption = PropertyPersistentFlags.shared.diagnosticsSharingEnabled {
-                diagnosticsSharingOption = storedDiagnosticsSharingOption ? .enabled : .disabled
-            } else {
-                diagnosticsSharingOption = .enabled
-            }
+            let crashlytics = PropertyPersistentFlags.shared.diagnosticsSharingEnabled ?? true
+            let telemetryDecided = PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true
+            let telemetry = telemetryDecided
+                ? (PropertyPersistentFlags.shared.telemetryEnabled ?? false)
+                : false
+            diagnosticsSharingOption = DiagnosticsSharingOption(
+                crashlyticsEnabled: crashlytics,
+                telemetryEnabled: telemetry
+            )
         }
 
-        /// Persists the current diagnostics sharing option to UserDefaults as a boolean.
+        /// Persists the current diagnostics sharing option to both underlying flags
+        /// and applies it to Crashlytics + the telemetry sender.
         func applyDiagnostics() {
-            let booleanValue: Bool = diagnosticsSharingOption == .enabled
-            PropertyPersistentFlags.shared.diagnosticsSharingEnabled = booleanValue
-            Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(booleanValue)
+            let wasTelemetryOn = PropertyPersistentFlags.shared.telemetryEnabled == true
+
+            PropertyPersistentFlags.shared.diagnosticsSharingEnabled = diagnosticsSharingOption.crashlyticsEnabled
+            PropertyPersistentFlags.shared.telemetryEnabled = diagnosticsSharingOption.telemetryEnabled
+            PropertyPersistentFlags.shared.telemetryConsentDecisionMade = true
+            Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(diagnosticsSharingOption.crashlyticsEnabled)
+
+            // Fire an inaugural send on a fresh opt-in so the first data point
+            // arrives at the moment of consent rather than 24h later.
+            if diagnosticsSharingOption.telemetryEnabled, !wasTelemetryOn {
+                TelemetryClient.shared.scheduleRecurring()
+                Task.detached { await TelemetryClient.shared.maybeSend() }
+            }
         }
     }
 }

+ 19 - 8
Trio/Sources/Modules/AppDiagnostics/View/AppDiagnosticsRootView.swift

@@ -21,7 +21,7 @@ extension AppDiagnostics {
                                 Button(action: {
                                     state.diagnosticsSharingOption = option
                                 }) {
-                                    HStack {
+                                    HStack(alignment: .top, spacing: 12) {
                                         Image(
                                             systemName: state
                                                 .diagnosticsSharingOption == option ? "largecircle.fill.circle" : "circle"
@@ -29,8 +29,14 @@ extension AppDiagnostics {
                                         .foregroundColor(state.diagnosticsSharingOption == option ? .accentColor : .secondary)
                                         .imageScale(.large)
 
-                                        Text(option.displayName)
-                                            .foregroundColor(.primary)
+                                        VStack(alignment: .leading, spacing: 4) {
+                                            Text(option.displayName)
+                                                .foregroundColor(.primary)
+                                                .bold()
+                                            Text(option.caption)
+                                                .font(.footnote)
+                                                .foregroundColor(.secondary)
+                                        }
 
                                         Spacer()
                                     }
@@ -48,32 +54,37 @@ extension AppDiagnostics {
                 ).listRowBackground(Color.chart)
 
                 Section {
+                    NavigationLink("What's sent") { TelemetryPreviewView() }
+                    NavigationLink("Privacy details") { TelemetryPrivacyView() }
+                }.listRowBackground(Color.chart)
+
+                Section {
                     VStack(alignment: .leading, spacing: 8) {
                         Text("Why does Trio collect this data?").bold()
                         VStack(alignment: .leading, spacing: 4) {
                             BulletPoint(
                                 String(
-                                    localized: "App diagnostic insights help us enhance app stability, ensure safety for all users, and enable us to quickly identify and resolve critical issues."
+                                    localized: "App diagnostic insights — based on crash reports only — help us enhance app stability, ensure safety for all users, and quickly identify and resolve critical issues."
                                 )
                             )
                             BulletPoint(
                                 String(
-                                    localized: "Trio collects the app's state on crash, device, iOS and general system info, and a stack trace."
+                                    localized: "Crash reports include the app's state on crash, device, iOS and general system info, and a stack trace. They are sent to a Google Firebase Crashlytics project maintained by the Trio team."
                                 )
                             )
                             BulletPoint(
                                 String(
-                                    localized: "Trio does not collect any health related data, e.g. glucose readings, insulin rates or doses, meal data, setting values, or similar."
+                                    localized: "Anonymous usage statistics include the app version and build, device and iOS version, which pump and CGM you have paired, and whether Nightscout, Tidepool, and Apple Health are configured (yes/no — no URLs or credentials). They are sent to a self-hosted Trio telemetry endpoint."
                                 )
                             )
                             BulletPoint(
                                 String(
-                                    localized: "Trio does not track any usage metrics or any other personal data about users other than the used iPhone model and iOS version."
+                                    localized: "Trio does not collect any health related data, e.g. glucose readings, insulin rates or doses, meal data, therapy setting values, or similar."
                                 )
                             )
                         }
                         Text(
-                            "Diagnostics are sent to a Google Firebase Crashlytics project, which is securely maintained and accessed only by the Trio team."
+                            "Use \"What's sent\" above to inspect the exact JSON payload before deciding."
                         )
                     }
                     .font(.footnote)

+ 1 - 0
Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift

@@ -230,6 +230,7 @@ extension AutosensSettings {
             .onAppear(perform: configureView)
             .navigationTitle("Autosens")
             .navigationBarTitleDisplayMode(.automatic)
+            .settingsHighlightScroll()
         }
     }
 }

+ 1 - 0
Trio/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift

@@ -200,6 +200,7 @@ extension BolusCalculatorConfig {
             .onAppear(perform: configureView)
             .navigationBarTitle("Bolus Calculator")
             .navigationBarTitleDisplayMode(.automatic)
+            .settingsHighlightScroll()
         }
     }
 }

+ 1 - 0
Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift

@@ -150,6 +150,7 @@ extension CGMSettings {
                 .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
                 .onAppear(perform: configureView)
                 .navigationTitle("CGM")
+                .settingsHighlightScroll()
                 .navigationBarTitleDisplayMode(.automatic)
                 .navigationBarItems(leading: displayClose ? Button("Close", action: state.hideModal) : nil)
                 .sheet(isPresented: $state.shouldDisplayCGMSetupSheet) {

+ 1 - 0
Trio/Sources/Modules/CalendarEventSettings/View/CalendarEventSettingsRootView.swift

@@ -147,6 +147,7 @@ extension CalendarEventSettings {
             .onAppear(perform: configureView)
             .navigationTitle("Calendar Events")
             .navigationBarTitleDisplayMode(.automatic)
+            .settingsHighlightScroll()
         }
     }
 }

+ 9 - 2
Trio/Sources/Modules/DynamicSettings/View/DynamicSettingsRootView.swift

@@ -19,7 +19,13 @@ extension DynamicSettings {
         private var shouldDisplayHintBinding: Binding<Bool> {
             Binding(
                 get: { hintPayload != nil },
-                set: { newValue in if !newValue { hintPayload = nil } }
+                set: { newValue in
+                    if !newValue {
+                        hintPayload = nil
+                    } else if hintPayload == nil {
+                        hintPayload = HintPayload(label: "", content: AnyView(EmptyView()))
+                    }
+                }
             )
         }
 
@@ -159,7 +165,7 @@ extension DynamicSettings {
                             }.padding(.top)
                         }.padding(.bottom)
                     }
-                ).listRowBackground(Color.chart)
+                ).settingsSearchTarget(label: String(localized: "Dynamic ISF"))
 
                 if state.dynamicSensitivityType != .disabled {
                     if state.dynamicSensitivityType == .logarithmic {
@@ -277,6 +283,7 @@ extension DynamicSettings {
             .onAppear(perform: configureView)
             .navigationBarTitle("Dynamic Settings")
             .navigationBarTitleDisplayMode(.automatic)
+            .settingsHighlightScroll()
         }
     }
 }

+ 1 - 0
Trio/Sources/Modules/GeneralSettings/View/UnitsLimitsSettingsRootView.swift

@@ -227,6 +227,7 @@ extension UnitsLimitsSettings {
             .onDisappear {
                 state.saveIfChanged()
             }
+            .settingsHighlightScroll()
         }
     }
 }

+ 1 - 0
Trio/Sources/Modules/GlucoseNotificationSettings/View/GlucoseNotificationSettingsRootView.swift

@@ -264,6 +264,7 @@ extension GlucoseNotificationSettings {
             .onAppear(perform: configureView)
             .navigationBarTitle("Trio Notifications")
             .navigationBarTitleDisplayMode(.automatic)
+            .settingsHighlightScroll()
         }
 
         var lowAndHighGlucoseAlertSection: some View {

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

@@ -22,6 +22,7 @@ extension Home {
         @State var state = StateModel()
 
         @State var settingsPath = NavigationPath()
+        @State var settingsSearchHighlight = SettingsSearchHighlight()
         @State var isStatusPopupPresented = false
         @State var showCancelAlert = false
         @State var showCancelConfirmDialog = false
@@ -1098,6 +1099,7 @@ extension Home {
 
                     NavigationStack(path: self.$settingsPath) {
                         Settings.RootView(resolver: resolver) }
+                        .environment(settingsSearchHighlight)
                         .tabItem { Label(
                             "Settings",
                             systemImage: "gear"

+ 1 - 0
Trio/Sources/Modules/LiveActivitySettings/View/LiveActivitySettingsRootView.swift

@@ -233,6 +233,7 @@ extension LiveActivitySettings {
             .onAppear(perform: configureView)
             .navigationTitle("Live Activity")
             .navigationBarTitleDisplayMode(.automatic)
+            .settingsHighlightScroll()
         }
     }
 }

+ 1 - 0
Trio/Sources/Modules/MealSettings/View/MealSettingsRootView.swift

@@ -371,6 +371,7 @@ extension MealSettings {
             .onAppear(perform: configureView)
             .navigationBarTitle("Meal Settings")
             .navigationBarTitleDisplayMode(.automatic)
+            .settingsHighlightScroll()
         }
     }
 }

+ 1 - 0
Trio/Sources/Modules/NightscoutConfig/View/NightscoutFetchView.swift

@@ -52,6 +52,7 @@ struct NightscoutFetchView: View {
         }
         .navigationTitle("Fetch")
         .navigationBarTitleDisplayMode(.automatic)
+        .settingsHighlightScroll()
         .scrollContentBackground(.hidden)
         .background(appState.trioBackgroundColor(for: colorScheme))
     }

+ 1 - 0
Trio/Sources/Modules/NightscoutConfig/View/NightscoutUploadView.swift

@@ -81,6 +81,7 @@ struct NightscoutUploadView: View {
         }
         .navigationTitle("Upload")
         .navigationBarTitleDisplayMode(.automatic)
+        .settingsHighlightScroll()
         .scrollContentBackground(.hidden)
         .background(appState.trioBackgroundColor(for: colorScheme))
     }

+ 27 - 12
Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift

@@ -26,21 +26,31 @@ extension Onboarding {
 
         // MARK: - App Diagnostics
 
-        private var persistedDiagnosticsSharing: Bool? {
-            get { PropertyPersistentFlags.shared.diagnosticsSharingEnabled }
-            set { PropertyPersistentFlags.shared.diagnosticsSharingEnabled = newValue }
-        }
-
-        var diagnosticsSharingOption: DiagnosticsSharingOption = .enabled
+        var diagnosticsSharingOption: DiagnosticsSharingOption = .full
         var hasAcceptedPrivacyPolicy: Bool = false
 
         func syncDiagnosticsOptionFromStorage() {
-            diagnosticsSharingOption = (persistedDiagnosticsSharing ?? true) ? .enabled : .disabled
+            // Onboarding *is* the consent decision point, so a fresh install
+            // sees `.full` (truly opt-out). If the user has already picked
+            // something — e.g. backed out of this step and returned — restore
+            // their saved selection so they see their current choice.
+            if PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true {
+                let crashlytics = PropertyPersistentFlags.shared.diagnosticsSharingEnabled ?? true
+                let telemetry = PropertyPersistentFlags.shared.telemetryEnabled ?? false
+                diagnosticsSharingOption = DiagnosticsSharingOption(
+                    crashlyticsEnabled: crashlytics,
+                    telemetryEnabled: telemetry
+                )
+            } else {
+                diagnosticsSharingOption = .full
+            }
         }
 
         func updateDiagnosticsOption(to option: DiagnosticsSharingOption) {
             diagnosticsSharingOption = option
-            persistedDiagnosticsSharing = (option == .enabled)
+            PropertyPersistentFlags.shared.diagnosticsSharingEnabled = option.crashlyticsEnabled
+            PropertyPersistentFlags.shared.telemetryEnabled = option.telemetryEnabled
+            PropertyPersistentFlags.shared.telemetryConsentDecisionMade = true
         }
 
         // MARK: - Determine Initial Build State
@@ -695,11 +705,16 @@ extension Onboarding {
             saveISFValues()
         }
 
-        /// Persists the current diagnostics sharing option to UserDefaults as a boolean.
+        /// Persists the current diagnostics sharing option and applies it to Crashlytics + telemetry.
         func applyDiagnostics() {
-            let booleanValue = diagnosticsSharingOption == .enabled
-            PropertyPersistentFlags.shared.diagnosticsSharingEnabled = booleanValue
-            Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(booleanValue)
+            PropertyPersistentFlags.shared.diagnosticsSharingEnabled = diagnosticsSharingOption.crashlyticsEnabled
+            PropertyPersistentFlags.shared.telemetryEnabled = diagnosticsSharingOption.telemetryEnabled
+            PropertyPersistentFlags.shared.telemetryConsentDecisionMade = true
+            Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(diagnosticsSharingOption.crashlyticsEnabled)
+            if diagnosticsSharingOption.telemetryEnabled {
+                TelemetryClient.shared.scheduleRecurring()
+                Task.detached { await TelemetryClient.shared.maybeSend() }
+            }
         }
 
         /// Applies the selected glucose units to the app's settings.

+ 1 - 1
Trio/Sources/Modules/Onboarding/View/OnboardingRootView.swift

@@ -71,7 +71,7 @@ extension Onboarding {
 
         // Next button conditional
         private var shouldDisableNextButton: Bool {
-            (currentStep == .diagnostics && state.diagnosticsSharingOption == .enabled && !state.hasAcceptedPrivacyPolicy)
+            (currentStep == .diagnostics && state.diagnosticsSharingOption != .disabled && !state.hasAcceptedPrivacyPolicy)
                 ||
                 (currentStep == .nightscout && didSelectNightscoutSetupOption)
                 ||

+ 22 - 11
Trio/Sources/Modules/Onboarding/View/OnboardingSteps/DiagnosticsStepView.swift

@@ -7,7 +7,7 @@ struct DiagnosticsStepView: View {
 
     var body: some View {
         VStack(alignment: .leading, spacing: 20) {
-            Text("If you prefer not to share this anonymized data, you can opt-out of data sharing.")
+            Text("Help us improve Trio. Pick how much you'd like to share — or opt out entirely.")
                 .font(.headline)
                 .padding(.horizontal)
                 .multilineTextAlignment(.leading)
@@ -16,13 +16,19 @@ struct DiagnosticsStepView: View {
                 Button(action: {
                     state.updateDiagnosticsOption(to: option)
                 }) {
-                    HStack {
+                    HStack(alignment: .top, spacing: 12) {
                         Image(systemName: state.diagnosticsSharingOption == option ? "largecircle.fill.circle" : "circle")
                             .foregroundColor(state.diagnosticsSharingOption == option ? .accentColor : .secondary)
                             .imageScale(.large)
 
-                        Text(option.displayName)
-                            .foregroundColor(.primary)
+                        VStack(alignment: .leading, spacing: 4) {
+                            Text(option.displayName)
+                                .foregroundColor(.primary)
+                                .bold()
+                            Text(option.caption)
+                                .font(.footnote)
+                                .foregroundColor(.secondary)
+                        }
 
                         Spacer()
                     }
@@ -33,6 +39,14 @@ struct DiagnosticsStepView: View {
                 .buttonStyle(.plain)
             }
 
+            NavigationLink {
+                TelemetryPreviewView()
+            } label: {
+                Label("See exactly what's sent", systemImage: "doc.text.magnifyingglass")
+                    .font(.footnote)
+            }
+            .padding(.horizontal)
+
             Toggle(isOn: $state.hasAcceptedPrivacyPolicy) {
                 HStack {
                     Text("I have read and accept the")
@@ -59,28 +73,25 @@ struct DiagnosticsStepView: View {
                 VStack(alignment: .leading, spacing: 4) {
                     BulletPoint(
                         String(
-                            localized: "App diagnostic insights help us enhance app stability, ensure safety for all users, and enable us to quickly identify and resolve critical issues."
+                            localized: "App diagnostic insights — based on crash reports only — help us enhance app stability, ensure safety for all users, and quickly identify and resolve critical issues."
                         )
                     )
                     BulletPoint(
                         String(
-                            localized: "Trio collects the app's state on crash, device, iOS and general system info, and a stack trace."
+                            localized: "Crash reports include the app's state on crash, device, iOS info, and a stack trace. They are sent to Google Firebase Crashlytics, maintained by the Trio team."
                         )
                     )
                     BulletPoint(
                         String(
-                            localized: "Trio does not collect any health related data, e.g. glucose readings, insulin rates or doses, meal data, setting values, or similar."
+                            localized: "Anonymous usage statistics include the app version, your device and iOS version, your paired pump and CGM, and whether Nightscout, Tidepool, and Apple Health are configured (yes/no). No URLs, tokens, or credentials are included."
                         )
                     )
                     BulletPoint(
                         String(
-                            localized: "Trio does not track any usage metrics or any other personal data about users other than the used iPhone model and iOS version."
+                            localized: "Trio never collects glucose readings, insulin rates or doses, meal data, therapy setting values, or any other health information."
                         )
                     )
                 }
-                Text(
-                    "Diagnostics are sent to a Google Firebase Crashlytics project, which is securely maintained and accessed only by the Trio team."
-                )
             }
             .multilineTextAlignment(.leading)
             .padding(.horizontal)

+ 47 - 3
Trio/Sources/Modules/Onboarding/View/OnboardingView+Util.swift

@@ -483,20 +483,64 @@ enum DeliveryLimitSubstep: Int, CaseIterable, Identifiable {
     }
 }
 
+/// Three-state diagnostics-sharing consent.
+///
+/// Maps to a pair of independent `Bool?` flags in `PropertyPersistentFlags`:
+/// `diagnosticsSharingEnabled` (Crashlytics) and `telemetryEnabled` (the
+/// anonymous-usage POST). See `TelemetryClient`.
 enum DiagnosticsSharingOption: String, Equatable, CaseIterable, Identifiable {
-    case enabled
+    case full
+    case crashOnly
     case disabled
 
     var id: String { rawValue }
 
     var displayName: String {
         switch self {
-        case .enabled:
-            return String(localized: "Enable Sharing")
+        case .full:
+            return String(localized: "Enable Full Sharing")
+        case .crashOnly:
+            return String(localized: "Crash Reports Only")
         case .disabled:
             return String(localized: "Disable Sharing")
         }
     }
+
+    var caption: String {
+        switch self {
+        case .full:
+            return String(localized: "Share anonymous crash reports + usage data.")
+        case .crashOnly:
+            return String(localized: "Share only crash reports — no usage data.")
+        case .disabled:
+            return String(localized: "Do not share any diagnostic data.")
+        }
+    }
+
+    var crashlyticsEnabled: Bool {
+        switch self {
+        case .crashOnly,
+             .full: return true
+        case .disabled: return false
+        }
+    }
+
+    var telemetryEnabled: Bool {
+        switch self {
+        case .full: return true
+        case .crashOnly,
+             .disabled: return false
+        }
+    }
+
+    init(crashlyticsEnabled: Bool, telemetryEnabled: Bool) {
+        switch (crashlyticsEnabled, telemetryEnabled) {
+        case (true, true): self = .full
+        case (true, false): self = .crashOnly
+        case (false, true): self = .full // unreachable in normal flow
+        case (false, false): self = .disabled
+        }
+    }
 }
 
 enum PumpOptionForOnboardingUnits: String, Equatable, CaseIterable, Identifiable {

+ 10 - 2
Trio/Sources/Modules/Onboarding/View/TherapySettingEditorView.swift

@@ -8,6 +8,13 @@ struct TherapySettingEditorView: View {
     var validateOnDelete: (() -> Void)?
     var onItemAdded: (() -> Void)?
 
+    private let basalFormatter: NumberFormatter = {
+        let numberFormatter = NumberFormatter()
+        numberFormatter.maximumFractionDigits = 3
+        numberFormatter.minimumFractionDigits = 2
+        return numberFormatter
+    }()
+
     @State private var selectedItemID: UUID?
     @Namespace var bottomID
 
@@ -280,10 +287,11 @@ struct TherapySettingEditorView: View {
         case .mmolL,
              .mmolLPerUnit:
             return decimalValue.formattedAsMmolL
+        case .unitPerHour:
+            return basalFormatter.string(from: decimalValue as NSNumber) ?? ""
         case .gramPerUnit,
              .mgdL,
-             .mgdLPerUnit,
-             .unitPerHour:
+             .mgdLPerUnit:
             return decimalValue.description
         }
     }

+ 1 - 0
Trio/Sources/Modules/RemoteControlConfig/View/RemoteControlConfig.swift

@@ -101,6 +101,7 @@ extension RemoteControlConfig {
             .onAppear(perform: configureView)
             .navigationTitle("Remote Control")
             .navigationBarTitleDisplayMode(.automatic)
+            .settingsHighlightScroll()
         }
     }
 }

+ 1 - 0
Trio/Sources/Modules/SMBSettings/View/SMBSettingsRootView.swift

@@ -370,6 +370,7 @@ extension SMBSettings {
             .onAppear(perform: configureView)
             .navigationTitle("SMB Settings")
             .navigationBarTitleDisplayMode(.automatic)
+            .settingsHighlightScroll()
         }
     }
 }

+ 66 - 2
Trio/Sources/Modules/Settings/SettingItems.swift

@@ -8,16 +8,22 @@ struct SettingItem: Identifiable {
     let view: Screen
     let searchContents: [String]?
     let path: [String]?
+    /// Maps a `searchContents` string to the exact label used in `SettingInputSection`
+    /// when the two differ (e.g. `"Max IOB"` → `"Maximum Insulin on Board (IOB)"`).
+    /// Entries whose searchContents string already matches the label don't need an entry here.
+    let scrollTargetLabels: [String: String]?
 
     init(
         title: String,
         view: Screen,
         searchContents: [String]? = nil,
+        scrollTargetLabels: [String: String]? = nil,
         path: [String]? = nil
     ) {
         self.title = title
         self.view = view
         self.searchContents = searchContents
+        self.scrollTargetLabels = scrollTargetLabels
         self.path = path
     }
 }
@@ -26,6 +32,11 @@ struct FilteredSettingItem: Identifiable {
     let id = UUID()
     let settingItem: SettingItem
     let matchedContent: String
+    /// The label string used as the scroll/highlight target in the destination view.
+    /// Falls back to `matchedContent` when no explicit mapping exists.
+    var scrollLabel: String {
+        settingItem.scrollTargetLabels?[matchedContent] ?? matchedContent
+    }
 }
 
 enum SettingItems {
@@ -89,6 +100,12 @@ enum SettingItems {
                 "Minimum Safety Threshold",
                 "Delivery Limits"
             ],
+            scrollTargetLabels: [
+                "Max IOB": "Maximum Insulin on Board (IOB)",
+                "Max Bolus": "Maximum Bolus",
+                "Max Basal": "Maximum Basal Rate",
+                "Max COB": "Maximum Carbs on Board (COB)"
+            ],
             path: ["Therapy Settings", "Units and Limits"]
         ),
         SettingItem(title: "Basal Rates", view: .basalProfileEditor, path: ["Therapy Settings"]),
@@ -122,6 +139,12 @@ enum SettingItems {
                 "Max UAM SMB Basal Minutes",
                 "Max Allowed Glucose Rise for SMB"
             ],
+            scrollTargetLabels: [
+                "Enable SMB With Temporary Target": "Enable SMB With Temptarget",
+                "Allow SMB With High Temporary Target": "Allow SMB With High Temptarget",
+                "Max UAM SMB Basal Minutes": "Max UAM Basal Minutes",
+                "High Glucose Target": "Enable SMB With High Glucose"
+            ],
             path: ["Algorithm", "Super Micro Bolus (SMB)"]
         ),
         SettingItem(
@@ -148,6 +171,10 @@ enum SettingItems {
                 "Resistance Lowers Target",
                 "Half Basal Exercise Target"
             ],
+            scrollTargetLabels: [
+                "High Temptarget Raises Sensitivity": "High Temp Target Raises Sensitivity",
+                "Low Temptarget Lowers Sensitivity": "Low Temp Target Lowers Sensitivity"
+            ],
             path: ["Algorithm", "Target Behavior"]
         ),
         SettingItem(
@@ -168,6 +195,11 @@ enum SettingItems {
                 "Remaining Carbs Cap",
                 "Noisy CGM Target Multiplier"
             ],
+            scrollTargetLabels: [
+                "Min 5m Carbimpact": "Min 5m Carb Impact",
+                "Remaining Carbs Fraction": "Remaining Carbs Percentage",
+                "Noisy CGM Target Multiplier": "Noisy CGM Target Increase"
+            ],
             path: ["Algorithm", "Additionals"]
         )
     ]
@@ -185,6 +217,12 @@ enum SettingItems {
                 "Super Bolus Factor",
                 "Very Low Glucose Warning"
             ],
+            scrollTargetLabels: [
+                "Enable Reduced Bolus Factor": "Enable Reduced Bolus Option",
+                "Reduced Bolus Factor": "Enable Reduced Bolus Option",
+                "Enable Super Bolus": "Enable Super Bolus Option",
+                "Super Bolus Factor": "Enable Super Bolus Option"
+            ],
             path: ["Features", "Bolus Calculator"]
         ),
         SettingItem(
@@ -201,6 +239,14 @@ enum SettingItems {
                 "Fat and Protein Percentage",
                 "FPU"
             ],
+            scrollTargetLabels: [
+                "Max Fat": "Enable Fat and Protein Entries",
+                "Max Protein": "Enable Fat and Protein Entries",
+                "Fat and Protein Delay": "Enable Fat and Protein Entries",
+                "Spread Interval": "Enable Fat and Protein Entries",
+                "Fat and Protein Percentage": "Enable Fat and Protein Entries",
+                "FPU": "Enable Fat and Protein Entries"
+            ],
             path: ["Features", "Meal Settings"]
         ),
         SettingItem(
@@ -224,7 +270,6 @@ enum SettingItems {
                 "Show Low and High Thresholds",
                 "Low Threshold",
                 "High Threshold",
-                "X-Axis Interval Step",
                 "eA1c/GMI Display Unit",
                 "Show Carbs Required Badge",
                 "Carbs Required Threshold",
@@ -232,17 +277,32 @@ enum SettingItems {
                 "Bolus Display Threshold",
                 "Cone",
                 "Lines",
+                "Appearance",
                 "Dark Mode",
                 "Light Mode",
-                "Appearance",
                 "Dark Scheme",
                 "Light Scheme",
                 "Glucose Color Scheme",
                 "Time in Range Type",
                 "Time in Tight Range (TITR)",
                 "Time in Normoglycemia (TING)",
+                "X-Axis Interval Step",
                 "Require Adjustments Confirmation"
             ],
+            scrollTargetLabels: [
+                "Show Y-Axis Grid Lines": "Show X-Axis Grid Lines",
+                "High Threshold": "Low Threshold",
+                "Cone": "Forecast Display Type",
+                "Lines": "Forecast Display Type",
+                "Dark Mode": "Appearance",
+                "Light Mode": "Appearance",
+                "Time in Tight Range (TITR)": "Time in Range Type",
+                "Time in Normoglycemia (TING)": "Time in Range Type",
+                "Dark Scheme": "Appearance",
+                "Light Scheme": "Appearance",
+                "X-Axis Interval Step": "Show X-Axis Grid Lines",
+                "Carbs Required Threshold": "Show Carbs Required Badge"
+            ],
             path: ["Features", "User Interface"]
         ),
         SettingItem(
@@ -275,6 +335,10 @@ enum SettingItems {
                 "Low Glucose Alarm Limit",
                 "High Glucose Alarm Limit"
             ],
+            scrollTargetLabels: [
+                "Low Glucose Alarm Limit": "Glucose Notifications",
+                "High Glucose Alarm Limit": "Glucose Notifications"
+            ],
             path: ["Notifications", "Trio Notifications"] // Glucose
         ),
         SettingItem(

+ 19 - 8
Trio/Sources/Modules/Settings/View/SettingsRootView.swift

@@ -38,6 +38,7 @@ extension Settings {
         @Environment(\.colorScheme) var colorScheme
         @EnvironmentObject var appIcons: Icons
         @Environment(AppState.self) var appState
+        @Environment(SettingsSearchHighlight.self) var searchHighlight
 
         private var filteredItems: [FilteredSettingItem] {
             SettingItems.filteredItems(searchText: searchText)
@@ -284,15 +285,19 @@ extension Settings {
                         content: {
                             if filteredItems.isNotEmpty {
                                 ForEach(filteredItems) { filteredItem in
-                                    VStack(alignment: .leading) {
-                                        Text(filteredItem.matchedContent.localized).bold()
-                                        if let path = filteredItem.settingItem.path {
-                                            Text(path.map(\.localized).joined(separator: " > "))
-                                                .font(.caption)
-                                                .foregroundColor(.secondary)
+                                    NavigationLink(value: SearchResultTarget(
+                                        screen: filteredItem.settingItem.view,
+                                        scrollLabel: filteredItem.scrollLabel.localized
+                                    )) {
+                                        VStack(alignment: .leading) {
+                                            Text(filteredItem.matchedContent.localized).bold()
+                                            if let path = filteredItem.settingItem.path {
+                                                Text(path.map(\.localized).joined(separator: " > "))
+                                                    .font(.caption)
+                                                    .foregroundColor(.secondary)
+                                            }
                                         }
-
-                                    }.navigationLink(to: filteredItem.settingItem.view, from: self)
+                                    }
                                 }
                             } else {
                                 Text("No settings matching your search query")
@@ -339,6 +344,12 @@ extension Settings {
                 }
             }
             .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
+            .navigationDestination(for: SearchResultTarget.self) { target in
+                state.view(for: target.screen)
+                    .onAppear {
+                        searchHighlight.highlightedSetting = target.scrollLabel
+                    }
+            }
             .screenNavigation(self)
             .onAppear {
                 Task { @MainActor in

+ 2 - 2
Trio/Sources/Modules/ShortcutsConfig/ShortcutsConfigStateModel.swift

@@ -16,7 +16,7 @@ extension ShortcutsConfig {
             units = settingsManager.settings.units
 
             subscribeSetting(\.bolusShortcut, on: $maxBolusByShortcuts) {
-                maxBolusByShortcuts = ($0 == .notAllowed) ? .limitBolusMax : $0
+                maxBolusByShortcuts = ($0 == .notAllowed) ? .limitWithSafetyChecks : $0
                 allowBolusByShortcuts = ($0 != .notAllowed)
             }
 
@@ -29,7 +29,7 @@ extension ShortcutsConfig {
                         if let bs = self?.maxBolusByShortcuts {
                             self?.settingsManager.settings.bolusShortcut = bs
                         } else {
-                            self?.settingsManager.settings.bolusShortcut = .limitBolusMax
+                            self?.settingsManager.settings.bolusShortcut = .limitWithSafetyChecks
                         }
                     }
                 }

+ 1 - 0
Trio/Sources/Modules/ShortcutsConfig/View/ShortcutsConfigView.swift

@@ -80,6 +80,7 @@ extension ShortcutsConfig {
             .onAppear(perform: configureView)
             .navigationTitle("Shortcuts")
             .navigationBarTitleDisplayMode(.automatic)
+            .settingsHighlightScroll()
         }
     }
 }

+ 1 - 0
Trio/Sources/Modules/TargetBehavoir/View/TargetBehavoirRootView.swift

@@ -200,6 +200,7 @@ extension TargetBehavoir {
             }
             .navigationTitle("Target Behavior")
             .navigationBarTitleDisplayMode(.automatic)
+            .settingsHighlightScroll()
         }
 
         private var effectiveLowTTLowersSensBinding: Binding<Bool> {

+ 5 - 0
Trio/Sources/Modules/Telemetry/TelemetryDataFlow.swift

@@ -0,0 +1,5 @@
+enum Telemetry {
+    enum Config {}
+}
+
+protocol TelemetryProvider {}

+ 3 - 0
Trio/Sources/Modules/Telemetry/TelemetryProvider.swift

@@ -0,0 +1,3 @@
+extension Telemetry {
+    final class Provider: BaseProvider, TelemetryProvider {}
+}

+ 9 - 0
Trio/Sources/Modules/Telemetry/TelemetryStateModel.swift

@@ -0,0 +1,9 @@
+import Observation
+
+extension Telemetry {
+    @Observable final class StateModel: BaseStateModel<Provider> {}
+}
+
+extension Telemetry.StateModel: SettingsObserver {
+    func settingsDidChange(_: TrioSettings) {}
+}

+ 145 - 0
Trio/Sources/Modules/Telemetry/View/TelemetryMigrationSheetView.swift

@@ -0,0 +1,145 @@
+import FirebaseCrashlytics
+import SwiftUI
+
+/// One-shot sheet shown on first foreground for users who completed onboarding
+/// before telemetry existed. Mirrors the onboarding `DiagnosticsStepView`
+/// chooser but is presented standalone, with a Privacy-Policy acceptance gate
+/// and no "skip" path — the user must explicitly pick one of the three options.
+///
+/// Once dismissed, `telemetryConsentDecisionMade` is set to `true` so the sheet
+/// never re-appears for this install.
+struct TelemetryMigrationSheetView: View {
+    @Environment(\.dismiss) private var dismiss
+    @Environment(\.openURL) private var openURL
+
+    @State private var selectedOption: DiagnosticsSharingOption = .full
+    // User already accepted the Privacy Policy during onboarding. This toggle
+    // is a re-acknowledgment that the policy has been updated to cover the new
+    // telemetry section — pre-checked so Continue works out of the box; users
+    // who want to read the updated policy can uncheck and tap the link.
+    @State private var hasAcceptedPrivacyPolicy: Bool = false
+
+    var onDecision: (() -> Void)?
+
+    var body: some View {
+        NavigationView {
+            ScrollView {
+                VStack(alignment: .leading, spacing: 20) {
+                    Text("Help us improve Trio")
+                        .font(.title2)
+                        .bold()
+
+                    Text(
+                        "Until now, Trio could only sent crash reports. You can now also share anonymous usage statistics — things like your iPhone and iOS version, and which pump and CGM you have paired. This helps the Trio team prioritize what to fix and improve next."
+                    )
+                    .font(.subheadline)
+
+                    Text(
+                        "Your glucose data, therapy settings, credentials, and logs always stay on your device. Pick what you'd like to share — you can change this any time in Settings → App Diagnostics."
+                    )
+                    .font(.footnote)
+                    .foregroundColor(.secondary)
+
+                    ForEach(DiagnosticsSharingOption.allCases, id: \.self) { option in
+                        Button(action: {
+                            selectedOption = option
+                        }) {
+                            HStack(alignment: .top, spacing: 12) {
+                                Image(systemName: selectedOption == option ? "largecircle.fill.circle" : "circle")
+                                    .foregroundColor(selectedOption == option ? .accentColor : .secondary)
+                                    .imageScale(.large)
+
+                                VStack(alignment: .leading, spacing: 4) {
+                                    Text(option.displayName)
+                                        .foregroundColor(.primary)
+                                        .bold()
+                                    Text(option.caption)
+                                        .font(.footnote)
+                                        .foregroundColor(.secondary)
+                                }
+
+                                Spacer()
+                            }
+                            .padding()
+                            .background(Color(.secondarySystemBackground))
+                            .cornerRadius(10)
+                        }
+                        .buttonStyle(.plain)
+                    }
+
+                    Toggle(isOn: $hasAcceptedPrivacyPolicy) {
+                        HStack {
+                            Text("I have read and accept the")
+                            Button("Privacy Policy") {
+                                if let url = URL(string: "https://github.com/nightscout/Trio/blob/dev/PRIVACY_POLICY.md") {
+                                    openURL(url)
+                                }
+                            }
+                            .foregroundColor(.accentColor)
+                            .underline()
+                        }
+                        .font(.footnote)
+                    }
+                    .toggleStyle(CheckboxToggleStyle(tint: Color.accentColor))
+                    .disabled(selectedOption == .disabled)
+                    .opacity(selectedOption == .disabled ? 0.35 : 1)
+
+                    NavigationLink {
+                        TelemetryPreviewView()
+                    } label: {
+                        Label("See exactly what's sent", systemImage: "doc.text.magnifyingglass")
+                    }
+                    .padding(.top, 4)
+                }
+                .padding()
+
+                Spacer()
+
+                Button {
+                    confirm()
+                } label: {
+                    Text("Confirm").bold().frame(maxWidth: .infinity, minHeight: 30, alignment: .center)
+                }
+                .buttonStyle(.borderedProminent)
+                .disabled(selectedOption != .disabled && !hasAcceptedPrivacyPolicy)
+                .padding(.top)
+                .padding(.horizontal)
+            }
+            .navigationTitle("Improved Diagnostics")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .principal) {
+                    HStack(spacing: 6) {
+                        Text("NEW")
+                            .font(.caption2)
+                            .bold()
+                            .foregroundColor(.white)
+                            .padding(.horizontal, 6)
+                            .padding(.vertical, 2)
+                            .background(Color.accentColor)
+                            .clipShape(Capsule())
+                        Text("Improved Diagnostics")
+                            .font(.headline)
+                    }
+                }
+            }
+            .interactiveDismissDisabled(true)
+        }
+    }
+
+    private func confirm() {
+        let wasTelemetryOn = PropertyPersistentFlags.shared.telemetryEnabled == true
+        PropertyPersistentFlags.shared.diagnosticsSharingEnabled = selectedOption.crashlyticsEnabled
+        PropertyPersistentFlags.shared.telemetryEnabled = selectedOption.telemetryEnabled
+        PropertyPersistentFlags.shared.telemetryConsentDecisionMade = true
+        Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(selectedOption.crashlyticsEnabled)
+
+        if selectedOption.telemetryEnabled, !wasTelemetryOn {
+            TelemetryClient.shared.scheduleRecurring()
+            Task.detached { await TelemetryClient.shared.maybeSend() }
+        }
+
+        onDecision?()
+        dismiss()
+    }
+}

+ 84 - 0
Trio/Sources/Modules/Telemetry/View/TelemetryPreviewView.swift

@@ -0,0 +1,84 @@
+import SwiftUI
+
+/// Renders the exact payload that would be sent right now, with a copy button.
+/// Linked to from Settings → App Diagnostics and from the migration sheet.
+struct TelemetryPreviewView: View {
+    @State private var jsonText: String = ""
+    @State private var showResetConfirm: Bool = false
+    @State private var resetStatus: String?
+
+    var body: some View {
+        ScrollView {
+            VStack(alignment: .leading, spacing: 12) {
+                Text(
+                    "Below is the exact JSON object Trio would send right now. No glucose, insulin, carbs, credentials, or settings values are included."
+                )
+                .font(.subheadline)
+                .foregroundColor(.secondary)
+                .padding(.bottom, 4)
+
+                Text(jsonText)
+                    .font(.system(.footnote, design: .monospaced))
+                    .frame(maxWidth: .infinity, alignment: .leading)
+                    .textSelection(.enabled)
+                    .padding(8)
+                    .background(Color(.secondarySystemBackground))
+                    .cornerRadius(6)
+
+                Button {
+                    UIPasteboard.general.string = jsonText
+                } label: {
+                    Label("Copy JSON", systemImage: "doc.on.doc")
+                }
+                .buttonStyle(.bordered)
+
+                Button(role: .destructive) {
+                    showResetConfirm = true
+                } label: {
+                    Label("Reset App Attest state", systemImage: "arrow.counterclockwise.circle")
+                }
+                .buttonStyle(.bordered)
+
+                if let resetStatus {
+                    Text(resetStatus)
+                        .font(.footnote)
+                        .foregroundColor(.secondary)
+                }
+            }
+            .padding()
+        }
+        .navigationTitle("What's sent")
+        .navigationBarTitleDisplayMode(.inline)
+        .onAppear { jsonText = Self.renderPayload() }
+        .confirmationDialog(
+            "Reset App Attest state?",
+            isPresented: $showResetConfirm,
+            titleVisibility: .visible
+        ) {
+            Button("Reset and retry send", role: .destructive) {
+                TelemetryAttestor.shared.resetAttestState()
+                resetStatus = "Reset done — attempting a fresh send. Check logs for status."
+                Task { await TelemetryClient.shared.maybeSend() }
+            }
+            Button("Cancel", role: .cancel) {}
+        } message: {
+            Text(
+                "Clears the local App Attest key, registered flag, and forbidden flag. The next telemetry send will re-attest from scratch. Use only if telemetry is stuck."
+            )
+        }
+    }
+
+    private static func renderPayload() -> String {
+        let payload = TelemetryClient.shared.buildPayload()
+        guard
+            let data = try? JSONSerialization.data(
+                withJSONObject: payload,
+                options: [.prettyPrinted, .sortedKeys]
+            ),
+            let text = String(data: data, encoding: .utf8)
+        else {
+            return String(localized: "Unable to render payload.")
+        }
+        return text
+    }
+}

Разлика између датотеке није приказан због своје велике величине
+ 54 - 0
Trio/Sources/Modules/Telemetry/View/TelemetryPrivacyView.swift


+ 9 - 8
Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift

@@ -96,7 +96,7 @@ extension UserInterfaceSettings {
                             }.padding(.top)
                         }.padding(.bottom)
                     }
-                ).listRowBackground(Color.chart)
+                ).settingsSearchTarget(label: String(localized: "Appearance"))
 
                 Section {
                     VStack {
@@ -154,7 +154,7 @@ extension UserInterfaceSettings {
                             ).buttonStyle(BorderlessButtonStyle())
                         }.padding(.top)
                     }.padding(.bottom)
-                }.listRowBackground(Color.chart)
+                }.settingsSearchTarget(label: String(localized: "Glucose Color Scheme"))
 
                 Section(
                     header: Text("Home View Settings"),
@@ -189,7 +189,7 @@ extension UserInterfaceSettings {
                             }.padding(.top)
                         }.padding(.vertical)
                     }
-                ).listRowBackground(Color.chart)
+                ).settingsSearchTarget(label: String(localized: "Show X-Axis Grid Lines"))
 
                 SettingInputSection(
                     decimalValue: $decimalPlaceholder,
@@ -319,7 +319,7 @@ extension UserInterfaceSettings {
                                 ).buttonStyle(BorderlessButtonStyle())
                             }.padding(.top)
                         }.padding(.bottom)
-                    }.listRowBackground(Color.chart)
+                    }.settingsSearchTarget(label: String(localized: "Low Threshold"))
                 }
 
                 Section {
@@ -374,7 +374,7 @@ extension UserInterfaceSettings {
                             ).buttonStyle(BorderlessButtonStyle())
                         }.padding(.top)
                     }.padding(.bottom)
-                }.listRowBackground(Color.chart)
+                }.settingsSearchTarget(label: String(localized: "Forecast Display Type"))
 
                 Section {
                     VStack {
@@ -416,7 +416,7 @@ extension UserInterfaceSettings {
                             ).buttonStyle(BorderlessButtonStyle())
                         }.padding(.top)
                     }.padding(.bottom)
-                }.listRowBackground(Color.chart)
+                }.settingsSearchTarget(label: String(localized: "Bolus Display Threshold"))
 
                 Section(
                     header: Text("Trio Statistics"),
@@ -459,7 +459,7 @@ extension UserInterfaceSettings {
                             }.padding(.top)
                         }.padding(.bottom)
                     }
-                ).listRowBackground(Color.chart)
+                ).settingsSearchTarget(label: String(localized: "eA1c/GMI Display Unit"))
 
                 Section {
                     VStack(alignment: .leading) {
@@ -538,7 +538,7 @@ extension UserInterfaceSettings {
                             ).buttonStyle(BorderlessButtonStyle())
                         }.padding(.top)
                     }.padding(.bottom)
-                }.listRowBackground(Color.chart)
+                }.settingsSearchTarget(label: String(localized: "Time in Range Type"))
 
                 SettingInputSection(
                     decimalValue: $state.carbsRequiredThreshold,
@@ -600,6 +600,7 @@ extension UserInterfaceSettings {
             .onAppear(perform: configureView)
             .navigationBarTitle("User Interface")
             .navigationBarTitleDisplayMode(.automatic)
+            .settingsHighlightScroll()
         }
     }
 }

+ 1 - 0
Trio/Sources/Modules/WatchConfig/View/WatchConfigAppleWatchView.swift

@@ -68,6 +68,7 @@ struct WatchConfigAppleWatchView: BaseView {
         }
         .navigationTitle("Apple Watch")
         .navigationBarTitleDisplayMode(.automatic)
+        .settingsHighlightScroll()
         .scrollContentBackground(.hidden)
         .background(appState.trioBackgroundColor(for: colorScheme))
     }

+ 106 - 0
Trio/Sources/Services/BolusSafety/BolusSafetyValidator.swift

@@ -0,0 +1,106 @@
+import CoreData
+import Foundation
+import Swinject
+
+/// Shared safety checks applied to any bolus command originating outside the main bolus UI
+/// (remote notifications, Shortcuts, etc.). Keeps validation logic consistent across call sites.
+protocol BolusSafetyValidator {
+    /// - Parameter lookbackStart: start of the window used for the recent-bolus 20% check.
+    ///   Defaults to `now - BolusSafetyEvaluator.recentBolusWindowMinutes`. Callers that know when the
+    ///   command was originally issued (e.g. APNS payload timestamp) should pass that instead so the
+    ///   check covers any bolus since the command was sent.
+    func validate(bolusAmount: Decimal, lookbackStart: Date?) async throws -> BolusSafetyResult
+    func fetchTotalRecentBolusAmount(since date: Date) async throws -> Decimal
+}
+
+extension BolusSafetyValidator {
+    func validate(bolusAmount: Decimal) async throws -> BolusSafetyResult {
+        try await validate(bolusAmount: bolusAmount, lookbackStart: nil)
+    }
+}
+
+enum BolusSafetyResult: Equatable {
+    case allowed
+    case rejected(BolusSafetyRejection)
+}
+
+enum BolusSafetyRejection: Equatable {
+    case exceedsMaxBolus(maxBolus: Decimal)
+    case iobUnavailable
+    case exceedsMaxIOB(currentIOB: Decimal, maxIOB: Decimal)
+    case recentBolusWithinWindow(totalRecent: Decimal)
+}
+
+struct BolusSafetyInputs: Equatable {
+    let maxBolus: Decimal
+    let maxIOB: Decimal
+    let currentIOB: Decimal?
+    /// Sum of bolus amounts delivered within the recent-bolus window (see `BolusSafetyEvaluator.recentBolusWindowMinutes`).
+    let totalRecentBolus: Decimal
+}
+
+enum BolusSafetyEvaluator {
+    static let recentBolusThreshold: Decimal = 0.2
+    static let recentBolusWindowMinutes: Int = 6
+
+    static func evaluate(bolusAmount: Decimal, inputs: BolusSafetyInputs) -> BolusSafetyResult {
+        if bolusAmount > inputs.maxBolus {
+            return .rejected(.exceedsMaxBolus(maxBolus: inputs.maxBolus))
+        }
+        guard let currentIOB = inputs.currentIOB else {
+            return .rejected(.iobUnavailable)
+        }
+        if (currentIOB + bolusAmount) > inputs.maxIOB {
+            return .rejected(.exceedsMaxIOB(currentIOB: currentIOB, maxIOB: inputs.maxIOB))
+        }
+        if inputs.totalRecentBolus >= bolusAmount * recentBolusThreshold {
+            return .rejected(.recentBolusWithinWindow(totalRecent: inputs.totalRecentBolus))
+        }
+        return .allowed
+    }
+}
+
+final class BaseBolusSafetyValidator: BolusSafetyValidator, Injectable {
+    @Injected() private var settingsManager: SettingsManager!
+    @Injected() private var iobService: IOBService!
+
+    private let fetchContext: NSManagedObjectContext
+
+    init(resolver: Resolver) {
+        fetchContext = CoreDataStack.shared.newTaskContext()
+        injectServices(resolver)
+    }
+
+    func validate(bolusAmount: Decimal, lookbackStart: Date?) async throws -> BolusSafetyResult {
+        let windowStart = lookbackStart
+            ?? Date().addingTimeInterval(-Double(BolusSafetyEvaluator.recentBolusWindowMinutes * 60))
+        let inputs = BolusSafetyInputs(
+            maxBolus: settingsManager.pumpSettings.maxBolus,
+            maxIOB: settingsManager.preferences.maxIOB,
+            currentIOB: iobService.currentIOB,
+            totalRecentBolus: try await fetchTotalRecentBolusAmount(since: windowStart)
+        )
+        return BolusSafetyEvaluator.evaluate(bolusAmount: bolusAmount, inputs: inputs)
+    }
+
+    func fetchTotalRecentBolusAmount(since date: Date) async throws -> Decimal {
+        let predicate = NSPredicate(
+            format: "type == %@ AND timestamp > %@",
+            PumpEventStored.EventType.bolus.rawValue,
+            date as NSDate
+        )
+        let results: Any = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: PumpEventStored.self,
+            onContext: fetchContext,
+            predicate: predicate,
+            key: "timestamp",
+            ascending: true,
+            fetchLimit: nil,
+            propertiesToFetch: ["bolus.amount"]
+        )
+        guard let bolusDictionaries = results as? [[String: Any]] else {
+            throw CoreDataError.fetchError(function: #function, file: #file)
+        }
+        return bolusDictionaries.compactMap { ($0["bolus.amount"] as? NSNumber)?.decimalValue }.reduce(0, +)
+    }
+}

+ 22 - 45
Trio/Sources/Services/RemoteControl/TrioRemoteControl+Bolus.swift

@@ -2,42 +2,22 @@ import Foundation
 import HealthKit
 
 extension TrioRemoteControl {
-    internal func handleBolusCommand(_ payload: CommandPayload) async throws {
+    func handleBolusCommand(_ payload: CommandPayload) async throws {
         guard let bolusAmount = payload.bolusAmount else {
             await logError("Command rejected: bolus amount is missing or invalid.", payload: payload)
             return
         }
 
-        let maxBolus = await TrioApp.resolver.resolve(SettingsManager.self)?.pumpSettings.maxBolus ?? Decimal(0)
-
-        if bolusAmount > maxBolus {
-            await logError(
-                "Command rejected: bolus amount (\(bolusAmount) units) exceeds the maximum allowed (\(maxBolus) units).",
-                payload: payload
-            )
-            return
-        }
-
-        let maxIOB = settings.preferences.maxIOB
-        guard let currentIOB = iobService.currentIOB else {
-            throw CoreDataError.fetchError(function: #function, file: #file)
-        }
-        if (currentIOB + bolusAmount) > maxIOB {
-            await logError(
-                "Command rejected: bolus amount (\(bolusAmount) units) would exceed max IOB (\(maxIOB) units). Current IOB: \(currentIOB) units.",
-                payload: payload
-            )
-            return
-        }
-
-        let totalRecentBolusAmount =
-            try await fetchTotalRecentBolusAmount(since: Date(timeIntervalSince1970: payload.timestamp))
+        let validation = try await bolusSafetyValidator.validate(
+            bolusAmount: bolusAmount,
+            lookbackStart: Date(timeIntervalSince1970: payload.timestamp)
+        )
 
-        if totalRecentBolusAmount >= bolusAmount * 0.2 {
-            await logError(
-                "Command rejected: boluses totaling more than 20% of the requested amount have been delivered since the command was sent.",
-                payload: payload
-            )
+        switch validation {
+        case .allowed:
+            break
+        case let .rejected(reason):
+            await logError(reason.remoteCommandMessage(bolusAmount: bolusAmount), payload: payload)
             return
         }
 
@@ -79,22 +59,19 @@ extension TrioRemoteControl {
                 }
             }
     }
+}
 
-    private func fetchTotalRecentBolusAmount(since date: Date) async throws -> Decimal {
-        let predicate = NSPredicate(
-            format: "type == %@ AND timestamp > %@",
-            PumpEventStored.EventType.bolus.rawValue,
-            date as NSDate
-        )
-        let results: Any = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: PumpEventStored.self, onContext: pumpHistoryFetchContext, predicate: predicate, key: "timestamp",
-            ascending: true, fetchLimit: nil, propertiesToFetch: ["bolus.amount"]
-        )
-        guard let bolusDictionaries = results as? [[String: Any]] else {
-            await logError("Failed to cast fetched bolus events. Fetched entities type: \(type(of: results))")
-            throw CoreDataError.fetchError(function: #function, file: #file)
+private extension BolusSafetyRejection {
+    func remoteCommandMessage(bolusAmount: Decimal) -> String {
+        switch self {
+        case let .exceedsMaxBolus(maxBolus):
+            return "Command rejected: bolus amount (\(bolusAmount) units) exceeds the maximum allowed (\(maxBolus) units)."
+        case .iobUnavailable:
+            return "Command rejected: current IOB is not available."
+        case let .exceedsMaxIOB(currentIOB, maxIOB):
+            return "Command rejected: bolus amount (\(bolusAmount) units) would exceed max IOB (\(maxIOB) units). Current IOB: \(currentIOB) units."
+        case .recentBolusWithinWindow:
+            return "Command rejected: boluses totaling more than 20% of the requested amount have been delivered since the command was sent."
         }
-        let totalAmount = bolusDictionaries.compactMap { ($0["bolus.amount"] as? NSNumber)?.decimalValue }.reduce(0, +)
-        return totalAmount
     }
 }

+ 1 - 3
Trio/Sources/Services/RemoteControl/TrioRemoteControl.swift

@@ -10,15 +10,13 @@ class TrioRemoteControl: Injectable {
     @Injected() internal var nightscoutManager: NightscoutManager!
     @Injected() internal var overrideStorage: OverrideStorage!
     @Injected() internal var settings: SettingsManager!
-    @Injected() internal var iobService: IOBService!
+    @Injected() internal var bolusSafetyValidator: BolusSafetyValidator!
 
     private let timeWindow: TimeInterval = 600
 
-    internal let pumpHistoryFetchContext: NSManagedObjectContext
     internal let viewContext: NSManagedObjectContext
 
     private init() {
-        pumpHistoryFetchContext = CoreDataStack.shared.newTaskContext()
         viewContext = CoreDataStack.shared.persistentContainer.viewContext
         injectServices(TrioApp.resolver)
     }

+ 338 - 0
Trio/Sources/Services/Telemetry/TelemetryAttestor.swift

@@ -0,0 +1,338 @@
+import CryptoKit
+import DeviceCheck
+import Foundation
+import Swinject
+
+// MARK: - TelemetryAttestor
+
+/// Apple App Attest wrapper for the telemetry uploader. Owns:
+///   - the per-install App Attest key (generated once, persisted in Keychain)
+///   - the "this install has been registered with the server" flag (Keychain)
+///   - challenge fetch + assertion generation per send cycle
+///
+/// Designed to fail soft: if the device doesn't support App Attest
+/// (simulators, older iOS, etc.), `isSupported` is false and the caller
+/// should silently skip the send. Server-side rejections (403 from the
+/// register endpoint) are sticky — recorded in PropertyPersistentFlags so
+/// subsequent cycles don't retry indefinitely.
+///
+/// Wire protocol matches `nightscout/trio-telemetry`:
+///   1. POST /api/auth/ios/challenge       → { "challenge": "<base64url>" }
+///   2. POST /api/attest/register          (once per install)
+///   3. /checkin                           (per ping, headers below)
+final class TelemetryAttestor: Injectable {
+    static let shared = TelemetryAttestor()
+
+    @Injected() private var keychain: Keychain!
+
+    private let service = DCAppAttestService.shared
+    private let lock = NSRecursiveLock()
+    private var didInjectServices = false
+
+    private static let keyIDStorageKey = "TelemetryAttest.keyID"
+    private static let registeredStorageKey = "TelemetryAttest.registered"
+
+    private init() {}
+
+    private func injectIfNeeded() {
+        lock.lock()
+        defer { lock.unlock() }
+        guard !didInjectServices else { return }
+        injectServices(TrioApp.resolver)
+        didInjectServices = true
+    }
+
+    /// True when the running device supports App Attest. Returns false on the
+    /// simulator and on devices that lack a Secure Enclave.
+    var isSupported: Bool {
+        service.isSupported
+    }
+
+    /// True once a 403 from `/api/attest/register` has flagged this install
+    /// as permanently rejected — typically a misconfigured `app_id`. Callers
+    /// should stop attempting to send.
+    var isForbidden: Bool {
+        PropertyPersistentFlags.shared.telemetryAttestForbidden == true
+    }
+
+    // MARK: - Registration
+
+    /// Idempotent: returns immediately if already registered. Otherwise
+    /// performs `generateKey` → fetch challenge → `attestKey` → POST register.
+    /// Throws on transport / server errors; sets the sticky "forbidden" flag
+    /// on a 403 so future cycles short-circuit.
+    func registerIfNeeded(baseURL: URL) async throws {
+        injectIfNeeded()
+
+        guard isSupported else { throw AttestError.unsupportedDevice }
+        guard !isForbidden else { throw AttestError.forbidden }
+
+        if (keychain.getValue(Bool.self, forKey: Self.registeredStorageKey) ?? false) == true {
+            return
+        }
+
+        // generateKey() returns a base64url-encoded key identifier (Apple's docs).
+        // We persist it as-is for use in the assertion path below.
+        let keyID = try await currentOrCreateKeyID()
+        let challenge = try await fetchChallenge(baseURL: baseURL)
+
+        // App Attest expects a SHA-256 of the "client data" — for the
+        // attestation step, that's the challenge bytes alone.
+        let challengeBytes = Data(challenge.utf8)
+        let clientDataHash = Data(SHA256.hash(data: challengeBytes))
+
+        // Diagnostics for `attestKey` failures. We log shape, not values:
+        // keyID prefix only (the keyID is per-install and shouldn't end up in
+        // shareable logs in full). If any of these look off, the failure is
+        // ours; if they look right and Apple still rejects, the failure is
+        // server-side at Apple.
+        let keyIDPrefix = String(keyID.prefix(8))
+        debug(
+            .telemetry,
+            "attestKey input: isSupported=\(service.isSupported) keyID.count=\(keyID.count) keyID.prefix=\(keyIDPrefix) hash.count=\(clientDataHash.count) challenge.count=\(challenge.count) bundle=\(Bundle.main.bundleIdentifier ?? "nil")"
+        )
+
+        let attestationCBOR: Data
+        do {
+            attestationCBOR = try await service.attestKey(keyID, clientDataHash: clientDataHash)
+        } catch {
+            // `attestKey` is one-shot per key per device, but only on success.
+            // Branch on the DCError code so logs distinguish the recoverable
+            // cases from real failures:
+            //   .invalidKey         — keyID is permanently burnt; drop it.
+            //   .invalidInput       — Apple rejected an argument as malformed.
+            //                         In practice we see this when the keyID
+            //                         is stale (e.g. survived an uninstall via
+            //                         Keychain) and no longer matches Apple's
+            //                         expected identity for this install. Drop
+            //                         the keyID — same recovery as invalidKey.
+            //   .serverUnavailable  — Apple's App Attest backend is down or
+            //                         throttling. Key is still valid; the
+            //                         next cycle retries with the same keyID.
+            if let dcError = error as? DCError {
+                switch dcError.code {
+                case .invalidInput,
+                     .invalidKey:
+                    keychain.removeObject(forKey: Self.keyIDStorageKey)
+                    let reason = dcError.code == .invalidKey ? "invalidKey" : "invalidInput"
+                    debug(.telemetry, "attestKey \(reason): discarded keyID; will regenerate next cycle")
+                case .serverUnavailable:
+                    debug(.telemetry, "attestKey serverUnavailable: Apple App Attest backend transient — will retry next cycle")
+                default:
+                    break
+                }
+            }
+            debug(.telemetry, "attestKey failed: \(error.localizedDescription)")
+            throw AttestError.attestationFailed(error)
+        }
+
+        guard let appID = Self.currentAppID() else {
+            throw AttestError.unknownAppID
+        }
+
+        let body: [String: Any] = [
+            "attestation": attestationCBOR.base64EncodedString(),
+            "key_id": keyID,
+            "challenge": challenge,
+            "app_id": appID
+        ]
+
+        var request = URLRequest(url: baseURL.appendingPathComponent("api/attest/register"))
+        request.httpMethod = "POST"
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+        request.httpBody = try JSONSerialization.data(withJSONObject: body)
+        request.timeoutInterval = 15
+
+        let (_, response) = try await URLSession.shared.data(for: request)
+        guard let http = response as? HTTPURLResponse else {
+            throw AttestError.transportError
+        }
+
+        switch http.statusCode {
+        case 200,
+             201:
+            keychain.setValue(true, forKey: Self.registeredStorageKey)
+            debug(.telemetry, "register ok status=\(http.statusCode)")
+        case 403:
+            // app_id rejected. Sticky — flag the install and surface to caller.
+            PropertyPersistentFlags.shared.telemetryAttestForbidden = true
+            debug(.telemetry, "register forbidden — app_id=\(appID) rejected; no further attempts")
+            throw AttestError.forbidden
+        case 400 ..< 500:
+            throw AttestError.clientError(http.statusCode)
+        case 500 ..< 600:
+            throw AttestError.serverError(http.statusCode)
+        default:
+            throw AttestError.serverError(http.statusCode)
+        }
+    }
+
+    /// Clears the local App Attest state so the next `registerIfNeeded`
+    /// generates a fresh key and re-runs the handshake from scratch. Both the
+    /// keyID and the "registered" flag are dropped: `attestKey` may be called
+    /// at most once per key per device, so reusing the old keyID would throw
+    /// `DCError.invalidKey`. Use when `/checkin` returns 401 (server lost our
+    /// registration).
+    func invalidateRegistration() {
+        injectIfNeeded()
+        keychain.removeObject(forKey: Self.keyIDStorageKey)
+        keychain.removeObject(forKey: Self.registeredStorageKey)
+    }
+
+    /// Full local-state reset for stuck installs. In addition to what
+    /// `invalidateRegistration` clears, this also drops the sticky
+    /// `telemetryAttestForbidden` flag — so a tester who got 403'd and wants
+    /// to retry can do so without reinstalling. Exposed through a button in
+    /// the telemetry inspector. Does not touch consent or installId.
+    func resetAttestState() {
+        injectIfNeeded()
+        keychain.removeObject(forKey: Self.keyIDStorageKey)
+        keychain.removeObject(forKey: Self.registeredStorageKey)
+        PropertyPersistentFlags.shared.telemetryAttestForbidden = false
+        debug(.telemetry, "reset App Attest state: keyID, registered flag, and forbidden flag cleared")
+    }
+
+    // MARK: - Per-ping assertion
+
+    /// Builds the App Attest assertion for a single `/checkin` send.
+    ///
+    /// `clientDataHash` for the assertion is `SHA256(payloadBytes || challengeBytes)`.
+    /// **Order matters**: payload first, then the challenge (per the server
+    /// spec). Returns the base64-encoded assertion CBOR, the keyID (already a
+    /// base64url string), and the challenge string — all three become headers
+    /// on the outgoing request.
+    func assertion(forPayload payload: Data, baseURL: URL) async throws -> (assertion: String, keyID: String, challenge: String) {
+        injectIfNeeded()
+
+        guard isSupported else { throw AttestError.unsupportedDevice }
+        guard !isForbidden else { throw AttestError.forbidden }
+
+        let keyID = try await currentOrCreateKeyID()
+        let challenge = try await fetchChallenge(baseURL: baseURL)
+
+        var hasher = SHA256()
+        hasher.update(data: payload)
+        hasher.update(data: Data(challenge.utf8))
+        let clientDataHash = Data(hasher.finalize())
+
+        let assertionCBOR: Data
+        do {
+            assertionCBOR = try await service.generateAssertion(keyID, clientDataHash: clientDataHash)
+        } catch {
+            throw AttestError.assertionFailed(error)
+        }
+        return (assertionCBOR.base64EncodedString(), keyID, challenge)
+    }
+
+    // MARK: - Helpers
+
+    /// Reads the cached App Attest key identifier from Keychain, generating a
+    /// new one (and persisting it) on first call. The keyID is the only thing
+    /// we store — Apple holds the actual private key in the Secure Enclave.
+    private func currentOrCreateKeyID() async throws -> String {
+        if let cached = keychain.getValue(String.self, forKey: Self.keyIDStorageKey),
+           !cached.isEmpty
+        {
+            return cached
+        }
+        let newKey: String
+        do {
+            newKey = try await service.generateKey()
+        } catch {
+            throw AttestError.keyGenerationFailed(error)
+        }
+        keychain.setValue(newKey, forKey: Self.keyIDStorageKey)
+        debug(.telemetry, "generated new App Attest keyID")
+        return newKey
+    }
+
+    private func fetchChallenge(baseURL: URL) async throws -> String {
+        var request = URLRequest(url: baseURL.appendingPathComponent("api/auth/ios/challenge"))
+        request.httpMethod = "POST"
+        request.timeoutInterval = 15
+
+        let (data, response) = try await URLSession.shared.data(for: request)
+        guard let http = response as? HTTPURLResponse else {
+            throw AttestError.transportError
+        }
+        guard (200 ..< 300).contains(http.statusCode) else {
+            if (500 ..< 600).contains(http.statusCode) {
+                throw AttestError.serverError(http.statusCode)
+            }
+            throw AttestError.clientError(http.statusCode)
+        }
+
+        struct ChallengeResponse: Decodable { let challenge: String }
+        do {
+            let cr = try JSONDecoder().decode(ChallengeResponse.self, from: data)
+            return cr.challenge
+        } catch {
+            throw AttestError.malformedResponse
+        }
+    }
+
+    /// Produces the `<TEAMID>.<bundle-id>` string the server expects in
+    /// `app_id` — matches the regex `^[A-Z0-9]+\.org\.nightscout\.[^.]+\.trio$`
+    /// when the build is configured correctly.
+    ///
+    /// Reads `application-identifier` from `embedded.mobileprovision`. On iOS
+    /// the SDK doesn't expose `SecTaskCopyValueForEntitlement` to Swift, and
+    /// parsing the mobile-provision file is the standard workaround. Returns
+    /// nil for App Store builds (no embedded.mobileprovision) — which Trio
+    /// doesn't ship, so this path is fine for sideload + TestFlight.
+    static func currentAppID() -> String? {
+        guard let url = Bundle.main.url(forResource: "embedded", withExtension: "mobileprovision"),
+              let raw = try? Data(contentsOf: url)
+        else { return nil }
+
+        // The mobileprovision file is a CMS-signed envelope around a plist.
+        // Pull the plist substring between the XML prolog and `</plist>`.
+        // ISO Latin-1 maps every byte 0x00–0xFF 1:1, so the conversion never
+        // fails on the binary CMS bytes surrounding the plist — `.ascii` would
+        // return nil here.
+        guard let scanned = String(data: raw, encoding: .isoLatin1),
+              let start = scanned.range(of: "<?xml"),
+              let end = scanned.range(of: "</plist>")
+        else { return nil }
+
+        let plistString = String(scanned[start.lowerBound ..< end.upperBound])
+        guard let plistData = plistString.data(using: .utf8),
+              let plist = try? PropertyListSerialization
+              .propertyList(from: plistData, options: [], format: nil) as? [String: Any],
+              let entitlements = plist["Entitlements"] as? [String: Any],
+              let appID = entitlements["application-identifier"] as? String
+        else { return nil }
+
+        return appID
+    }
+
+    // MARK: - Errors
+
+    enum AttestError: Error, CustomStringConvertible {
+        case unsupportedDevice
+        case forbidden
+        case unknownAppID
+        case keyGenerationFailed(Error)
+        case attestationFailed(Error)
+        case assertionFailed(Error)
+        case transportError
+        case malformedResponse
+        case clientError(Int)
+        case serverError(Int)
+
+        var description: String {
+            switch self {
+            case .unsupportedDevice: return "App Attest unsupported on this device"
+            case .forbidden: return "app_id forbidden by server"
+            case .unknownAppID: return "unable to read application-identifier entitlement"
+            case let .keyGenerationFailed(e): return "generateKey failed: \(e.localizedDescription)"
+            case let .attestationFailed(e): return "attestKey failed: \(e.localizedDescription)"
+            case let .assertionFailed(e): return "generateAssertion failed: \(e.localizedDescription)"
+            case .transportError: return "non-HTTP response"
+            case .malformedResponse: return "malformed challenge response"
+            case let .clientError(code): return "client error \(code)"
+            case let .serverError(code): return "server error \(code)"
+            }
+        }
+    }
+}

+ 369 - 0
Trio/Sources/Services/Telemetry/TelemetryClient.swift

@@ -0,0 +1,369 @@
+import Foundation
+import HealthKit
+import LoopKit
+import Swinject
+import UIKit
+
+// MARK: - TelemetryClient
+
+/// Opt-out anonymous usage check-in. Sends a small JSON payload to a self-hosted
+/// endpoint at most once every 24 hours, plus once after a new build is installed.
+/// Consent is collected during onboarding (or via a one-time migration sheet for
+/// existing users) and editable in Settings → App Diagnostics.
+///
+/// No health data, credentials, or personally-identifying information is sent.
+/// See `buildPayload()` for the exact set of fields and `TelemetryPreviewView`
+/// for the in-app inspector that renders the same payload.
+final class TelemetryClient: Injectable {
+    static let shared = TelemetryClient()
+
+    // MARK: Endpoint configuration
+
+    private static let productionBaseURL: URL? = URL(string: "https://telemetry.triodocs.org")
+
+    /// Effective base URL: respects the debug override in
+    /// `PropertyPersistentFlags.telemetryDebugServerURL`, then falls back to
+    /// `productionBaseURL`. Used by both the registration and `/checkin` paths.
+    private static var baseURL: URL? {
+        if let override = PropertyPersistentFlags.shared.telemetryDebugServerURL?
+            .trimmingCharacters(in: .whitespacesAndNewlines),
+            !override.isEmpty,
+            let url = URL(string: override)
+        {
+            return url
+        }
+        return productionBaseURL
+    }
+
+    private static let weeklyInterval: TimeInterval = 7 * 24 * 60 * 60
+    private static let dailyInterval: TimeInterval = 24 * 60 * 60
+    private static let maxPayloadBytes = 4096
+
+    // MARK: Injected services
+
+    @Injected() private var apsManager: APSManager!
+    @Injected() private var fetchGlucoseManager: FetchGlucoseManager!
+    @Injected() private var settingsManager: SettingsManager!
+    @Injected() private var tidepoolManager: TidepoolManager!
+    @Injected() private var healthKitManager: HealthKitManager!
+    @Injected() private var keychain: Keychain!
+
+    private let lock = NSRecursiveLock()
+    private var didInjectServices = false
+    private var timer: DispatchTimer?
+
+    private init() {}
+
+    private func injectIfNeeded() {
+        lock.lock()
+        defer { lock.unlock() }
+        guard !didInjectServices else { return }
+        injectServices(TrioApp.resolver)
+        didInjectServices = true
+    }
+
+    // MARK: - Cold launches
+
+    /// Records a cold launch in a sliding 7-day window of timestamps. The count
+    /// of entries in the window ships as `coldLaunches7d` in every ping — a
+    /// "how often does iOS recycle this process" signal that is directly
+    /// comparable across pings regardless of the cadence between them.
+    func recordColdLaunch(now: Date = Date()) {
+        let cutoff = now.addingTimeInterval(-Self.weeklyInterval)
+        var recent = PropertyPersistentFlags.shared.telemetryColdLaunchTimes ?? []
+        recent.removeAll { $0 < cutoff }
+        recent.append(now)
+        PropertyPersistentFlags.shared.telemetryColdLaunchTimes = recent
+    }
+
+    // MARK: - Install identifier
+
+    /// Stable per-install UUID, generated lazily on first call. IDFV resets if
+    /// the user deletes every Trio-team app at once; this survives
+    /// independently and is wiped only by deleting Trio itself.
+    private func installId() -> String {
+        if let existing = PropertyPersistentFlags.shared.telemetryInstallId, !existing.isEmpty {
+            return existing
+        }
+        let new = UUID().uuidString
+        PropertyPersistentFlags.shared.telemetryInstallId = new
+        return new
+    }
+
+    // MARK: - Cadence
+
+    /// True when the running build's commit SHA differs from the SHA recorded
+    /// at the last successful send. Used at startup to fire one immediate ping
+    /// after an app update — the 24h scheduler can't notice a build change and
+    /// would otherwise wait out the previous interval.
+    func buildShaChangedSinceLastSend() -> Bool {
+        let currentSha = BuildDetails.shared.trioCommitSHA
+        return PropertyPersistentFlags.shared.telemetryLastSentSha != currentSha
+    }
+
+    /// Arms (or re-arms) the 24h send timer. Idempotent. Bails out without
+    /// scheduling if the user hasn't decided on consent yet or has opted out
+    /// — there's nothing for the timer to do.
+    func scheduleRecurring() {
+        guard PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true,
+              PropertyPersistentFlags.shared.telemetryEnabled == true
+        else {
+            return
+        }
+
+        lock.lock()
+        defer { lock.unlock() }
+
+        if timer == nil {
+            let t = DispatchTimer(timeInterval: Self.dailyInterval)
+            t.eventHandler = { [weak self] in
+                Task.detached { await self?.maybeSend() }
+            }
+            t.resume()
+            timer = t
+        }
+    }
+
+    /// Single entry point for all sends (scheduler tick, consent-yes, startup
+    /// SHA-change). Gated on consent + opt-in. *When* to send is the caller's
+    /// decision — startup handles the SHA-change shortcut, the timer handles
+    /// 24h cadence.
+    func maybeSend() async {
+        guard PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true,
+              PropertyPersistentFlags.shared.telemetryEnabled == true
+        else {
+            return
+        }
+        await send()
+    }
+
+    // MARK: - Payload
+
+    /// The exact payload that would be POSTed right now. Pure function: shared
+    /// by `send()` and `TelemetryPreviewView`.
+    func buildPayload() -> [String: Any] {
+        injectIfNeeded()
+
+        let bd = BuildDetails.shared
+        let info = Bundle.main.infoDictionary ?? [:]
+
+        var payload: [String: Any] = [:]
+
+        if let v = info["CFBundleShortVersionString"] as? String { payload["appVersion"] = v }
+        // appDevVersion is Trio's 4-component dev counter (e.g. "0.7.0.14") —
+        // the most precise build identifier we have. Always emit, even when
+        // the Info.plist key is missing, so dashboards can rely on the field.
+        payload["appDevVersion"] = Bundle.main.appDevVersion ?? "unknown"
+        payload["commitSha"] = bd.trioCommitSHA
+        payload["branch"] = bd.trioBranch
+
+        // Date-only prefix of the build-date string. Keeps the field a
+        // low-resolution build identifier, not a precise timestamp.
+        if let raw = bd.buildDateString, raw.count >= 10 {
+            payload["buildDate"] = String(raw.prefix(10))
+        }
+
+        payload["isTestFlight"] = bd.isTestFlightBuild()
+
+        if let idfv = UIDevice.current.identifierForVendor?.uuidString {
+            payload["idfv"] = idfv
+        }
+        payload["installId"] = installId()
+
+        payload["device"] = Self.hardwareIdentifier()
+        payload["platform"] = Self.detectPlatform()
+        payload["osVersion"] = UIDevice.current.systemVersion
+
+        // Pump model — omitted entirely when no pump is paired.
+        if let pump = apsManager?.pumpManager {
+            payload["pumpModel"] = pump.localizedTitle
+        }
+
+        // CGM: enum tells us the configured *type*; the live manager (if any)
+        // tells us the specific model name. Both are useful — `cgmType`
+        // distinguishes Dexcom-via-Nightscout from Dexcom-via-direct, etc.
+        let settings = settingsManager?.settings
+        payload["cgmType"] = settings?.cgm.rawValue ?? CGMType.none.rawValue
+        if let cgm = fetchGlucoseManager?.cgmManager {
+            payload["cgmModel"] = cgm.localizedTitle
+        }
+
+        // Nightscout: keys present in keychain ⇒ configured. We never include
+        // the URL or token themselves.
+        let nsUrl = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.urlKey)?
+            .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        let nsSecret = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.secretKey)?
+            .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        payload["nightscoutPaired"] = !nsUrl.isEmpty && !nsSecret.isEmpty
+
+        payload["tidepoolPaired"] = tidepoolManager?.getTidepoolServiceUI() != nil
+
+        // Apple Health: report `enabled = true` as soon as *any* per-type write
+        // permission is granted, with the full per-type breakdown in
+        // `appleHealthWrites`.
+        let appleHealthSampleTypes: [(name: String, type: HKObjectType?)] = [
+            ("glucose", AppleHealthConfig.healthBGObject),
+            ("insulin", AppleHealthConfig.healthInsulinObject),
+            ("carbs", AppleHealthConfig.healthCarbObject),
+            ("fat", AppleHealthConfig.healthFatObject),
+            ("protein", AppleHealthConfig.healthProteinObject)
+        ]
+        var writePermissions: [String: Bool] = [:]
+        for (name, type) in appleHealthSampleTypes {
+            let granted = type.flatMap { healthKitManager?.checkWriteToHealthPermissions(objectTypeToHealthStore: $0) } ?? false
+            writePermissions[name] = granted
+        }
+        payload["appleHealthEnabled"] = writePermissions.values.contains(true)
+        if !writePermissions.isEmpty {
+            payload["appleHealthWrites"] = writePermissions
+        }
+
+        if let settings = settings {
+            payload["closedLoop"] = settings.closedLoop
+            payload["units"] = settings.units.rawValue
+            payload["useLiveActivity"] = settings.useLiveActivity
+            payload["useCalendar"] = settings.useCalendar
+        }
+
+        payload["coldLaunches7d"] = (PropertyPersistentFlags.shared.telemetryColdLaunchTimes ?? []).count
+
+        // Submodule SHAs — small, useful for tracking which LoopKit / OmniBLE /
+        // etc. revision the user is on. Branch is dropped to keep payload size small.
+        let submoduleShas = bd.submodules.mapValues { $0.commitSHA }
+        if !submoduleShas.isEmpty {
+            payload["submodules"] = submoduleShas
+        }
+
+        return payload
+    }
+
+    // MARK: - Send
+
+    /// Build payload, attest it via App Attest, POST it, update last-sent state
+    /// on 2xx. Fire-and-forget; errors are logged at debug level only.
+    ///
+    /// Flow:
+    /// 1. Skip if `TelemetryAttestor.isSupported == false` (simulator, older
+    ///    devices). This is the primary opt-out for unsupported hardware —
+    ///    sending without attestation would just bounce off the server.
+    /// 2. Skip if the install has been flagged forbidden by a previous 403.
+    /// 3. Register if needed (idempotent; first launch + once on retry after
+    ///    transient failures).
+    /// 4. Serialize the payload. Reject if > 4096 bytes (server-enforced cap).
+    /// 5. Ask the attestor for an assertion over `SHA256(payload || challenge)`.
+    /// 6. POST `/checkin` with the three App Attest headers.
+    ///
+    /// Backoff: failures don't update `telemetryLastSentAt`, so the next
+    /// scheduler tick / cold launch retries naturally. The 24h cadence is the
+    /// natural backoff floor; no per-attempt exponential timer is added.
+    func send() async {
+        guard let baseURL = Self.baseURL else {
+            debug(.telemetry, "skip send: server URL not configured")
+            return
+        }
+
+        let attestor = TelemetryAttestor.shared
+        guard attestor.isSupported else {
+            debug(.telemetry, "skip send: App Attest unsupported (simulator or older device)")
+            return
+        }
+        guard !attestor.isForbidden else {
+            debug(.telemetry, "skip send: app_id previously rejected (403)")
+            return
+        }
+
+        do {
+            try await attestor.registerIfNeeded(baseURL: baseURL)
+        } catch TelemetryAttestor.AttestError.forbidden {
+            // Already logged + sticky-flagged in registerIfNeeded.
+            return
+        } catch {
+            debug(.telemetry, "register failed: \(error) — will retry next cycle")
+            return
+        }
+
+        let payload = buildPayload()
+        guard let body = try? JSONSerialization.data(withJSONObject: payload, options: []) else {
+            debug(.telemetry, "skip send: payload not JSON-serializable")
+            return
+        }
+        guard body.count <= Self.maxPayloadBytes else {
+            debug(.telemetry, "skip send: payload exceeds \(Self.maxPayloadBytes) bytes (\(body.count))")
+            return
+        }
+
+        let assertion: (assertion: String, keyID: String, challenge: String)
+        do {
+            assertion = try await attestor.assertion(forPayload: body, baseURL: baseURL)
+        } catch {
+            debug(.telemetry, "assertion failed: \(error)")
+            return
+        }
+
+        var request = URLRequest(url: baseURL.appendingPathComponent("checkin"))
+        request.httpMethod = "POST"
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+        request.setValue(assertion.keyID, forHTTPHeaderField: "X-AppAttest-KeyId")
+        request.setValue(assertion.assertion, forHTTPHeaderField: "X-AppAttest-Assertion")
+        request.setValue(assertion.challenge, forHTTPHeaderField: "X-Challenge")
+        request.httpBody = body
+        request.timeoutInterval = 15
+
+        do {
+            let (_, response) = try await URLSession.shared.data(for: request)
+            guard let http = response as? HTTPURLResponse else {
+                debug(.telemetry, "send: non-HTTP response")
+                return
+            }
+            switch http.statusCode {
+            case 200 ..< 300:
+                PropertyPersistentFlags.shared.telemetryLastSentAt = Date()
+                PropertyPersistentFlags.shared.telemetryLastSentSha = BuildDetails.shared.trioCommitSHA
+                debug(.telemetry, "send ok status=\(http.statusCode)")
+            case 401:
+                // Server doesn't recognize our registration (e.g. its registry
+                // was wiped). Drop the local keyID + registered flag so the
+                // next cycle generates a fresh key and re-attests — `attestKey`
+                // can't be re-run on the existing keyID (one-shot per Apple).
+                attestor.invalidateRegistration()
+                debug(.telemetry, "send 401: stale registration, will re-register next cycle")
+            default:
+                debug(.telemetry, "send non-2xx status=\(http.statusCode)")
+            }
+        } catch {
+            debug(.telemetry, "send error: \(error.localizedDescription)")
+        }
+    }
+
+    // MARK: - Helpers
+
+    /// `iPhone15,2`-style identifier from `utsname.machine`. Returns
+    /// `Simulator <SIMULATOR_MODEL_IDENTIFIER>` on the simulator so analysis
+    /// can ignore those rows.
+    static func hardwareIdentifier() -> String {
+        #if targetEnvironment(simulator)
+            let env = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "Unknown"
+            return "Simulator \(env)"
+        #else
+            var sys = utsname()
+            uname(&sys)
+            let mirror = Mirror(reflecting: sys.machine)
+            let machine = mirror.children.reduce(into: "") { acc, child in
+                guard let v = child.value as? Int8, v != 0 else { return }
+                acc.append(Character(UnicodeScalar(UInt8(v))))
+            }
+            return machine.isEmpty ? "Unknown" : machine
+        #endif
+    }
+
+    static func detectPlatform() -> String {
+        #if targetEnvironment(macCatalyst)
+            return "macCatalyst"
+        #else
+            switch UIDevice.current.userInterfaceIdiom {
+            case .pad: return "iPadOS"
+            default: return "iOS"
+            }
+        #endif
+    }
+}

+ 1 - 0
Trio/Sources/Shortcuts/BaseIntentsRequest.swift

@@ -17,6 +17,7 @@ import Swinject
     @Injected() var liveActivityManager: LiveActivityManager!
     @Injected() var pumpHistoryStorage: PumpHistoryStorage!
     @Injected() var iobService: IOBService!
+    @Injected() var bolusSafetyValidator: BolusSafetyValidator!
 
     let resolver: Resolver
 

+ 37 - 10
Trio/Sources/Shortcuts/Bolus/BolusIntentRequest.swift

@@ -4,25 +4,25 @@ import Foundation
 
 final class BolusIntentRequest: BaseIntentsRequest {
     func bolus(_ bolusAmount: Double) async throws -> String {
-        var bolusQuantity: Decimal = 0
         switch settingsManager.settings.bolusShortcut {
-        // Block boluses if they are disabled
         case .notAllowed:
             return String(
                 localized:
                 "Bolusing via Shortcuts is disabled in Trio settings."
             )
 
-        // Block any bolus attempted if it is larger than the max bolus in settings
-        case .limitBolusMax:
-            if Decimal(bolusAmount) > settingsManager.pumpSettings.maxBolus {
-                return String(
-                    localized:
-                    "The bolus cannot be larger than the pump setting max bolus (\(settingsManager.pumpSettings.maxBolus.description))."
+        case .limitWithSafetyChecks:
+            let requestedAmount = Decimal(bolusAmount)
+            let validation = try await bolusSafetyValidator.validate(bolusAmount: requestedAmount)
+
+            if case let .rejected(reason) = validation {
+                return reason.shortcutMessage(
+                    requestedAmount: requestedAmount,
+                    pumpMaxBolus: settingsManager.pumpSettings.maxBolus
                 )
-            } else {
-                bolusQuantity = apsManager.roundBolus(amount: Decimal(bolusAmount))
             }
+
+            let bolusQuantity = apsManager.roundBolus(amount: requestedAmount)
             await apsManager.enactBolus(amount: Double(bolusQuantity), isSMB: false, callback: nil)
             return String(
                 localized:
@@ -52,3 +52,30 @@ final class BolusIntentRequest: BaseIntentsRequest {
         }
     }
 }
+
+private extension BolusSafetyRejection {
+    func shortcutMessage(requestedAmount: Decimal, pumpMaxBolus: Decimal) -> String {
+        switch self {
+        case .exceedsMaxBolus:
+            return String(
+                localized:
+                "The bolus cannot be larger than the pump setting max bolus (\(pumpMaxBolus.description))."
+            )
+        case .iobUnavailable:
+            return String(
+                localized:
+                "Bolus blocked: current IOB is not available."
+            )
+        case let .exceedsMaxIOB(currentIOB, maxIOB):
+            return String(
+                localized:
+                "Bolus blocked: a \(requestedAmount.formatted()) U bolus would exceed max IOB (\(maxIOB.formatted()) U). Current IOB: \(currentIOB.formatted()) U."
+            )
+        case .recentBolusWithinWindow:
+            return String(
+                localized:
+                "Bolus blocked: a significant bolus was delivered within the last \(BolusSafetyEvaluator.recentBolusWindowMinutes) minutes."
+            )
+        }
+    }
+}

+ 2 - 1
Trio/Sources/Views/SettingInputSection.swift

@@ -83,7 +83,8 @@ struct SettingInputSection<VerboseHint: View>: View {
             },
             header: { headerText.map(Text.init) },
             footer: { footerText.map(Text.init) }
-        ).listRowBackground(Color.chart)
+        )
+        .settingsSearchTarget(label: label)
     }
 
     // Helper function to retrieve PickerSetting based on key

+ 73 - 0
Trio/Sources/Views/SettingsSearchHighlight.swift

@@ -0,0 +1,73 @@
+import SwiftUI
+
+@MainActor @Observable final class SettingsSearchHighlight {
+    var highlightedSetting: String?
+}
+
+/// Wraps a Screen value with the scroll-target label for search-result navigation.
+struct SearchResultTarget: Hashable {
+    let screen: Screen
+    let scrollLabel: String
+}
+
+private struct SettingsHighlightScrollModifier: ViewModifier {
+    @Environment(SettingsSearchHighlight.self) private var searchHighlight
+
+    func body(content: Content) -> some View {
+        ScrollViewReader { proxy in
+            content
+                .task(id: searchHighlight.highlightedSetting) {
+                    guard let target = searchHighlight.highlightedSetting else { return }
+                    try? await Task.sleep(for: .milliseconds(500))
+                    guard !Task.isCancelled else { return }
+                    withAnimation { proxy.scrollTo(target, anchor: .center) }
+                }
+        }
+    }
+}
+
+private struct SettingsSearchHighlightAnimationModifier: ViewModifier {
+    let label: String
+    @Environment(SettingsSearchHighlight.self) private var searchHighlight
+    @State private var highlightOpacity: Double = 0.0
+
+    func body(content: Content) -> some View {
+        content
+            .listRowBackground(
+                Color.chart.overlay(Color.accentColor.opacity(highlightOpacity))
+                    .animation(.easeOut(duration: 1.2), value: highlightOpacity)
+            )
+            .onAppear {
+                guard searchHighlight.highlightedSetting == label else { return }
+                startHighlightAnimation()
+            }
+            .onChange(of: searchHighlight.highlightedSetting) { _, newValue in
+                guard newValue == label else { return }
+                startHighlightAnimation()
+            }
+    }
+
+    private func startHighlightAnimation() {
+        Task { @MainActor in
+            try? await Task.sleep(for: .milliseconds(500))
+            highlightOpacity = 0.6
+            try? await Task.sleep(for: .milliseconds(800))
+            searchHighlight.highlightedSetting = nil
+            highlightOpacity = 0.0
+        }
+    }
+}
+
+extension View {
+    /// Enables scroll-to-highlight on a settings screen. Add once per destination view.
+    func settingsHighlightScroll() -> some View {
+        modifier(SettingsHighlightScrollModifier())
+    }
+
+    /// Marks a section as a scroll-to and highlight target for settings search.
+    /// Combines `.id(label)` with a highlight flash animation in a single call.
+    func settingsSearchTarget(label: String) -> some View {
+        id(label)
+            .modifier(SettingsSearchHighlightAnimationModifier(label: label))
+    }
+}

+ 113 - 0
TrioTests/BolusSafetyTests/BolusSafetyValidatorTests.swift

@@ -0,0 +1,113 @@
+import Foundation
+import Testing
+
+@testable import Trio
+
+@Suite("Bolus Safety Validator Tests") struct BolusSafetyValidatorTests: Injectable {
+    @Injected() var validator: BolusSafetyValidator!
+    let resolver = TrioApp().resolver
+
+    init() {
+        injectServices(resolver)
+    }
+
+    @Test("Validator resolves from the service container") func testValidatorResolves() {
+        #expect(validator != nil, "BolusSafetyValidator should be registered in ServiceAssembly")
+        #expect(validator is BaseBolusSafetyValidator, "Validator should be of type BaseBolusSafetyValidator")
+    }
+
+    @Test("Allows bolus when all inputs are within limits") func testAllowed() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 10,
+            maxIOB: 10,
+            currentIOB: 1,
+            totalRecentBolus: 0
+        )
+        #expect(BolusSafetyEvaluator.evaluate(bolusAmount: 5, inputs: inputs) == .allowed)
+    }
+
+    @Test("Rejects when amount exceeds max bolus") func testExceedsMaxBolus() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 5,
+            maxIOB: 10,
+            currentIOB: 0,
+            totalRecentBolus: 0
+        )
+        let result = BolusSafetyEvaluator.evaluate(bolusAmount: 6, inputs: inputs)
+        #expect(result == .rejected(.exceedsMaxBolus(maxBolus: 5)))
+    }
+
+    @Test("Rejects when current IOB is unavailable") func testIOBUnavailable() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 10,
+            maxIOB: 10,
+            currentIOB: nil,
+            totalRecentBolus: 0
+        )
+        let result = BolusSafetyEvaluator.evaluate(bolusAmount: 1, inputs: inputs)
+        #expect(result == .rejected(.iobUnavailable))
+    }
+
+    @Test("Rejects when amount would exceed max IOB") func testExceedsMaxIOB() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 10,
+            maxIOB: 5,
+            currentIOB: 3,
+            totalRecentBolus: 0
+        )
+        let result = BolusSafetyEvaluator.evaluate(bolusAmount: 2.5, inputs: inputs)
+        #expect(result == .rejected(.exceedsMaxIOB(currentIOB: 3, maxIOB: 5)))
+    }
+
+    @Test("Rejects when recent bolus totals >= 20% of requested amount") func testRecentBolusWithinWindow() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 10,
+            maxIOB: 10,
+            currentIOB: 0,
+            totalRecentBolus: 1.0
+        )
+        let result = BolusSafetyEvaluator.evaluate(bolusAmount: 5, inputs: inputs)
+        #expect(result == .rejected(.recentBolusWithinWindow(totalRecent: 1.0)))
+    }
+
+    @Test("Allows when recent bolus total is below 20% threshold") func testRecentBolusBelowThreshold() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 10,
+            maxIOB: 10,
+            currentIOB: 0,
+            totalRecentBolus: 0.99
+        )
+        #expect(BolusSafetyEvaluator.evaluate(bolusAmount: 5, inputs: inputs) == .allowed)
+    }
+
+    @Test("Max bolus check runs before IOB check") func testCheckOrdering() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 5,
+            maxIOB: 10,
+            currentIOB: nil,
+            totalRecentBolus: 0
+        )
+        let result = BolusSafetyEvaluator.evaluate(bolusAmount: 6, inputs: inputs)
+        #expect(result == .rejected(.exceedsMaxBolus(maxBolus: 5)))
+    }
+
+    @Test("Equal-to-max-bolus is allowed") func testEqualsMaxBolus() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 5,
+            maxIOB: 10,
+            currentIOB: 0,
+            totalRecentBolus: 0
+        )
+        #expect(BolusSafetyEvaluator.evaluate(bolusAmount: 5, inputs: inputs) == .allowed)
+    }
+
+    @Test("Current IOB plus amount equal to max IOB is allowed") func testEqualToMaxIOB() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 10,
+            maxIOB: 5,
+            currentIOB: 3,
+            totalRecentBolus: 0
+        )
+        #expect(BolusSafetyEvaluator.evaluate(bolusAmount: 2, inputs: inputs) == .allowed)
+    }
+}

+ 69 - 0
TrioTests/SettingsSearchTests.swift

@@ -0,0 +1,69 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("Settings Search Navigation") struct SettingsSearchTests {
+    @Test("Searching 'Dynamic ISF' finds the Dynamic Settings screen") func searchDynamicISF() {
+        let results = SettingItems.filteredItems(searchText: "Dynamic ISF")
+        #expect(!results.isEmpty)
+        let match = results.first { $0.matchedContent == "Dynamic ISF" }
+        #expect(match != nil)
+        #expect(match?.settingItem.view == .dynamicISF)
+        #expect(match?.scrollLabel == "Dynamic ISF")
+    }
+
+    @Test("All scrollTargetLabels have valid non-empty targets") func scrollTargetLabelsNonEmpty() {
+        for item in SettingItems.allItems {
+            guard let labels = item.scrollTargetLabels else { continue }
+            for (key, value) in labels {
+                #expect(!value.isEmpty)
+                #expect(item.searchContents?.contains(key) == true)
+            }
+        }
+    }
+
+    @Test("Every searchContents entry produces at least one result") func allSearchContentsAreSearchable() {
+        for item in SettingItems.allItems {
+            guard let contents = item.searchContents else { continue }
+            for content in contents {
+                let results = SettingItems.filteredItems(searchText: content)
+                #expect(!results.isEmpty)
+            }
+        }
+    }
+
+    @Test("SearchResultTarget is Hashable and equatable by value") func searchResultTargetHashable() {
+        let a = SearchResultTarget(screen: .dynamicISF, scrollLabel: "Dynamic ISF")
+        let b = SearchResultTarget(screen: .dynamicISF, scrollLabel: "Dynamic ISF")
+        let c = SearchResultTarget(screen: .dynamicISF, scrollLabel: "Adjust Basal")
+        #expect(a == b)
+        #expect(a != c)
+        #expect(a.hashValue == b.hashValue)
+    }
+
+    @Test("SettingsSearchHighlight starts nil and accepts assignments")
+    @MainActor func highlightStateTransitions() {
+        let highlight = SettingsSearchHighlight()
+        #expect(highlight.highlightedSetting == nil)
+
+        highlight.highlightedSetting = "Dynamic ISF"
+        #expect(highlight.highlightedSetting == "Dynamic ISF")
+
+        highlight.highlightedSetting = nil
+        #expect(highlight.highlightedSetting == nil)
+    }
+
+    @Test("SettingsSearchHighlight can be set and cleared in sequence")
+    @MainActor func highlightSequentialUpdates() async {
+        let highlight = SettingsSearchHighlight()
+
+        highlight.highlightedSetting = "First Setting"
+        #expect(highlight.highlightedSetting == "First Setting")
+
+        highlight.highlightedSetting = "Second Setting"
+        #expect(highlight.highlightedSetting == "Second Setting")
+
+        highlight.highlightedSetting = nil
+        #expect(highlight.highlightedSetting == nil)
+    }
+}