215 Commits 432e404b01 ... 3f36530db7

Autor SHA1 Mensagem Data
  github-actions[bot] 3f36530db7 CI: Bump APP_DEV_VERSION to 0.8.4.7 [skip ci] 1 dia atrás
  Deniz Cengiz d694a9fdda Merge pull request #1279 from nightscout/fix-signing 1 dia atrás
  dnzxy 6b0a477363 Fix signing, revert back to 1 dia atrás
  github-actions[bot] aa1b690579 CI: Bump APP_DEV_VERSION to 0.8.4.6 [skip ci] 2 dias atrás
  trioneer-dev bf3b87599f Merge pull request #1274 from nightscout/glucoseChart 2 dias atrás
  github-actions[bot] 2a63ad4bed CI: Bump APP_DEV_VERSION to 0.8.4.5 [skip ci] 2 dias atrás
  trioneer-dev fc7b5c7531 Merge pull request #1248 from t1dude/feat/quickbolus 2 dias atrás
  github-actions[bot] 275c4e064d CI: Bump APP_DEV_VERSION to 0.8.4.4 [skip ci] 2 dias atrás
  marv-out 531608e39e Merge pull request #1273 from nightscout/oref-swift-default 2 dias atrás
  Marvin Polscheit b5e0b947a0 Replace Image in GlucoseChartView with symbol 3 dias atrás
  Sam King f51bf3f275 Use Swift Oref by default 3 dias atrás
  Magnus Reintz bfef439fe0 Merge dev into feat/quickbolus 3 dias atrás
  Magnus Reintz 4423ada3c6 Quick bolus: add Enable Quick-Pick Boluses to settings export, group with Treatments 3 dias atrás
  github-actions[bot] 11459386bd CI: Bump APP_DEV_VERSION to 0.8.4.3 [skip ci] 3 dias atrás
  Deniz Cengiz ce4771186a Merge pull request #1269 from nightscout/reworked-alerting-to-dev 3 dias atrás
  dnzxy 742bbc63d2 Sync latest dev 3 dias atrás
  github-actions[bot] 112fe8830f CI: Bump APP_DEV_VERSION to 0.8.4.2 [skip ci] 5 dias atrás
  Deniz Cengiz b0370ba66e Merge pull request #1263 from t1dude/crowdin 5 dias atrás
  Magnus Reintz 94bb9c3127 Updated translations from Crowdin 2026-07-03 6 dias atrás
  dnzxy ef209230a4 Fix snooze alert long-press 1 semana atrás
  dnzxy a5bc3dcc96 Merge branch 'dev' of github.com:nightscout/Trio into feat/o5 1 semana atrás
  Deniz Cengiz cb9725a8d7 Merge pull request #1243 from trioneer-dev/feat/move-snooze-all-to-settings 1 semana atrás
  github-actions[bot] fc4e69d912 CI: Bump APP_DEV_VERSION to 0.8.4.1 [skip ci] 1 semana atrás
  Deniz Cengiz 98c2f0afea Merge pull request #1258 from nightscout/release/v0.8.4 1 semana atrás
  dnzxy a7eb9365cc Merge branch 'dev' of github.com:nightscout/Trio into release/v0.8.4 1 semana atrás
  Deniz Cengiz 29350e31a9 Merge pull request #1257 from nightscout/release/v0.8.4 1 semana atrás
  github-actions[bot] 88ef102db1 CI: Bump APP_DEV_VERSION to 0.8.3.14 [skip ci] 1 semana atrás
  Deniz Cengiz aee3c5d85e Merge pull request #1256 from nightscout/fix-omnipod-dose-reporting 1 semana atrás
  dnzxy 000943e2e7 Prepare patch release: bump version to 0.8.4 1 semana atrás
  dnzxy f347813119 Bump OmnipodKit, MedtrumKit, G7SensorKit 1 semana atrás
  Deniz Cengiz aad2122366 Merge branch 'dev' into fix-omnipod-dose-reporting 1 semana atrás
  dnzxy ada0e1c4e3 Bump OmnipodKit, fixes #1236 1 semana atrás
  github-actions[bot] 47e456317a CI: Bump APP_DEV_VERSION to 0.8.3.13 [skip ci] 1 semana atrás
  Sam King b40ab40b1e Merge pull request #1255 from nightscout/fix-onboarding-string 1 semana atrás
  github-actions[bot] 218c621737 CI: Bump APP_DEV_VERSION to 0.8.3.12 [skip ci] 1 semana atrás
  Deniz Cengiz edfea8c6c1 Merge pull request #1254 from nightscout/fix-applied-fetch-limits 1 semana atrás
  dnzxy 71c5e87d96 Shorten Omnipod selection string in Onboarding to avoid break 1 semana atrás
  dnzxy 8fc5e86269 Remove core data fetch limits (already time predicate limited) 1 semana atrás
  trioneer-dev 818e92bcd4 Merge branch 'feat/o5' into feat/move-snooze-all-to-settings 1 semana atrás
  dnzxy 9ac7d34f5c Bump OmnipodKit, linting, sync localizations 1 semana atrás
  dnzxy 447bf83a31 Rename files, types, structs to QuickPickBolusesXXX 1 semana atrás
  dnzxy 8317671505 Adjust spacing for quick pick boluses sheet view 1 semana atrás
  Magnus Reintz 7e47cb3954 Quick bolus: rename feature to Quick-Pick Boluses 1 semana atrás
  trioneer a57b70422f Reconcile in-app banners with delivered notifications 1 semana atrás
  trioneer e83a252fca Merge branch 'feat/move-snooze-all-to-settings' of github.com:trioneer-dev/Trio into feat/move-snooze-all-to-settings 1 semana atrás
  trioneer 37910ebeeb Rename SnoozeAllSheet to SnoozeAlertsSheetView 1 semana atrás
  trioneer 5c38a0e1db Restore long-press snooze sheet on glucose view 1 semana atrás
  dnzxy d1dccb3ed5 Fix swipe away behavior not dismissing in-app banner 1 semana atrás
  t1dude b948356c45 Quick bolus: apply code review fixes 1 semana atrás
  t1dude 8441495fb7 Quick bolus: fix settings search paths after Treatments regrouping 1 semana atrás
  t1dude ea1c7d4263 Quick bolus: apply UI review feedback 1 semana atrás
  t1dude 6a2f6c9ff6 Quick bolus: add haptic feedback on long press of + button 1 semana atrás
  github-actions[bot] 8606d41aba CI: Bump APP_DEV_VERSION to 0.8.3.11 [skip ci] 1 semana atrás
  Deniz Cengiz 033d55bc66 Merge pull request #1242 from EJM0/feat/animated-bolus-progess 1 semana atrás
  Elias 659733622e Merge branch 'dev' into feat/animated-bolus-progess 1 semana atrás
  Magnus Reintz b4f6c65744 Quick bolus: replace 'take' with 'enact' in info sheet body 1 semana atrás
  Magnus Reintz feadf999f6 Quick bolus: apply code review fixes 1 semana atrás
  Magnus Reintz d26b4afb81 Quick bolus: add enableQuickBolus to TrioSettings custom decoder so setting persists 1 semana atrás
  Magnus Reintz 60ed2faded Quick bolus: increase sheet height to 320pt to fit content on iPhone mini 1 semana atrás
  Magnus Reintz f6e7d277a3 Quick bolus: redesign sheet with pills, slide-to-enact, and settings toggle 1 semana atrás
  Deniz Cengiz 41ed82460b Merge branch 'feat/o5' into feat/move-snooze-all-to-settings 1 semana atrás
  trioneer 019d41a6a3 Rework device alert views along tester feedback 1 semana atrás
  Magnus Reintz 2fed953f3c Fix leading zero missing in quick bolus list for amounts < 1 U 1 semana atrás
  Magnus Reintz af4165dcc8 Quick bolus: replace info button with ? sheet 1 semana atrás
  Magnus Reintz 464cea0f9f Add history-based quick bolus via long-press on + button 1 semana atrás
  trioneer 8b889dad6c Merge branch 'feat/o5' of github.com:trioneer-dev/Trio into feat/o5 1 semana atrás
  trioneer 221dfd1e18 Drop stale .carbsRequired test args (moved to GlucoseAlertType) 1 semana atrás
  trioneer c03193cb3a Drop stale .carbsRequired test args (moved to GlucoseAlertType) 1 semana atrás
  trioneer 3384ae0cd9 Drop stale .carbsRequired test args (moved to GlucoseAlertType) 1 semana atrás
  Deniz Cengiz ea8dee2768 Merge branch 'dev' into feat/o5 1 semana atrás
  github-actions[bot] f14089581d CI: Bump APP_DEV_VERSION to 0.8.3.10 [skip ci] 1 semana atrás
  Deniz Cengiz 9ff64e1c58 Merge pull request #1205 from dnzxy/feat/cgm-lifetime-indicator 1 semana atrás
  Deniz Cengiz eff241c315 Merge branch 'dev' into feat/o5 1 semana atrás
  trioneer 6f3fd99dcd Extend catalog tests for CGM + Algorithm entries 1 semana atrás
  trioneer dea9c2e780 Wire PluginSource alert lookups 1 semana atrás
  trioneer 451977f948 Catalog LibreLoop CGM + Trio Algorithm alerts 1 semana atrás
  trioneer 8f751633c6 Rename Pump Alarms → Device Alarms 1 semana atrás
  github-actions[bot] b66cb43c18 CI: Bump APP_DEV_VERSION to 0.8.3.9 [skip ci] 1 semana atrás
  Sam King 6e3b30beaf Merge pull request #1247 from nightscout/update-lastHistoryToken-on-error 1 semana atrás
  Sam King 3c99ae4cfd Refresh stale persistent history token for CoreData 1 semana atrás
  trioneer 9bbba5b875 Merge branch 'feat/o5' of github.com:nightscout/Trio into feat/move-snooze-all-to-settings 1 semana atrás
  trioneer 9706cfa0ea Route CGMManager-issued alerts through TrioAlertManager 1 semana atrás
  trioneer 65634d0404 Merge branch 'dev' of github.com:nightscout/Trio into feat/o5 1 semana atrás
  trioneer 783cb99924 Bump OmnipodKit 1 semana atrás
  trioneer-dev 2214506dc3 Merge pull request #1203 from dnzxy/feat/refactor-notifications+alerts 1 semana atrás
  github-actions[bot] 4c54147dba CI: Bump APP_DEV_VERSION to 0.8.3.8 [skip ci] 1 semana atrás
  Sam King 5ee0f432d5 Merge pull request #1227 from nightscout/apsManager-part3 1 semana atrás
  Sam King b787a4b690 Merge branch 'dev' into apsManager-part3 1 semana atrás
  trioneer 50940f2283 Drop SwiftMessages SwiftPM dependency 2 semanas atrás
  trioneer c1bdf9a4c4 Strip legacy SwiftMessages alert pipeline 2 semanas atrás
  trioneer 3254e995ba Delete Snooze module and .snooze route 2 semanas atrás
  trioneer 1281fc6d8c Surface Snooze All as Settings button; drop bobble long-press 2 semanas atrás
  trioneer f9148f7763 Drop banner side-effect from Logger.info(); log-only now 2 semanas atrás
  trioneer cb4bff2725 Issue CGM-settings open-failed warnings via TrioAlertManager 2 semanas atrás
  trioneer 37fbbdf535 Issue Garmin-Connect-missing warning via TrioAlertManager 2 semanas atrás
  trioneer 365aa321f4 Issue stale-glucose calibration warning via TrioAlertManager 2 semanas atrás
  trioneer b967229053 Extract TimeInterval convenience helpers out of Snooze module 2 semanas atrás
  ejm0 9a1e15bf80 adds animation to the bolus progress bar 2 semanas atrás
  trioneer 5ca4a0bfc5 Skip not-looping alarm in open loop, manual basal, or suspension 2 semanas atrás
  trioneer 7a226177b3 Sync snooze comment refs to 15 min 2 semanas atrás
  trioneer 73d9512252 Default snooze duration 20 min → 15 min 2 semanas atrás
  github-actions[bot] 179c86398d CI: Bump APP_DEV_VERSION to 0.8.3.7 [skip ci] 2 semanas atrás
  Deniz Cengiz 034b150200 Merge pull request #1234 from bastiaanv/chore/update-medtrumkit 2 semanas atrás
  trioneer 1d72f67557 Age out simulator readings when picking a non-active scenario 2 semanas atrás
  trioneer 1b6a75e414 Stop simulator fetch when scenario isn't active 2 semanas atrás
  trioneer cfc83eb33a Read simulator lifecycle/highlight in home timer tick 2 semanas atrás
  github-actions[bot] b5e07cee1a CI: Bump APP_DEV_VERSION to 0.8.3.6 [skip ci] 2 semanas atrás
  Deniz Cengiz 82bd6236cf Merge pull request #1218 from bastiaanv/fix/manual-temp-basal-medtrumkit 2 semanas atrás
  Marvin Polscheit c7b4007c2b Merge branch 'apsManager-part3' of github.com:nightscout/Trio into apsManager-part3 2 semanas atrás
  Marvin Polscheit 3b064ecf69 Implement PR feedback 2 semanas atrás
  Marvin Polscheit d8fb6e14a2 Merge branch 'dev' of github.com:nightscout/Trio into apsManager-part3 2 semanas atrás
  bastiaanv 8c83c0afd0 chore: update MedtrumKit 2 semanas atrás
  trioneer 426c500515 Fall back to G7 sensorEndsAt during grace period so bobble matches arc 2 semanas atrás
  trioneer 679a8a0430 Auto-off + disable Use CGM App Alerts toggle when no companion owns alerts 2 semanas atrás
  trioneer 0832e4623f Surface override-Focus fact independently of sound on/off in alarm row 2 semanas atrás
  trioneer c204505601 Pass swiftformat --exclude once per path so submodule exclusion works on 0.47.11 2 semanas atrás
  trioneer 0277536d15 Pin CGMManagerAlertOwnership lookup, including the xDrip4iOS source path 2 semanas atrás
  trioneer 6f479bb3c2 Show grams for Carbs Required row; shorten CGM-app toggle footer 2 semanas atrás
  trioneer 4d38135452 Surface CGM-owned alarms in their own section with a linked app name 2 semanas atrás
  trioneer dadca6b344 Cover xDrip4iOS in CGM-owns-alerts gate; expose owning app name and deep link 2 semanas atrás
  trioneer af58edbe67 Cover isNight across DST spring-forward and fall-back 2 semanas atrás
  trioneer 6555fb4bf1 Migrate AlarmEnumMenuPicker onChange to two-parameter closure 2 semanas atrás
  trioneer 22cb43f140 Remove dev-only test-alert buttons from Pump settings 2 semanas atrás
  trioneer 0575eca675 Consume user-initiated loop flag before canStartNewLoop guard 2 semanas atrás
  trioneer 79987ee5f1 Restore Package.resolved to match origin/dev 2 semanas atrás
  github-actions[bot] ed949dc156 CI: Bump APP_DEV_VERSION to 0.8.3.5 [skip ci] 2 semanas atrás
  Sam King 0dfd6c9a5e Merge pull request #1233 from nightscout/oref-swift-cold-start-fix 2 semanas atrás
  Sam King 134a9eebe4 Make sure that autosens has a profile to work with 2 semanas atrás
  Sam King 664faceff7 Merge branch 'dev' into apsManager-part3 2 semanas atrás
  github-actions[bot] 8311bf1e72 CI: Bump APP_DEV_VERSION to 0.8.3.4 [skip ci] 2 semanas atrás
  Sam King 86a329273e Merge pull request #1226 from nightscout/apsManager-part2 2 semanas atrás
  trioneer a514b1cfbd Pin Add Alarm picker regression: fresh seed has zero available windows 2 semanas atrás
  trioneer 09c4fce5cd Block alarm picker when .always covers a type; split into Available + Unavailable 2 semanas atrás
  trioneer a224748ba2 Gate Add Alarm picker + editor window options on already-configured alarms 2 semanas atrás
  trioneer 3353cd228b Cover Carbs Required alarm type with coordinator + store + alert tests 2 semanas atrás
  trioneer 6102131634 Add Carbs Required as a configurable glucose alarm type 2 semanas atrás
  trioneer d07ca4329f Rename glucose-alarm list sections Active/Inactive to Enabled/Disabled 2 semanas atrás
  trioneer 90c696bde9 Prefix two more catalog test suites with TrioAlert: for Xcode grouping 2 semanas atrás
  trioneer 9fb9e2c572 Normalize per-pump alert-emission test suites to Trio Alert Emission prefix 2 semanas atrás
  James Woglom 0e41a559f8 Test: TandemKit message classification catalog 2 semanas atrás
  James Woglom a53e0fdda3 Test: RileyLinkKit message classification catalog 2 semanas atrás
  James Woglom d8a0ca2abf Test: OmnipodKit message classification catalog 2 semanas atrás
  James Woglom 27e6ab93f7 Test: MinimedKit message classification catalog 2 semanas atrás
  James Woglom 64cd310fa8 Test: MedtrumKit message classification catalog 2 semanas atrás
  James Woglom 74d5c08ab7 Test: DanaKit message classification catalog 2 semanas atrás
  Marvin Polscheit b64aa73628 Add expiration handler in beginBackgroundTask method 2 semanas atrás
  Marvin Polscheit 22cf1bb2e6 Swap order in execute loop function to calculate Loop end date after determination 2 semanas atrás
  Marvin Polscheit 6f011efad9 Merge branch 'dev' of github.com:nightscout/Trio into apsManager-part2 2 semanas atrás
  James Woglom 8759af7b18 Test: TandemKit alert emissions — structural no-Alert gap 2 semanas atrás
  James Woglom 49a7f22b0d Test: RileyLinkKit alert emissions vs registry routing 2 semanas atrás
  James Woglom be8b94623f Test: OmnipodKit alert emissions vs registry routing 2 semanas atrás
  James Woglom 9c672969ba Test: MinimedKit alert emissions — managerIdentifier mismatch gap 2 semanas atrás
  James Woglom 7982ea54ed Test: MedtrumKit alert emissions vs registry routing 2 semanas atrás
  James Woglom 6c399013eb Test: DanaKit alert emissions vs registry routing 2 semanas atrás
  James Woglom 09f9d83841 Test: AlertCatalogRegistry escalation table + Medtrum occlusion typo 2 semanas atrás
  James Woglom a2acb9d6e0 Test: NotLoopingMonitor re-arm/retract 2 semanas atrás
  James Woglom 23023c9dd1 Test: TrioModalAlertScheduler banner gates 2 semanas atrás
  James Woglom 1e2fc8d41f Test: GlucoseAlertCoordinator breach/retract hysteresis 2 semanas atrás
  James Woglom c1471053db Test: DeviceAlertsStore per-tier snooze 2 semanas atrás
  James Woglom c332c9b49b Test: TrioAlertCategory severity + dwell mapping 2 semanas atrás
  James Woglom 7b7002cd82 Test: GlucoseAlert safety gate and decoder defaults 2 semanas atrás
  James Woglom 7d803b8258 Test: GlucoseAlertConfiguration day/night window resolution 2 semanas atrás
  James Woglom 9a04647927 Test: ForecastedGlucoseEvaluator min-curve selection 2 semanas atrás
  James Woglom 837ac4444b Fix build: drop stray CGM-lifetime fragment from HomeRootView 2 semanas atrás
  Deniz Cengiz ce0420dc9f Merge branch 'feat/o5' into feat/refactor-notifications+alerts 2 semanas atrás
  dnzxy e2067f53f3 Move OmnipodKit to dev branch (latest) 2 semanas atrás
  Deniz Cengiz 9bead7bc67 Merge branch 'feat/o5' into feat/refactor-notifications+alerts 2 semanas atrás
  trioneer aed7308f55 Rewrite forecasted-low alert copy and rename type to Low Glucose Soon 2 semanas atrás
  github-actions[bot] dbc1026617 CI: Bump APP_DEV_VERSION to 0.8.3.3 [skip ci] 2 semanas atrás
  Deniz Cengiz 0bc038ccdb Merge pull request #1225 from nightscout/apsManager-part1 2 semanas atrás
  Marvin Polscheit aa39571ab9 APSManager thread safety fixes Part 3: BolusProgressState actor 2 semanas atrás
  Marvin Polscheit 4492f6cbd2 APSManager thread safety fixes Part 2: LoopGuard actor + background-task lifecycle 2 semanas atrás
  Marvin Polscheit e274ed1d9c APSManager Thread Safety Fixes Part 1 2 semanas atrás
  trioneer bd3b71ac0d Replace dead TrioAlertClassifier tests with catalog Omni-fault parser tests 2 semanas atrás
  trioneer 1890f0ac55 Fix ambiguous '/' operator on CGFloat opacity divisor 2 semanas atrás
  Deniz Cengiz 1d007796e2 Merge branch 'dev' into feat/refactor-notifications+alerts 2 semanas atrás
  Deniz Cengiz 26aa7249c4 Merge branch 'dev' into feat/cgm-lifetime-indicator 2 semanas atrás
  trioneer 76f2315199 Route carbs-required through TrioAlertManager + retract when resolved 2 semanas atrás
  trioneer 4d8cf554b6 Widen glucose alarm range floor to 54 mg/dL 2 semanas atrás
  trioneer 00bdcb84be Replace substring alert classifier with catalog-based routing 2 semanas atrás
  trioneer ecee548dcb WIP: bridge CGMManager.providesOwnGlucoseAlerts until LoopKit next-dev bump 2 semanas atrás
  trioneer cb52e8da3b Scale bobble to 0.9 only on bobble path; compact + empty states render at 1.0 2 semanas atrás
  trioneer b3ffb547be Surface CGM status imageName as compact bobble fallback for non-warmup error states 2 semanas atrás
  trioneer 7a2242bb14 Drop threshold echo from glucose alarm body text 2 semanas atrás
  trioneer a74533e512 Add time-sensitive UN entitlement so alerts pierce Focus modes 2 semanas atrás
  trioneer baf03b68a0 Emit updatePublisher on single-reading stores so alarm coordinator wakes up 2 semanas atrás
  trioneer aef2a5a20e Gate the remaining-time tag on the same 48h window as the arc 2 semanas atrás
  trioneer 98eda7658f Bump CGMBLEKit pointer to G5/G6 lifecycle + Anubis detection branch 2 semanas atrás
  trioneer 599955c42b Surface CGM lifecycle on the bobble: 48h arc/tag gate, warmup countdown, timer-driven status refresh 2 semanas atrás
  trioneer 0b315cbe5e WIP: xDrip extended app-group dict parsing 2 semanas atrás
  bastiaanv 713f9912df fix: Always check manual temp basal with any PM 3 semanas atrás
  Deniz Cengiz f39e9f4994 Fix G5/G6 sensor-expiry math and home tag layout 3 semanas atrás
  Deniz Cengiz 567dc0bb6c Merge branch 'dev' of github.com:nightscout/Trio into feat/cgm-lifetime-indicator 3 semanas atrás
  Deniz Cengiz 68405f7b04 Merge branch 'dev' of github.com:nightscout/Trio into feat/refactor-notifications+alerts 3 semanas atrás
  Deniz Cengiz 8472f55f18 Alerts: rename Device Alarms → Pump & CGM Alarms 3 semanas atrás
  Deniz Cengiz bbf4dd9a5f Alerts: per-type/category snooze, Snooze All header, fix coordinator CD crash, skip loop with no pump 3 semanas atrás
  Deniz Cengiz 1a966cce6f Home: CGM sensor lifetime indicator (arc + tag) on glucose bobble 3 semanas atrás
  Deniz Cengiz 9fdd461417 Alerts: lock urgent-low + Critical tier as always-armed, glucose alerts on by default 3 semanas atrás
  Deniz Cengiz 604289d577 Alerts: MedtrumKit slug coverage (jbr7rr/MedtrumKit#147) + tests 3 semanas atrás
  Deniz Cengiz b895debe33 Alerts: add unit tests for classifier, muter, device alerts store 3 semanas atrás
  Deniz Cengiz 8ea5b4ccf4 Merge branch 'dev' of github.com:nightscout/Trio into feat/refactor-notifications+alerts 3 semanas atrás
  Deniz Cengiz 8b4ef92632 Sync localizations, linting 3 semanas atrás
  Deniz Cengiz c8cd4f8265 Alerts: rename isAlertWorthy → shouldFireImmediately, tighten critical-tier copy 3 semanas atrás
  Deniz Cengiz b17125675b Alerts: PR review follow-ups (title suffixes, locale time, freshness window, audio loops until ack) 3 semanas atrás
  Deniz Cengiz dc8060c315 Alerts: PR review fixes (alertId enum prop, podexpiring → reminder, drop banner swipe) 3 semanas atrás
  Deniz Cengiz d5b98a0190 Add algorithmError; sync locales 3 semanas atrás
  Deniz Cengiz 4d32b6b7f6 Alerts: launch quiet window, stale-timer guard, snooze suppresses pending banners 3 semanas atrás
  Deniz Cengiz 3355cb62db Cleanup: drop dead allowNotify / removeGlucoseNotifications 3 semanas atrás
  Deniz Cengiz 956405cfc0 Snooze: unify across pipeline + in-app banner affordances 3 semanas atrás
  Deniz Cengiz 9b70f4c25a Alarm windows, per-variant toggle, not-looping pipeline, UN cleanup 3 semanas atrás
  Deniz Cengiz 57ee5f06f5 Alert taxonomy: broaden categories to cover CGM + uncertain delivery 3 semanas atrás
  Deniz Cengiz 271022d33a Device Alarms: tiered config + force-loop dwell bypass 3 semanas atrás
  Deniz Cengiz 380c672d8b Glucose Alarms: user-configurable list of alarms 3 semanas atrás
  Deniz Cengiz cdfbfd9330 Alerts: critical-audio fallback, snooze unification, forecasted-low 4 semanas atrás
  Deniz Cengiz ec30ee2dae Introduce new alert system to Trio 4 semanas atrás
100 arquivos alterados com 5210 adições e 1269 exclusões
  1. 1 1
      CGMBLEKit
  2. 2 2
      Config.xcconfig
  3. 1 1
      G7SensorKit
  4. 1 1
      MedtrumKit
  5. 20 0
      Model/CoreDataStack.swift
  6. 1 1
      OmnipodKit
  7. 501 178
      Trio.xcodeproj/project.pbxproj
  8. 17 35
      Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved
  9. BIN
      Trio/Resources/Sounds/alarm.caf
  10. BIN
      Trio/Resources/Sounds/bloom.caf
  11. BIN
      Trio/Resources/Sounds/bloop.caf
  12. BIN
      Trio/Resources/Sounds/bright_alarm.caf
  13. BIN
      Trio/Resources/Sounds/chime.caf
  14. BIN
      Trio/Resources/Sounds/clear_chimes.caf
  15. BIN
      Trio/Resources/Sounds/critical.caf
  16. BIN
      Trio/Resources/Sounds/crying.caf
  17. BIN
      Trio/Resources/Sounds/dings.caf
  18. BIN
      Trio/Resources/Sounds/high_chimes.caf
  19. BIN
      Trio/Resources/Sounds/honk.caf
  20. BIN
      Trio/Resources/Sounds/minimal.caf
  21. BIN
      Trio/Resources/Sounds/mood_synth.caf
  22. BIN
      Trio/Resources/Sounds/simple.caf
  23. BIN
      Trio/Resources/Sounds/spring.caf
  24. BIN
      Trio/Resources/Sounds/synth.caf
  25. BIN
      Trio/Resources/Sounds/trill.caf
  26. BIN
      Trio/Resources/Sounds/urgent_low.caf
  27. 2 0
      Trio/Resources/Trio.entitlements
  28. 303 172
      Trio/Sources/APS/APSManager.swift
  29. 98 1
      Trio/Sources/APS/CGM/AppGroupSource.swift
  30. 228 0
      Trio/Sources/APS/CGM/GlucoseSimulatorSource.swift
  31. 4 0
      Trio/Sources/APS/CGM/GlucoseSource.swift
  32. 59 9
      Trio/Sources/APS/CGM/PluginSource.swift
  33. 21 57
      Trio/Sources/APS/DeviceDataManager.swift
  34. 28 1
      Trio/Sources/APS/FetchGlucoseManager.swift
  35. 60 54
      Trio/Sources/APS/OpenAPS/OpenAPS.swift
  36. 3 3
      Trio/Sources/APS/Storage/GlucoseStorage.swift
  37. 1 2
      Trio/Sources/APS/Storage/PumpHistoryStorage.swift
  38. 3 0
      Trio/Sources/Application/TrioApp.swift
  39. 6 0
      Trio/Sources/Assemblies/ServiceAssembly.swift
  40. 8 0
      Trio/Sources/Helpers/BasalDeliveryState+Extension.swift
  41. 1 1
      Trio/Sources/Helpers/Formatters.swift
  42. 29 0
      Trio/Sources/Helpers/TimeInterval+Convenience.swift
  43. 606 19
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  44. 1 47
      Trio/Sources/Logger/Logger.swift
  45. 0 2
      Trio/Sources/Models/DecimalPickerSettings.swift
  46. 34 0
      Trio/Sources/Models/GlucoseAlerts/AlarmSoundCatalog.swift
  47. 33 0
      Trio/Sources/Models/GlucoseAlerts/DayNightOptions.swift
  48. 79 0
      Trio/Sources/Models/GlucoseAlerts/DeviceAlertSeverity.swift
  49. 63 0
      Trio/Sources/Models/GlucoseAlerts/DeviceAlertSeverityConfig.swift
  50. 77 0
      Trio/Sources/Models/GlucoseAlerts/GlucoseAlert.swift
  51. 63 0
      Trio/Sources/Models/GlucoseAlerts/GlucoseAlertConfiguration.swift
  52. 103 0
      Trio/Sources/Models/GlucoseAlerts/GlucoseAlertType.swift
  53. 0 27
      Trio/Sources/Models/GlucoseNotificationsOption.swift
  54. 10 0
      Trio/Sources/Models/GlucoseSourceKey.swift
  55. 11 9
      Trio/Sources/Models/NotificationIdentifiers.swift
  56. 8 49
      Trio/Sources/Models/TrioSettings.swift
  57. 7 0
      Trio/Sources/Modules/AlarmWindows/AlarmWindowsDataFlow.swift
  58. 5 0
      Trio/Sources/Modules/AlarmWindows/AlarmWindowsProvider.swift
  59. 18 0
      Trio/Sources/Modules/AlarmWindows/AlarmWindowsStateModel.swift
  60. 135 0
      Trio/Sources/Modules/AlarmWindows/View/AlarmWindowsRootView.swift
  61. 3 3
      Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsStateModel.swift
  62. 7 8
      Trio/Sources/Modules/AlgorithmAdvancedSettings/View/AlgorithmAdvancedSettingsRootView.swift
  63. 39 0
      Trio/Sources/Modules/CGMSettings/CGMSettingsDataFlow.swift
  64. 66 10
      Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift
  65. 27 1
      Trio/Sources/Modules/Calibrations/CalibrationsStateModel.swift
  66. 7 0
      Trio/Sources/Modules/DeviceAlarms/DeviceAlarmsDataFlow.swift
  67. 5 0
      Trio/Sources/Modules/DeviceAlarms/DeviceAlarmsProvider.swift
  68. 8 0
      Trio/Sources/Modules/DeviceAlarms/DeviceAlarmsStateModel.swift
  69. 65 0
      Trio/Sources/Modules/DeviceAlarms/View/AddDeviceAlarmSheet.swift
  70. 129 0
      Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmEditorView.swift
  71. 226 0
      Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmsRootView.swift
  72. 7 0
      Trio/Sources/Modules/GlucoseAlerts/GlucoseAlertsDataFlow.swift
  73. 5 0
      Trio/Sources/Modules/GlucoseAlerts/GlucoseAlertsProvider.swift
  74. 36 0
      Trio/Sources/Modules/GlucoseAlerts/GlucoseAlertsStateModel.swift
  75. 87 0
      Trio/Sources/Modules/GlucoseAlerts/View/AddGlucoseAlertSheet.swift
  76. 23 0
      Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmActiveSection.swift
  77. 168 0
      Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmAudioSection.swift
  78. 54 0
      Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmBGSection.swift
  79. 39 0
      Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmEnumMenuPicker.swift
  80. 47 0
      Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmGramsSection.swift
  81. 44 0
      Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmWindowIcon.swift
  82. 191 0
      Trio/Sources/Modules/GlucoseAlerts/View/GlucoseAlertEditorView.swift
  83. 345 0
      Trio/Sources/Modules/GlucoseAlerts/View/GlucoseAlertsRootView.swift
  84. 0 5
      Trio/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsDataFlow.swift
  85. 0 3
      Trio/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsProvider.swift
  86. 0 53
      Trio/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsStateModel.swift
  87. 0 389
      Trio/Sources/Modules/GlucoseNotificationSettings/View/GlucoseNotificationSettingsRootView.swift
  88. 3 0
      Trio/Sources/Modules/Home/HomeProvider.swift
  89. 226 3
      Trio/Sources/Modules/Home/HomeStateModel.swift
  90. 31 26
      Trio/Sources/Modules/Home/View/Chart/ChartElements/GlucoseChartView.swift
  91. 233 72
      Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift
  92. 1 1
      Trio/Sources/Modules/Home/View/Header/LoopStatusView.swift
  93. 22 0
      Trio/Sources/Modules/Home/View/Header/SensorLifecycle/CGMSensorDisplayState.swift
  94. 55 0
      Trio/Sources/Modules/Home/View/Header/SensorLifecycle/SensorLifecycleArcView.swift
  95. 56 0
      Trio/Sources/Modules/Home/View/Header/SensorLifecycle/SensorStatusTagView.swift
  96. 62 23
      Trio/Sources/Modules/Home/View/HomeRootView.swift
  97. 35 0
      Trio/Sources/Modules/Home/View/QuickPickBoluses/QuickPickBolusesInfoView.swift
  98. 149 0
      Trio/Sources/Modules/Home/View/QuickPickBoluses/QuickPickBolusesView.swift
  99. 58 0
      Trio/Sources/Modules/Home/View/QuickPickBoluses/SlideToConfirmView.swift
  100. 0 0
      Trio/Sources/Modules/LiveActivitySettings/View/LiveActivityWidgetConfiguration.swift

+ 1 - 1
CGMBLEKit

@@ -1 +1 @@
-Subproject commit 98fae7929c8c8e4e849d18a70c1f249dd6c09e5f
+Subproject commit 26ad72115d3b98b05668d57b817b1bf961f8a721

+ 2 - 2
Config.xcconfig

@@ -18,8 +18,8 @@ BUNDLE_IDENTIFIER = org.nightscout.$(DEVELOPMENT_TEAM).trio
 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.8.3
-APP_DEV_VERSION = 0.8.3.2
+APP_VERSION = 0.8.4
+APP_DEV_VERSION = 0.8.4.7
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 

+ 1 - 1
G7SensorKit

@@ -1 +1 @@
-Subproject commit 4d0780db06c7c95b3a3bf3cdb2f2838d521e411a
+Subproject commit 0c87905297b70a7f0fa41e87fa0fbe1efff6c29f

+ 1 - 1
MedtrumKit

@@ -1 +1 @@
-Subproject commit 7a3cb276b65ebaee7a3d6485878baa525399c3ee
+Subproject commit 3ad0979a3c8fc36c2d48a76da8ea70ef2132943a

+ 20 - 0
Model/CoreDataStack.swift

@@ -113,11 +113,31 @@ class CoreDataStack: ObservableObject {
     func fetchPersistentHistory() async {
         do {
             try await fetchPersistentHistoryTransactionsAndChanges()
+        } catch let error as NSError where error.code == NSPersistentHistoryTokenExpiredError {
+            debug(.coreData, "Persistent history token expired; clearing token and replaying available history.")
+            await recoverFromExpiredHistoryToken()
         } catch {
             debug(.coreData, "\(error)")
         }
     }
 
+    /// Recovers the change-merge pipeline after the persistent history token expires.
+    private func recoverFromExpiredHistoryToken() async {
+        lastToken = nil
+        do {
+            try await fetchPersistentHistoryTransactionsAndChanges()
+        } catch {
+            debug(.coreData, "Replay after token reset failed; jumping to current token. \(error)")
+            let viewContext = persistentContainer.viewContext
+            let currentToken = persistentContainer.persistentStoreCoordinator
+                .currentPersistentHistoryToken(fromStores: nil)
+            lastToken = currentToken
+            await viewContext.perform {
+                viewContext.refreshAllObjects()
+            }
+        }
+    }
+
     private func fetchPersistentHistoryTransactionsAndChanges() async throws {
         let taskContext = newTaskContext()
         taskContext.name = "persistentHistoryContext"

+ 1 - 1
OmnipodKit

@@ -1 +1 @@
-Subproject commit 4d254498cf016806b3e0ec33dd5fbfdf6e2135a6
+Subproject commit b5b95562f73e6d3627e3e20ffe31f8d7f6a4639e

Diferenças do arquivo suprimidas por serem muito extensas
+ 501 - 178
Trio.xcodeproj/project.pbxproj


+ 17 - 35
Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved

@@ -1,5 +1,5 @@
 {
-  "originHash" : "1e72c1cdf8ea5ec9fe527ebfab01ea55fca9e8651fe3252338fd3d4ea2cb327a",
+  "originHash" : "af604046297daf839c0c212e73929960a33a0fecfdb97b64b9356d8a98b7bd60",
   "pins" : [
     {
       "identity" : "abseil-cpp-binary",
@@ -69,17 +69,8 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/firebase/firebase-ios-sdk.git",
       "state" : {
-        "revision" : "fdc352fabaf5916e7faa1f96ad02b1957e93e5a5",
-        "version" : "11.15.0"
-      }
-    },
-    {
-      "identity" : "google-ads-on-device-conversion-ios-sdk",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
-      "state" : {
-        "revision" : "a2d0f1f1666de591eb1a811f40b1706f5c63a2ed",
-        "version" : "2.3.0"
+        "revision" : "d1f7c7e8eaa74d7e44467184dc5f592268247d33",
+        "version" : "11.11.0"
       }
     },
     {
@@ -87,8 +78,8 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/google/GoogleAppMeasurement.git",
       "state" : {
-        "revision" : "45ce435e9406d3c674dd249a042b932bee006f60",
-        "version" : "11.15.0"
+        "revision" : "dd89fc79a77183830742a16866d87e4e54785734",
+        "version" : "11.11.0"
       }
     },
     {
@@ -105,8 +96,8 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/google/GoogleUtilities.git",
       "state" : {
-        "revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
-        "version" : "8.1.0"
+        "revision" : "53156c7ec267db846e6b64c9f4c4e31ba4cf75eb",
+        "version" : "8.0.2"
       }
     },
     {
@@ -114,8 +105,8 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/google/grpc-binary.git",
       "state" : {
-        "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
-        "version" : "1.69.1"
+        "revision" : "cc0001a0cf963aa40501d9c2b181e7fc9fd8ec71",
+        "version" : "1.69.0"
       }
     },
     {
@@ -123,8 +114,8 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/google/gtm-session-fetcher.git",
       "state" : {
-        "revision" : "c756a29784521063b6a1202907e2cc47f41b667c",
-        "version" : "4.5.0"
+        "revision" : "4d70340d55d7d07cc2fdf8e8125c4c126c1d5f35",
+        "version" : "4.4.0"
       }
     },
     {
@@ -222,8 +213,8 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/apple/swift-log.git",
       "state" : {
-        "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523",
-        "version" : "1.10.1"
+        "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2",
+        "version" : "1.6.4"
       }
     },
     {
@@ -240,8 +231,8 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/apple/swift-protobuf.git",
       "state" : {
-        "revision" : "a008af1a102ff3dd6cc3764bb69bf63226d0f5f6",
-        "version" : "1.36.1"
+        "revision" : "d72aed98f8253ec1aa9ea1141e28150f408cf17f",
+        "version" : "1.29.0"
       }
     },
     {
@@ -263,21 +254,12 @@
       }
     },
     {
-      "identity" : "swiftmessages",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/SwiftKickMobile/SwiftMessages",
-      "state" : {
-        "revision" : "62e12e138fc3eedf88c7553dd5d98712aa119f40",
-        "version" : "9.0.9"
-      }
-    },
-    {
       "identity" : "swinject",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/Swinject/Swinject",
       "state" : {
-        "revision" : "b685b549fe4d8ae265fc7a2f27d0789720425d69",
-        "version" : "2.10.0"
+        "revision" : "be9dbcc7b86811bc131539a20c6f9c2d3e56919f",
+        "version" : "2.9.1"
       }
     },
     {

BIN
Trio/Resources/Sounds/alarm.caf


BIN
Trio/Resources/Sounds/bloom.caf


BIN
Trio/Resources/Sounds/bloop.caf


BIN
Trio/Resources/Sounds/bright_alarm.caf


BIN
Trio/Resources/Sounds/chime.caf


BIN
Trio/Resources/Sounds/clear_chimes.caf


BIN
Trio/Resources/Sounds/critical.caf


BIN
Trio/Resources/Sounds/crying.caf


BIN
Trio/Resources/Sounds/dings.caf


BIN
Trio/Resources/Sounds/high_chimes.caf


BIN
Trio/Resources/Sounds/honk.caf


BIN
Trio/Resources/Sounds/minimal.caf


BIN
Trio/Resources/Sounds/mood_synth.caf


BIN
Trio/Resources/Sounds/simple.caf


BIN
Trio/Resources/Sounds/spring.caf


BIN
Trio/Resources/Sounds/synth.caf


BIN
Trio/Resources/Sounds/trill.caf


BIN
Trio/Resources/Sounds/urgent_low.caf


+ 2 - 0
Trio/Resources/Trio.entitlements

@@ -14,6 +14,8 @@
 	<array>
 		<string>TAG</string>
 	</array>
+	<key>com.apple.developer.usernotifications.time-sensitive</key>
+	<true/>
 	<key>com.apple.security.application-groups</key>
 	<array>
 		<string>$(APP_GROUP_ID)</string>

+ 303 - 172
Trio/Sources/APS/APSManager.swift

@@ -8,6 +8,11 @@ import Swinject
 
 protocol APSManager {
     func heartbeat(date: Date)
+    /// Mark the next loop attempt as user-initiated (e.g. force-loop button).
+    /// Surfaces transient errors immediately instead of waiting for the
+    /// usual dwell threshold — when the user explicitly asks for a loop,
+    /// they want feedback even if the underlying error is "transient".
+    func markNextLoopUserInitiated()
     func enactBolus(amount: Double, isSMB: Bool, callback: ((Bool, String) -> Void)?) async
     var pumpManager: PumpManagerUI? { get set }
     var bluetoothManager: BluetoothStateManager? { get }
@@ -36,6 +41,13 @@ protocol APSManager {
     var iobFileDidUpdate: PassthroughSubject<Void, Never> { get }
 }
 
+/// Notified after a bolus-related failure so observing UI (e.g. the
+/// treatment screen's bolus state) can clean up state. Broadcast by
+/// `APSManager` from `enactBolus` / `cancelBolus` error paths.
+protocol BolusFailureObserver {
+    func bolusDidFail()
+}
+
 enum APSError: LocalizedError {
     case pumpError(Error)
     case invalidPumpState(message: String)
@@ -57,16 +69,27 @@ enum APSError: LocalizedError {
             return String(localized: "Manual Temporary Basal Rate (\(message)). Looping suspended.")
         }
     }
+}
 
-    static func pumpErrorMatches(message: String) -> Bool {
-        message.contains(String(localized: "Pump Error"))
+// MARK: - Thread-safe loop serialization
+
+/// Ensures only one loop runs at a time via actor isolation
+private actor LoopGuard {
+    private var isRunning = false
+
+    /// Atomically checks whether a new loop can start and marks it as running if so.
+    func tryStart(minInterval: TimeInterval, lastLoopDate: Date, lastLoopStartDate: Date) -> Bool {
+        // If the last loop completed after it started, enforce minimum interval
+        if lastLoopDate > lastLoopStartDate {
+            guard lastLoopStartDate.addingTimeInterval(minInterval) < Date() else { return false }
+        }
+        guard !isRunning else { return false }
+        isRunning = true
+        return true
     }
 
-    static func pumpWarningMatches(message: String) -> Bool {
-        message.contains(String(localized: "Invalid Pump State")) || message
-            .contains("PumpMessage") || message
-            .contains("PumpOpsError") || message.contains("RileyLink") || message
-            .contains(String(localized: "Pump did not respond in time"))
+    func finish() {
+        isRunning = false
     }
 }
 
@@ -82,6 +105,7 @@ final class BaseAPSManager: APSManager, Injectable {
     @Injected() private var settingsManager: SettingsManager!
     @Injected() private var tddStorage: TDDStorage!
     @Injected() private var broadcaster: Broadcaster!
+    @Injected() private var trioAlertManager: TrioAlertManager!
     @Persisted(key: "lastLoopStartDate") private var lastLoopStartDate: Date = .distantPast
     @Persisted(key: "lastLoopDate") var lastLoopDate: Date = .distantPast {
         didSet {
@@ -89,14 +113,18 @@ final class BaseAPSManager: APSManager, Injectable {
         }
     }
 
-    let viewContext = CoreDataStack.shared.persistentContainer.viewContext
     let privateContext = CoreDataStack.shared.newTaskContext()
 
     private var openAPS: OpenAPS!
 
     private var lifetime = Lifetime()
 
-    private var backgroundTaskID: UIBackgroundTaskIdentifier?
+    private let loopGuard = LoopGuard()
+    /// All reads/writes are dispatched onto `processQueue` so the bolus
+    /// trigger sink, `cancelBolus`, and the `DoseProgressReporter`
+    /// callback (which the pump manager already invokes on
+    /// `processQueue`) all serialize through one queue
+    private var bolusReporter: DoseProgressReporter?
 
     var pumpManager: PumpManagerUI? {
         get { deviceDataManager.pumpManager }
@@ -156,7 +184,7 @@ final class BaseAPSManager: APSManager, Injectable {
             if wasParsed {
                 Task {
                     do {
-                        try await openAPS.createProfiles(useSwiftOref: settings.useSwiftOref)
+                        try await openAPS.createProfiles(useJavascriptOref: settings.useJavascriptOref)
                     } catch {
                         debug(
                             .apsManager,
@@ -178,46 +206,48 @@ final class BaseAPSManager: APSManager, Injectable {
         deviceDataManager.errorSubject
             .receive(on: processQueue)
             .map { APSError.pumpError($0) }
-            .sink {
-                self.processError($0)
+            .sink { [weak self] in
+                self?.processError($0)
             }
             .store(in: &lifetime)
 
         deviceDataManager.bolusTrigger
             .receive(on: processQueue)
-            .sink { bolusing in
+            .sink { [weak self] bolusing in
                 if bolusing {
-                    self.createBolusReporter()
+                    self?.createBolusReporter()
                 } else {
-                    self.clearBolusReporter()
+                    self?.clearBolusReporter()
                 }
             }
             .store(in: &lifetime)
 
+        // The following three publishers update `@Persisted` properties that
+        // are also read from the main thread (UI bindings)
         deviceDataManager.scheduledBasal
-            .receive(on: processQueue)
-            .sink { scheduledBasal in
-                self.isScheduledBasal = scheduledBasal
+            .receive(on: DispatchQueue.main)
+            .sink { [weak self] scheduledBasal in
+                self?.isScheduledBasal = scheduledBasal
             }
             .store(in: &lifetime)
 
         deviceDataManager.suspended
-            .receive(on: processQueue)
-            .sink { suspended in
-                self.isSuspended = suspended
+            .receive(on: DispatchQueue.main)
+            .sink { [weak self] suspended in
+                self?.isSuspended = suspended
             }
             .store(in: &lifetime)
 
         // manage a manual Temp Basal from PumpManager - force loop() after manual temp basal is cancelled or finishes
         deviceDataManager.manualTempBasal
-            .receive(on: processQueue)
-            .sink { manualBasal in
+            .receive(on: DispatchQueue.main)
+            .sink { [weak self] manualBasal in
                 if manualBasal {
-                    self.isManualTempBasal = true
+                    self?.isManualTempBasal = true
                 } else {
-                    if self.isManualTempBasal {
-                        self.isManualTempBasal = false
-                        self.loop()
+                    if self?.isManualTempBasal == true {
+                        self?.isManualTempBasal = false
+                        self?.loop()
                     }
                 }
             }
@@ -233,116 +263,115 @@ final class BaseAPSManager: APSManager, Injectable {
         Task { [weak self] in
             guard let self else { return }
 
-            // Check if we can start a new loop
-            guard await self.canStartNewLoop() else { return }
+            // Consume the user-initiated flag unconditionally — it was set
+            // for the loop the user just triggered. If the guards below block
+            // (suspended, too-soon, no pump), the next scheduled tick must
+            // not inherit it and bypass dwell suppression for an error the
+            // user didn't request.
+            let userInitiated = self.nextLoopUserInitiated
+            self.nextLoopUserInitiated = false
+
+            // Don't try to run a loop while pump setup / pod pairing is in
+            // progress — `verifyStatus` would throw `invalidPumpState("Pump
+            // not set")` and surface a modal banner on top of the pod
+            // activation sheet, closing the sheet (reported by tester during
+            // O5 pairing).
+            guard self.pumpManager != nil else {
+                debug(.apsManager, "No pump manager — skipping loop attempt")
+                return
+            }
 
-            // Setup loop and background task
-            var (loopStatRecord, backgroundTask) = await self.setupLoop()
+            // Atomic check-and-set via actor — eliminates the race between
+            // checking isLooping.value and sending isLooping(true).
+            guard await loopGuard.tryStart(
+                minInterval: Config.loopInterval,
+                lastLoopDate: lastLoopDate,
+                lastLoopStartDate: lastLoopStartDate
+            ) else {
+                debug(.apsManager, "Loop skipped (already running or too soon)")
+                return
+            }
 
-            do {
-                // Execute loop logic
-                try await self.executeLoop(loopStatRecord: &loopStatRecord)
+            // Affects whether transient errors surface immediately instead of
+            // dwell-suppressed (see `surfaceErrorIfNeeded`).
+            self.currentLoopUserInitiated = userInitiated
+            defer { self.currentLoopUserInitiated = false }
+
+            // Start background task
+            // we probably need to refactor this when implementing Swift 6 due to mutation of a captured var in an async context
+            var taskID: UIBackgroundTaskIdentifier = .invalid
+            taskID = await UIApplication.shared.beginBackgroundTask(withName: "Loop starting") {
+                // closure runs on the Main Thread
+                // removed the Task that provided no guarantee to end the background task
+                if taskID != .invalid {
+                    UIApplication.shared.endBackgroundTask(taskID)
+                    taskID = .invalid
+                }
+            }
+
+            isLooping.send(true)
+
+            let loopStartDate = Date()
+            lastLoopStartDate = loopStartDate
+            let interval = await calculateLoopInterval(loopStartDate: loopStartDate)
+
+            var loopStatRecord = LoopStats(
+                start: loopStartDate,
+                loopStatus: "Starting",
+                interval: interval
+            )
 
+            do {
+                try await executeLoop(loopStatRecord: &loopStatRecord)
                 requestNightscoutUpload(
                     [.carbs, .pumpHistory, .overrides, .tempTargets],
                     source: "APSManager"
                 )
+                await finalizeLoop(loopStatRecord: loopStatRecord)
             } catch {
-                var updatedStats = loopStatRecord
-                updatedStats.end = Date()
-                updatedStats.duration = roundDouble((updatedStats.end! - updatedStats.start).timeInterval / 60, 2)
-                updatedStats.loopStatus = error.localizedDescription
-                await loopCompleted(error: error, loopStatRecord: updatedStats)
+                let endDate = Date()
+                loopStatRecord.end = endDate
+                loopStatRecord.duration = roundDouble((endDate - loopStatRecord.start).timeInterval / 60, 2)
+                loopStatRecord.loopStatus = error.localizedDescription
+                await finalizeLoop(error: error, loopStatRecord: loopStatRecord)
                 debug(.apsManager, "\(DebuggingIdentifiers.failed) Failed to complete Loop: \(error)")
             }
 
-            // Cleanup background task
-            if let backgroundTask = backgroundTask {
-                await UIApplication.shared.endBackgroundTask(backgroundTask)
-                self.backgroundTaskID = .invalid
-            }
-        }
-    }
-
-    private func canStartNewLoop() async -> Bool {
-        // Check if too soon for next loop
-        if lastLoopDate > lastLoopStartDate {
-            guard lastLoopStartDate.addingTimeInterval(Config.loopInterval) < Date() else {
-                debug(.apsManager, "Not enough time have passed since last loop at : \(lastLoopStartDate)")
-                return false
+            // End the background task
+            if taskID != .invalid {
+                await UIApplication.shared.endBackgroundTask(taskID)
+                taskID = .invalid
             }
         }
-
-        // Check if loop already in progress
-        guard !isLooping.value else {
-            warning(.apsManager, "Loop already in progress. Skip recommendation.")
-            return false
-        }
-
-        return true
-    }
-
-    private func setupLoop() async -> (LoopStats, UIBackgroundTaskIdentifier?) {
-        // Start background task
-        let backgroundTask = await UIApplication.shared.beginBackgroundTask(withName: "Loop starting") { [weak self] in
-            guard let self, let backgroundTask = self.backgroundTaskID else { return }
-            Task {
-                UIApplication.shared.endBackgroundTask(backgroundTask)
-            }
-            self.backgroundTaskID = .invalid
-        }
-        backgroundTaskID = backgroundTask
-
-        // Set loop start time
-        lastLoopStartDate = Date()
-
-        // Calculate interval from previous loop
-        let interval = await calculateLoopInterval()
-
-        // Create initial loop stats record
-        let loopStatRecord = LoopStats(
-            start: lastLoopStartDate,
-            loopStatus: "Starting",
-            interval: interval
-        )
-
-        isLooping.send(true)
-
-        return (loopStatRecord, backgroundTask)
     }
 
     private func executeLoop(loopStatRecord: inout LoopStats) async throws {
         try await determineBasal()
 
-        // Handle open loop
-        guard settings.closedLoop else {
-            loopStatRecord.end = Date()
-            loopStatRecord.duration = roundDouble((loopStatRecord.end! - loopStatRecord.start).timeInterval / 60, 2)
-            loopStatRecord.loopStatus = "Success"
-            await loopCompleted(loopStatRecord: loopStatRecord)
-            return
+        // Closed loop: also enact the determination.
+        if settings.closedLoop {
+            try await enactDetermination()
         }
 
-        // Handle closed loop
-        try await enactDetermination()
-        loopStatRecord.end = Date()
-        loopStatRecord.duration = roundDouble((loopStatRecord.end! - loopStatRecord.start).timeInterval / 60, 2)
+        let endDate = Date()
+        loopStatRecord.end = endDate
+        loopStatRecord.duration = roundDouble((endDate - loopStatRecord.start).timeInterval / 60, 2)
         loopStatRecord.loopStatus = "Success"
-        await loopCompleted(loopStatRecord: loopStatRecord)
     }
 
-    private func calculateLoopInterval() async -> Double? {
+    private func calculateLoopInterval(loopStartDate: Date) async -> Double? {
         do {
-            return try await privateContext.perform {
+            return try await privateContext.perform { [weak self] in
+                guard let self else { return nil }
                 let requestStats = LoopStatRecord.fetchRequest() as NSFetchRequest<LoopStatRecord>
                 let sortStats = NSSortDescriptor(key: "end", ascending: false)
                 requestStats.sortDescriptors = [sortStats]
                 requestStats.fetchLimit = 1
                 let previousLoop = try self.privateContext.fetch(requestStats)
 
-                if (previousLoop.first?.end ?? .distantFuture) < self.lastLoopStartDate {
+                if (previousLoop.first?.end ?? .distantFuture) < loopStartDate {
                     return self.roundDouble(
-                        (self.lastLoopStartDate - (previousLoop.first?.end ?? Date())).timeInterval / 60,
+                        (loopStartDate - (previousLoop.first?.end ?? Date())).timeInterval / 60,
                         1
                     )
                 }
@@ -354,21 +383,20 @@ final class BaseAPSManager: APSManager, Injectable {
         }
     }
 
-    // Loop exit point
-    private func loopCompleted(error: Error? = nil, loopStatRecord: LoopStats) async {
+    /// Single exit point for loop — replaces the old `loopCompleted()`.
+    private func finalizeLoop(error: Error? = nil, loopStatRecord: LoopStats) async {
+        await loopGuard.finish()
         isLooping.send(false)
 
         if let error = error {
             warning(.apsManager, "Loop failed with error: \(error)")
-            if let backgroundTask = backgroundTaskID {
-                await UIApplication.shared.endBackgroundTask(backgroundTask)
-                backgroundTaskID = .invalid
-            }
             processError(error)
         } else {
             debug(.apsManager, "Loop succeeded")
             lastLoopDate = Date()
             lastError.send(nil)
+            transientCategoryFirstSeen.removeAll()
+            transientCategoryCount.removeAll()
         }
 
         loopStats(loopStatRecord: loopStatRecord)
@@ -376,12 +404,6 @@ final class BaseAPSManager: APSManager, Injectable {
         if settings.closedLoop {
             await reportEnacted(wasEnacted: error == nil)
         }
-
-        // End of the BG tasks
-        if let backgroundTask = backgroundTaskID {
-            await UIApplication.shared.endBackgroundTask(backgroundTask)
-            backgroundTaskID = .invalid
-        }
     }
 
     private func verifyStatus() -> Error? {
@@ -412,7 +434,7 @@ final class BaseAPSManager: APSManager, Injectable {
         else {
             let result = try await openAPS.autosense(
                 shouldSmoothGlucose: settingsManager.settings.smoothGlucose,
-                useSwiftOref: settings.useSwiftOref
+                useJavascriptOref: settings.useJavascriptOref
             )
             return result != nil
         }
@@ -478,16 +500,15 @@ final class BaseAPSManager: APSManager, Injectable {
         do {
             let now = Date()
 
-            // Parallelize the fetches using async let
-            async let currentTemp = fetchCurrentTempBasal(date: now)
-            async let autosenseResult = autosense()
+            // put profile creation up front since autosens needs it
+            try await openAPS.createProfiles(useJavascriptOref: settings.useJavascriptOref)
+            let currentTemp = try await fetchCurrentTempBasal(date: now)
+            _ = try await autosense()
 
-            _ = try await autosenseResult
-            try await openAPS.createProfiles(useSwiftOref: settings.useSwiftOref)
             let determination = try await openAPS.determineBasal(
-                currentTemp: await currentTemp,
+                currentTemp: currentTemp,
                 shouldSmoothGlucose: settingsManager.settings.smoothGlucose,
-                useSwiftOref: settings.useSwiftOref,
+                useJavascriptOref: settings.useJavascriptOref,
                 clock: now
             )
             iobFileDidUpdate.send(())
@@ -534,7 +555,7 @@ final class BaseAPSManager: APSManager, Injectable {
             return try await openAPS.determineBasal(
                 currentTemp: temp,
                 shouldSmoothGlucose: settingsManager.settings.smoothGlucose,
-                useSwiftOref: settings.useSwiftOref,
+                useJavascriptOref: settings.useJavascriptOref,
                 clock: Date(),
                 simulatedCarbsAmount: simulatedCarbsAmount,
                 simulatedBolusAmount: simulatedBolusAmount,
@@ -556,8 +577,6 @@ final class BaseAPSManager: APSManager, Injectable {
         return min(rounded, maxBolus)
     }
 
-    private var bolusReporter: DoseProgressReporter?
-
     func enactBolus(amount: Double, isSMB: Bool, callback: ((Bool, String) -> Void)?) async {
         if amount <= 0 {
             return
@@ -602,9 +621,14 @@ final class BaseAPSManager: APSManager, Injectable {
             }
         } catch {
             warning(.apsManager, "Bolus failed with error: \(error)")
-            processError(APSError.pumpError(error))
+            lastError.send(APSError.pumpError(error))
+            issueAlertForCategory(
+                .bolusFailed,
+                title: String(localized: "Bolus failed"),
+                body: String(localized: "Check pump history before repeating.")
+                    + "\n\n\(error.localizedDescription)"
+            )
             if !isSMB {
-                // Use MainActor to handle broadcaster notification
                 let broadcaster = self.broadcaster
                 Task { @MainActor in
                     broadcaster?.notify(BolusFailureObserver.self, on: .main) {
@@ -628,7 +652,12 @@ final class BaseAPSManager: APSManager, Injectable {
             callback?(true, String(localized: "Bolus cancelled successfully.", comment: "Success message for canceling a bolus"))
         } catch {
             debug(.apsManager, "Bolus cancellation failed with error: \(error)")
-            processError(APSError.pumpError(error))
+            lastError.send(APSError.pumpError(error))
+            issueAlertForCategory(
+                .bolusFailed,
+                title: String(localized: "Bolus cancellation failed"),
+                body: String(localized: "Try again.") + "\n\n\(error.localizedDescription)"
+            )
             callback?(
                 false,
                 String(
@@ -637,9 +666,7 @@ final class BaseAPSManager: APSManager, Injectable {
                 )
             )
         }
-        bolusReporter?.removeObserver(self)
-        bolusReporter = nil
-        bolusProgress.send(nil)
+        clearBolusReporter()
     }
 
     func enactTempBasal(rate: Double, duration: TimeInterval) async {
@@ -719,10 +746,9 @@ final class BaseAPSManager: APSManager, Injectable {
             throw APSError.apsError(message: "Pump not set")
         }
 
-        // Check if pump is suspended and abort if it is
         if pump.status.pumpStatus.suspended {
-            info(.apsManager, "Skipping enactDetermination because pump is suspended")
-            return // return without throwing an error
+            debug(.apsManager, "Skipping enactDetermination because pump is suspended")
+            return
         }
 
         // Unable to do temp basal during manual temp basal 😁
@@ -838,31 +864,31 @@ final class BaseAPSManager: APSManager, Injectable {
         return Double(sorted[length / 2])
     }
 
+    /// Computes Time-in-Range statistics. Must be called from inside a
+    /// `privateContext.perform` block — the inner perform was redundant
     private func tir(_ glucose: [GlucoseStored]) -> (TIR: Double, hypos: Double, hypers: Double, normal_: Double) {
-        privateContext.perform {
-            let justGlucoseArray = glucose.compactMap({ each in Int(each.glucose as Int16) })
-            let totalReadings = justGlucoseArray.count
-            let highLimit = settingsManager.settings.high
-            let lowLimit = settingsManager.settings.low
-            let hyperArray = glucose.filter({ $0.glucose >= Int(highLimit) })
-            let hyperReadings = hyperArray.compactMap({ each in each.glucose as Int16 }).count
-            let hyperPercentage = Double(hyperReadings) / Double(totalReadings) * 100
-            let hypoArray = glucose.filter({ $0.glucose <= Int(lowLimit) })
-            let hypoReadings = hypoArray.compactMap({ each in each.glucose as Int16 }).count
-            let hypoPercentage = Double(hypoReadings) / Double(totalReadings) * 100
-            // Euglyccemic range
-            let normalArray = glucose.filter({ $0.glucose >= 70 && $0.glucose <= 140 })
-            let normalReadings = normalArray.compactMap({ each in each.glucose as Int16 }).count
-            let normalPercentage = Double(normalReadings) / Double(totalReadings) * 100
-            // TIR
-            let tir = 100 - (hypoPercentage + hyperPercentage)
-            return (
-                roundDouble(tir, 1),
-                roundDouble(hypoPercentage, 1),
-                roundDouble(hyperPercentage, 1),
-                roundDouble(normalPercentage, 1)
-            )
-        }
+        let justGlucoseArray = glucose.compactMap({ each in Int(each.glucose as Int16) })
+        let totalReadings = justGlucoseArray.count
+        let highLimit = settingsManager.settings.high
+        let lowLimit = settingsManager.settings.low
+        let hyperArray = glucose.filter({ $0.glucose >= Int(highLimit) })
+        let hyperReadings = hyperArray.compactMap({ each in each.glucose as Int16 }).count
+        let hyperPercentage = Double(hyperReadings) / Double(totalReadings) * 100
+        let hypoArray = glucose.filter({ $0.glucose <= Int(lowLimit) })
+        let hypoReadings = hypoArray.compactMap({ each in each.glucose as Int16 }).count
+        let hypoPercentage = Double(hypoReadings) / Double(totalReadings) * 100
+        // Euglycemic range
+        let normalArray = glucose.filter({ $0.glucose >= 70 && $0.glucose <= 140 })
+        let normalReadings = normalArray.compactMap({ each in each.glucose as Int16 }).count
+        let normalPercentage = Double(normalReadings) / Double(totalReadings) * 100
+        // TIR
+        let tir = 100 - (hypoPercentage + hyperPercentage)
+        return (
+            roundDouble(tir, 1),
+            roundDouble(hypoPercentage, 1),
+            roundDouble(hyperPercentage, 1),
+            roundDouble(normalPercentage, 1)
+        )
     }
 
     private func glucoseStats(_ fetchedGlucose: [GlucoseStored])
@@ -1060,17 +1086,14 @@ final class BaseAPSManager: APSManager, Injectable {
             let glucose24h = try await fetchGlucose(predicate: NSPredicate.predicateForOneDayAgo, fetchLimit: 288, batchSize: 50)
             let glucoseOneWeek = try await fetchGlucose(
                 predicate: NSPredicate.predicateForOneWeek,
-                fetchLimit: 288 * 7,
                 batchSize: 250
             )
             let glucoseOneMonth = try await fetchGlucose(
                 predicate: NSPredicate.predicateForOneMonth,
-                fetchLimit: 288 * 7 * 30,
                 batchSize: 500
             )
             let glucoseThreeMonths = try await fetchGlucose(
                 predicate: NSPredicate.predicateForThreeMonths,
-                fetchLimit: 288 * 7 * 30 * 3,
                 batchSize: 1000
             )
 
@@ -1196,7 +1219,8 @@ final class BaseAPSManager: APSManager, Injectable {
     }
 
     private func loopStats(loopStatRecord: LoopStats) {
-        privateContext.perform {
+        privateContext.perform { [weak self] in
+            guard let self else { return }
             let nLS = LoopStatRecord(context: self.privateContext)
             nLS.start = loopStatRecord.start
             nLS.end = loopStatRecord.end ?? Date()
@@ -1213,20 +1237,126 @@ final class BaseAPSManager: APSManager, Injectable {
         }
     }
 
+    private var transientCategoryFirstSeen: [String: Date] = [:]
+    private var transientCategoryCount: [String: Int] = [:]
+    private static let transientDwellThreshold: TimeInterval = 60
+    private static let transientCountThreshold = 2
+
+    /// Set by `markNextLoopUserInitiated()` (e.g. force-loop button), consumed
+    /// on the next entry into `loop()` so that errors during a user-initiated
+    /// loop surface immediately instead of being suppressed by dwell logic.
+    @SyncAccess private var nextLoopUserInitiated: Bool = false
+    private var currentLoopUserInitiated: Bool = false
+
+    func markNextLoopUserInitiated() {
+        nextLoopUserInitiated = true
+    }
+
     private func processError(_ error: Error) {
         warning(.apsManager, "\(error)")
         lastError.send(error)
+        surfaceErrorIfNeeded(error)
+    }
+
+    private func surfaceErrorIfNeeded(_ error: Error) {
+        let category = TrioAlertClassifier.categorize(error: error)
+        let key = String(describing: category)
+
+        if category.shouldFireImmediately || currentLoopUserInitiated {
+            transientCategoryFirstSeen.removeValue(forKey: key)
+            transientCategoryCount.removeValue(forKey: key)
+            issueAlertForError(error, category: category)
+            return
+        }
+
+        let now = Date()
+        let firstSeen = transientCategoryFirstSeen[key] ?? now
+        let count = (transientCategoryCount[key] ?? 0) + 1
+        let dwellElapsed = now.timeIntervalSince(firstSeen)
+        let dwellMet = dwellElapsed >= Self.transientDwellThreshold
+        let countMet = count >= Self.transientCountThreshold
+
+        if dwellMet || countMet {
+            transientCategoryFirstSeen.removeValue(forKey: key)
+            transientCategoryCount.removeValue(forKey: key)
+            issueAlertForError(error, category: category)
+        } else {
+            transientCategoryFirstSeen[key] = firstSeen
+            transientCategoryCount[key] = count
+            debug(
+                .apsManager,
+                "APSManager suppressed transient \(category) (count=\(count)/\(Self.transientCountThreshold), dwell=\(Int(dwellElapsed))s/\(Int(Self.transientDwellThreshold))s)"
+            )
+        }
+    }
+
+    private func issueAlertForCategory(_ category: TrioAlertCategory, title: String, body: String) {
+        let content = Alert.Content(
+            title: title,
+            body: body,
+            acknowledgeActionButtonLabel: String(localized: "OK")
+        )
+        let alert = Alert(
+            identifier: Alert.Identifier(managerIdentifier: "trio.aps", alertIdentifier: category.alertIdentifier),
+            foregroundContent: content,
+            backgroundContent: content,
+            trigger: .immediate,
+            interruptionLevel: category.interruptionLevel
+        )
+        trioAlertManager?.issueAlert(alert)
     }
 
+    private func issueAlertForError(_ error: Error, category: TrioAlertCategory) {
+        let (title, body) = describeForAlert(error)
+        let content = Alert.Content(
+            title: title,
+            body: body,
+            acknowledgeActionButtonLabel: "OK"
+        )
+        let alert = Alert(
+            identifier: Alert.Identifier(managerIdentifier: "trio.aps", alertIdentifier: category.alertIdentifier),
+            foregroundContent: content,
+            backgroundContent: content,
+            trigger: .immediate,
+            interruptionLevel: category.interruptionLevel
+        )
+        trioAlertManager?.issueAlert(alert)
+    }
+
+    private func describeForAlert(_ error: Error) -> (title: String, body: String) {
+        if let apsError = error as? APSError {
+            switch apsError {
+            case let .pumpError(inner):
+                return (
+                    String(localized: "Pump Error"),
+                    String(localized: "Trio could not communicate with the pump. Check the pump and try again.")
+                        + "\n\n\(inner.localizedDescription)"
+                )
+            case let .invalidPumpState(message): return (String(localized: "Pump State Error"), message)
+            case let .glucoseError(message): return (String(localized: "Glucose Error"), message)
+            case let .apsError(message): return (String(localized: "Algorithm Error"), message)
+            case let .manualBasalTemp(message): return (String(localized: "Manual Temp Basal Active"), message)
+            }
+        }
+        return ("Trio", error.localizedDescription)
+    }
+
+    /// Called from the `bolusTrigger` Combine sink (already on
+    /// `processQueue`) and from `doseProgressReporterDidUpdate` (the
+    /// pump manager schedules the callback on `processQueue` too).
+    /// Mutations are dispatched onto the queue regardless, so a future
+    /// caller from another context (e.g. `cancelBolus`) stays safe.
     private func createBolusReporter() {
-        bolusReporter = pumpManager?.createBolusProgressReporter(reportingOn: processQueue)
-        bolusReporter?.addObserver(self)
+        processQueue.async {
+            self.bolusReporter = self.pumpManager?.createBolusProgressReporter(reportingOn: self.processQueue)
+            self.bolusReporter?.addObserver(self)
+        }
     }
 
     private func clearBolusReporter() {
-        bolusReporter?.removeObserver(self)
-        bolusReporter = nil
-        processQueue.asyncAfter(deadline: .now() + 0.5) {
+        processQueue.async {
+            self.bolusReporter?.removeObserver(self)
+            self.bolusReporter = nil
             self.bolusProgress.send(nil)
         }
     }
@@ -1307,7 +1437,8 @@ extension BaseAPSManager: PumpManagerStatusObserver {
     func pumpManager(_: PumpManager, didUpdate status: PumpManagerStatus, oldStatus _: PumpManagerStatus) {
         let percent = Int((status.pumpBatteryChargeRemaining ?? 1) * 100)
 
-        privateContext.perform {
+        privateContext.perform { [weak self] in
+            guard let self else { return }
             /// only update the last item with the current battery infos instead of saving a new one each time
             let fetchRequest: NSFetchRequest<OpenAPS_Battery> = OpenAPS_Battery.fetchRequest()
             fetchRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]

+ 98 - 1
Trio/Sources/APS/CGM/AppGroupSource.swift

@@ -30,6 +30,9 @@ struct AppGroupSource: GlucoseSource {
     let from: String
     var cgmType: CGMType
 
+    let cgmDisplayState = CurrentValueSubject<CgmDisplayState?, Never>(nil)
+    let cgmProgressHighlight = CurrentValueSubject<LoopKit.DeviceLifecycleProgress?, Never>(nil)
+
     func fetch(_ heartbeat: DispatchTimer?) -> AnyPublisher<[BloodGlucose], Never> {
         guard let suiteName = Bundle.main.appGroupSuiteName,
               let sharedDefaults = UserDefaults(suiteName: suiteName)
@@ -52,7 +55,21 @@ struct AppGroupSource: GlucoseSource {
         HeartBeatManager.shared.checkCGMBluetoothTransmitter(sharedUserDefaults: sharedDefaults, heartbeat: heartbeat)
         debug(.deviceManager, "APPGROUP : START FETCH LAST BG ")
         let decoded = try? JSONSerialization.jsonObject(with: sharedData, options: [])
-        guard let sgvs = decoded as? [AnyObject] else {
+
+        // Two shapes accepted:
+        //   Legacy (xDrip4iOS today): top-level array of reading dicts.
+        //   Rich (xDrip4iOS extended for CGM lifecycle):
+        //   top-level dict carrying readings under
+        //   `recentReadings` plus sibling keys for CGM status, sensor
+        //   lifecycle, and transmitter info — see `applyRichState`.
+        let sgvs: [AnyObject]
+        if let dict = decoded as? [String: Any] {
+            applyRichState(dict)
+            sgvs = (dict["recentReadings"] as? [AnyObject]) ?? []
+        } else if let arr = decoded as? [AnyObject] {
+            applyRichState(nil)
+            sgvs = arr
+        } else {
             return []
         }
 
@@ -101,6 +118,81 @@ struct AppGroupSource: GlucoseSource {
         return results
     }
 
+    /// Reads the rich top-level dict from xDrip4iOS (when present) and
+    /// pushes status + lifecycle into the publishers HomeStateModel
+    /// subscribes to. Defensive on every key — xdrip ships partial dicts
+    /// during warmup / failure / between sensors.
+    private func applyRichState(_ payload: [String: Any]?) {
+        guard let payload else {
+            cgmDisplayState.value = nil
+            cgmProgressHighlight.value = nil
+            return
+        }
+
+        let cgm = payload["cgm"] as? [String: Any]
+        cgmDisplayState.value = parseStatus(cgm?["status"] as? [String: Any])
+        cgmProgressHighlight.value = parseSensorLifecycle(cgm?["sensor"] as? [String: Any])
+    }
+
+    private func parseStatus(_ status: [String: Any]?) -> CgmDisplayState? {
+        guard let status,
+              let message = status["localizedMessage"] as? String,
+              !message.isEmpty
+        else { return nil }
+        return CgmDisplayState(
+            localizedMessage: message,
+            imageName: (status["imageName"] as? String) ?? "",
+            status: cgmDisplayStatus(forCode: status["displayState"] as? String ?? status["code"] as? String)
+        )
+    }
+
+    /// xDrip4iOS sends a free-form code string (e.g. "normal", "warning",
+    /// "critical", "warmup", "calibration_needed", "sensor_failed"). We
+    /// fold anything unfamiliar into `.warning` so unknown future codes
+    /// surface visibly instead of going silent.
+    private func cgmDisplayStatus(forCode code: String?) -> CgmDisplayStatus {
+        switch code?.lowercased() {
+        case nil,
+             "normal",
+             "ok": return .normal
+        case "critical",
+             "expired",
+             "sensor_failed",
+             "session_failed",
+             "stopped": return .critical
+        default: return .warning
+        }
+    }
+
+    private func parseSensorLifecycle(_ sensor: [String: Any]?) -> DeviceLifecycleProgress? {
+        guard let sensor else { return nil }
+        let percent = (sensor["percentComplete"] as? NSNumber)?.doubleValue
+        guard let percent else { return nil }
+        let progressState = lifecycleProgressState(
+            for: sensor["progressState"] as? String,
+            isInWarmup: sensor["isInWarmup"] as? Bool ?? false,
+            isExpired: sensor["isExpired"] as? Bool ?? false
+        )
+        return AppGroupLifecycleProgress(
+            percentComplete: max(0, min(1, percent)),
+            progressState: progressState
+        )
+    }
+
+    private func lifecycleProgressState(
+        for code: String?,
+        isInWarmup: Bool,
+        isExpired: Bool
+    ) -> DeviceLifecycleProgressState {
+        if isExpired { return .critical }
+        if isInWarmup { return .normalCGM }
+        switch code?.lowercased() {
+        case "critical": return .critical
+        case "warning": return .warning
+        default: return .normalCGM
+        }
+    }
+
     private func parseDate(_ timestamp: String) -> Date? {
         // timestamp looks like "/Date(1462404576000)/"
         guard let re = try? NSRegularExpression(pattern: "\\((.*)\\)"),
@@ -119,6 +211,11 @@ struct AppGroupSource: GlucoseSource {
     }
 }
 
+private struct AppGroupLifecycleProgress: DeviceLifecycleProgress {
+    let percentComplete: Double
+    let progressState: DeviceLifecycleProgressState
+}
+
 public extension Bundle {
     var appGroupSuiteName: String? {
         object(forInfoDictionaryKey: "AppGroupID") as? String

+ 228 - 0
Trio/Sources/APS/CGM/GlucoseSimulatorSource.swift

@@ -13,7 +13,9 @@
 ///  - OscillatingGenerator: BloodGlucoseGenerator - Generates sinusoidal glucose values around a center point
 
 import Combine
+import CoreData
 import Foundation
+import LoopKit
 import LoopKitUI
 
 // MARK: - Glucose simulator
@@ -25,6 +27,9 @@ final class GlucoseSimulatorSource: GlucoseSource {
     var cgmManager: CGMManagerUI?
     var glucoseManager: FetchGlucoseManager?
 
+    let cgmDisplayState = CurrentValueSubject<CgmDisplayState?, Never>(nil)
+    let cgmProgressHighlight = CurrentValueSubject<DeviceLifecycleProgress?, Never>(nil)
+
     private enum Config {
         /// Minimum time period between data publications (in seconds)
         static let workInterval: TimeInterval = 300
@@ -49,6 +54,57 @@ final class GlucoseSimulatorSource: GlucoseSource {
             }
             lastFetchDate = lastDate
         }
+        publishSimulatedState()
+    }
+
+    /// Republishes synthetic lifecycle/highlight from `simulatedScenario`.
+    func publishSimulatedState() {
+        cgmProgressHighlight.value = cgmLifecycleProgress
+        if let highlight = cgmStatusHighlight {
+            cgmDisplayState.value = CgmDisplayState(
+                localizedMessage: highlight.localizedMessage,
+                imageName: highlight.imageName,
+                status: CgmDisplayStatus.from(highlight.state)
+            )
+        } else {
+            cgmDisplayState.value = nil
+        }
+    }
+
+    /// Picker entry point — change scenario + propagate immediately. When
+    /// flipping to a state where a real sensor wouldn't be delivering fresh
+    /// readings, drop any GlucoseStored rows inside the home view's 12 min
+    /// freshness window so the bobble switches to its compact stale view
+    /// right away instead of waiting for organic aging.
+    func applySimulatedScenario(_ scenario: SimulatedSensorScenario) {
+        simulatedScenario = scenario
+        if !scenario.deliversFreshGlucose {
+            clearRecentSimulatorReadings()
+        }
+        publishSimulatedState()
+    }
+
+    /// Deletes GlucoseStored rows newer than the home view's 12 min
+    /// freshness window. Dev-only — only invoked from the simulator's
+    /// scenario picker, which is itself gated to simulator mode.
+    private func clearRecentSimulatorReadings() {
+        let context = CoreDataStack.shared.newTaskContext()
+        let cutoff = Date().addingTimeInterval(-12 * 60)
+        context.perform {
+            let request = GlucoseStored.fetchRequest()
+            request.predicate = NSPredicate(format: "date > %@", cutoff as NSDate)
+            do {
+                let recent = try context.fetch(request)
+                for row in recent {
+                    context.delete(row)
+                }
+                if context.hasChanges {
+                    try context.save()
+                }
+            } catch {
+                print("GlucoseSimulatorSource: clearRecentSimulatorReadings failed: \(error)")
+            }
+        }
     }
 
     /// The glucose generator used to create simulated values
@@ -74,6 +130,14 @@ final class GlucoseSimulatorSource: GlucoseSource {
         guard canGenerateNewValues else {
             return Just([]).eraseToAnyPublisher()
         }
+        // Match real CGM behavior: scenarios where a physical sensor wouldn't
+        // be delivering readings (warmup, calibration, expired, failed) also
+        // stop the simulator from emitting fresh values. Existing readings
+        // then age out of the 12 min freshness window, the bobble flips to
+        // its compact symbol view, and the highlight's imageName surfaces.
+        guard simulatedScenario.deliversFreshGlucose else {
+            return Just([]).eraseToAnyPublisher()
+        }
 
         let glucoses = generator.getBloodGlucoses(
             startDate: lastFetchDate,
@@ -94,6 +158,170 @@ final class GlucoseSimulatorSource: GlucoseSource {
     func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never> {
         fetch(nil)
     }
+
+    // MARK: - Simulated sensor lifecycle / status (dev-only)
+
+    //
+    // The simulator doesn't own a real `CGMManagerUI`, so `cgmStatusHighlight`
+    // and `cgmLifecycleProgress` would be nil on the home screen. To make the
+    // outer arc + tag indicator actually exercisable without Libre/Dexcom
+    // hardware, the simulator exposes synthetic values driven by
+    // `simulatedScenario`. Flip the persisted enum at runtime (debug menu or
+    // by editing the default below) and the home view picks up the change on
+    // its next 5-second refresh tick.
+
+    /// Which pre-canned sensor state the simulator should advertise. Plain
+    /// `UserDefaults` string access (not `@Persisted`) so the CGM-settings
+    /// picker — which writes via `UserDefaults.standard.set(_:forKey:)` — and
+    /// the read here use the same encoding. `@Persisted` stores values as
+    /// JSON-wrapped `Data`, which would silently break the picker round-trip.
+    static let simulatedScenarioKey = "GlucoseSimulator.simulatedScenario"
+
+    var simulatedScenario: SimulatedSensorScenario {
+        get {
+            let raw = UserDefaults.standard.string(forKey: Self.simulatedScenarioKey)
+            return raw.flatMap(SimulatedSensorScenario.init(rawValue:)) ?? .runningNormally
+        }
+        set {
+            UserDefaults.standard.set(newValue.rawValue, forKey: Self.simulatedScenarioKey)
+        }
+    }
+
+    /// Synthetic expiration date that mirrors what a real CGM would expose.
+    /// Picked so the remaining-time label matches the scenario's
+    /// `percentComplete` against a 10-day total (Dexcom-like).
+    var simulatedSensorExpiresAt: Date? {
+        guard let progress = cgmLifecycleProgress else { return nil }
+        let totalLifetime: TimeInterval = 10 * 24 * 60 * 60
+        let remaining = (1.0 - progress.percentComplete) * totalLifetime
+        return Date().addingTimeInterval(remaining)
+    }
+
+    /// Synthetic outer-arc data — nil for scenarios where lifetime is moot
+    /// (warmup, hardware fault). Matches the production `DeviceLifecycleProgress`
+    /// shape so the home state model can treat both sources identically.
+    var cgmLifecycleProgress: DeviceLifecycleProgress? {
+        switch simulatedScenario {
+        case .runningNormally:
+            return SimulatedLifecycleProgress(percentComplete: 0.45, progressState: .normalCGM)
+        case .expiringSoon:
+            return SimulatedLifecycleProgress(percentComplete: 0.94, progressState: .warning)
+        case .warmup:
+            return nil
+        case .calibrationRequired:
+            return SimulatedLifecycleProgress(percentComplete: 0.30, progressState: .normalCGM)
+        case .expired:
+            return SimulatedLifecycleProgress(percentComplete: 1.0, progressState: .critical)
+        case .sensorFailed:
+            return nil
+        }
+    }
+
+    /// Synthetic status highlight. nil for `.runningNormally` and
+    /// `.expiringSoon` (those run off lifecycle only).
+    var cgmStatusHighlight: DeviceStatusHighlight? {
+        switch simulatedScenario {
+        case .expiringSoon,
+             .runningNormally:
+            return nil
+        case .warmup:
+            return SimulatedStatusHighlight(
+                localizedMessage: "Sensor warming up",
+                imageName: "hourglass",
+                state: .warning
+            )
+        case .calibrationRequired:
+            return SimulatedStatusHighlight(
+                localizedMessage: "Calibrate",
+                imageName: "drop.fill",
+                state: .warning
+            )
+        case .expired:
+            return SimulatedStatusHighlight(
+                localizedMessage: "Sensor expired",
+                imageName: "exclamationmark.circle.fill",
+                state: .critical
+            )
+        case .sensorFailed:
+            return SimulatedStatusHighlight(
+                localizedMessage: "Replace Sensor",
+                imageName: "exclamationmark.triangle.fill",
+                state: .critical
+            )
+        }
+    }
+}
+
+/// Pre-canned sensor scenarios surfaced by `GlucoseSimulatorSource`. One per
+/// home-screen state so flipping this drives the indicator
+/// through every visual state.
+enum SimulatedSensorScenario: String, CaseIterable, Identifiable {
+    case runningNormally
+    case expiringSoon
+    case warmup
+    case calibrationRequired
+    case expired
+    case sensorFailed
+
+    var id: String { rawValue }
+
+    var displayName: String {
+        switch self {
+        case .runningNormally: return "Running normally"
+        case .expiringSoon: return "Expiring soon"
+        case .warmup: return "Warmup"
+        case .calibrationRequired: return "Calibration required"
+        case .expired: return "Expired"
+        case .sensorFailed: return "Sensor failed"
+        }
+    }
+
+    /// Whether a real CGM would still be delivering fresh glucose readings
+    /// while in this state. Drives the simulator's `fetch()` gate so non-
+    /// active scenarios stop emitting and the home view sees stale data
+    /// the same way it would from a real sensor.
+    var deliversFreshGlucose: Bool {
+        switch self {
+        case .expiringSoon,
+             .runningNormally:
+            return true
+        case .calibrationRequired,
+             .expired,
+             .sensorFailed,
+             .warmup:
+            return false
+        }
+    }
+
+    /// Short blurb shown under the picker so dev users know what each
+    /// scenario renders on the home screen.
+    var devNotes: String {
+        switch self {
+        case .runningNormally:
+            return "Teal outer ring at ~45%, tag shows time remaining."
+        case .expiringSoon:
+            return "Amber outer ring at ~94%, tag shows time remaining with \"left\" suffix."
+        case .warmup:
+            return "Arc hidden, pulsing amber tag, glucose shown as \"– –\"."
+        case .calibrationRequired:
+            return "Arc visible, amber tag with \"Calibrate\" message, glucose still shown."
+        case .expired:
+            return "Red full ring, red tag \"sensor expired\", glucose shown as \"– –\"."
+        case .sensorFailed:
+            return "Arc hidden, pulsing red tag, glucose shown as \"– –\"."
+        }
+    }
+}
+
+private struct SimulatedLifecycleProgress: DeviceLifecycleProgress {
+    let percentComplete: Double
+    let progressState: DeviceLifecycleProgressState
+}
+
+private struct SimulatedStatusHighlight: DeviceStatusHighlight {
+    let localizedMessage: String
+    let imageName: String
+    let state: DeviceStatusHighlightState
 }
 
 // MARK: - Glucose generator

+ 4 - 0
Trio/Sources/APS/CGM/GlucoseSource.swift

@@ -11,6 +11,10 @@ protocol GlucoseSource: SourceInfoProvider {
     func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never>
     var glucoseManager: FetchGlucoseManager? { get set }
     var cgmManager: CGMManagerUI? { get set }
+    /// Mirrors `CGMManagerUI.cgmStatusHighlight`; republished on manager change.
+    var cgmDisplayState: CurrentValueSubject<CgmDisplayState?, Never> { get }
+    /// Mirrors `CGMManagerUI.cgmLifecycleProgress`.
+    var cgmProgressHighlight: CurrentValueSubject<LoopKit.DeviceLifecycleProgress?, Never> { get }
 }
 
 extension GlucoseSource {

+ 59 - 9
Trio/Sources/APS/CGM/PluginSource.swift

@@ -11,7 +11,12 @@ final class PluginSource: GlucoseSource {
     private let glucoseStorage: GlucoseStorage!
     var glucoseManager: FetchGlucoseManager?
 
-    var cgmManager: CGMManagerUI?
+    let cgmDisplayState = CurrentValueSubject<CgmDisplayState?, Never>(nil)
+    let cgmProgressHighlight = CurrentValueSubject<DeviceLifecycleProgress?, Never>(nil)
+
+    var cgmManager: CGMManagerUI? {
+        didSet { publishCGMStatus() }
+    }
 
     var cgmHasValidSensorSession: Bool = false
 
@@ -22,6 +27,23 @@ final class PluginSource: GlucoseSource {
         cgmManager = glucoseManager.cgmManager
         cgmManager?.delegateQueue = processQueue
         cgmManager?.cgmManagerDelegate = self
+        // didSet doesn't fire from the defining class's own init.
+        publishCGMStatus()
+    }
+
+    /// Republishes the manager's lifecycle/highlight to the subjects.
+    /// Called from `cgmManager.didSet` and after delegate state updates.
+    func publishCGMStatus() {
+        if let highlight = cgmManager?.cgmStatusHighlight {
+            cgmDisplayState.value = CgmDisplayState(
+                localizedMessage: highlight.localizedMessage,
+                imageName: highlight.imageName,
+                status: CgmDisplayStatus.from(highlight.state)
+            )
+        } else {
+            cgmDisplayState.value = nil
+        }
+        cgmProgressHighlight.value = cgmManager?.cgmLifecycleProgress
     }
 
     /// Function that fetches blood glucose data
@@ -77,23 +99,47 @@ extension PluginSource: CGMManagerDelegate {
         debug(.deviceManager, "device Manager for \(String(describing: deviceIdentifier)) : \(message)")
     }
 
-    func issueAlert(_: LoopKit.Alert) {}
+    /// Forwards CGMManager-issued alerts into the unified `TrioAlertManager`
+    /// pipeline so they get the same in-app banner + UN scheduling + history
+    /// logging treatment as everything else. Used to be a no-op; on
+    /// dev-libre3 / LibreLoop builds that meant CGM-issued alerts (sensor
+    /// failure, signal loss, expiry, etc.) silently dropped.
+    func issueAlert(_ alert: LoopKit.Alert) {
+        glucoseManager?.trioAlertManager?.issueAlert(alert)
+    }
 
-    func retractAlert(identifier _: LoopKit.Alert.Identifier) {}
+    func retractAlert(identifier: LoopKit.Alert.Identifier) {
+        glucoseManager?.trioAlertManager?.retractAlert(identifier: identifier)
+    }
 
-    func doesIssuedAlertExist(identifier _: LoopKit.Alert.Identifier, completion _: @escaping (Result<Bool, Error>) -> Void) {}
+    /// LoopKit asks this on reconnect to avoid re-issuing an alert that's
+    /// still live. `TrioAlertManager` deduplicates downstream via its own
+    /// throttler and live-alert table, so answering "no" here is safe — at
+    /// worst we get one duplicate banner, which the throttler suppresses.
+    func doesIssuedAlertExist(identifier _: LoopKit.Alert.Identifier, completion: @escaping (Result<Bool, Error>) -> Void) {
+        completion(.success(false))
+    }
 
     func lookupAllUnretracted(
         managerIdentifier _: String,
-        completion _: @escaping (Result<[LoopKit.PersistedAlert], Error>) -> Void
-    ) {}
+        completion: @escaping (Result<[LoopKit.PersistedAlert], Error>) -> Void
+    ) {
+        completion(.success([]))
+    }
 
     func lookupAllUnacknowledgedUnretracted(
         managerIdentifier _: String,
-        completion _: @escaping (Result<[LoopKit.PersistedAlert], Error>) -> Void
-    ) {}
+        completion: @escaping (Result<[LoopKit.PersistedAlert], Error>) -> Void
+    ) {
+        completion(.success([]))
+    }
 
-    func recordRetractedAlert(_: LoopKit.Alert, at _: Date) {}
+    /// LoopKit calls this when a manager itself decides an alert is no
+    /// longer relevant. Mirror the action on our pipeline so the in-app
+    /// banner, scheduled UN, and history all clear.
+    func recordRetractedAlert(_ alert: LoopKit.Alert, at _: Date) {
+        glucoseManager?.trioAlertManager?.retractAlert(identifier: alert.identifier)
+    }
 
     func cgmManagerWantsDeletion(_ manager: CGMManager) {
         processQueue.async { [weak self] in
@@ -121,6 +167,8 @@ extension PluginSource: CGMManagerDelegate {
                 debug(.deviceManager, "CGM PLUGIN - unable to read CGM result")
             }
 
+            self.publishCGMStatus()
+
             debug(.deviceManager, "CGM PLUGIN - Direct return done")
         }
     }
@@ -170,6 +218,8 @@ extension PluginSource: CGMManagerDelegate {
                 cgmGlucosePluginId: fetchGlucoseManager.settingsManager.settings.cgmPluginIdentifier,
                 newManager: cgmManager as? CGMManagerUI
             )
+
+            self.publishCGMStatus()
         }
     }
 

+ 21 - 57
Trio/Sources/APS/DeviceDataManager.swift

@@ -62,6 +62,7 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
     @Injected() private var glucoseStorage: GlucoseStorage!
     @Injected() private var settingsManager: SettingsManager!
     @Injected() private var bluetoothProvider: BluetoothStateManager!
+    @Injected() private var trioAlertManager: TrioAlertManager!
 
     @Persisted(key: "BaseDeviceDataManager.lastEventDate") var lastEventDate: Date? = nil
     @SyncAccess(lock: accessLock) @Persisted(key: "BaseDeviceDataManager.lastHeartBeatTime") var lastHeartBeatTime: Date =
@@ -83,10 +84,16 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
 
     var pumpManager: PumpManagerUI? {
         didSet {
+            if let oldValue = oldValue {
+                trioAlertManager?.unregister(managerIdentifier: oldValue.pluginIdentifier)
+            }
             if let pumpManager = pumpManager {
                 pumpManager.pumpManagerDelegate = self
                 pumpManager.delegateQueue = processQueue
 
+                trioAlertManager?.register(responder: pumpManager, for: pumpManager.pluginIdentifier)
+                trioAlertManager?.register(soundVendor: pumpManager, for: pumpManager.pluginIdentifier)
+
                 /// Since the pump manager has been successfully instantiated from its saved state,
                 /// copy its rawValue to rawPumpManager which will be saved to persistant storage.
                 rawPumpManager = pumpManager.rawValue
@@ -241,7 +248,6 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
         injectServices(resolver)
         setupPumpManager()
         UIDevice.current.isBatteryMonitoringEnabled = true
-        broadcaster.register(AlertObserver.self, observer: self)
     }
 
     func setupPumpManager() {
@@ -341,6 +347,9 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
     var cgmManager: CGMManagerUI?
     var cgmType: CGMType = .enlite
 
+    let cgmDisplayState = CurrentValueSubject<CgmDisplayState?, Never>(nil)
+    let cgmProgressHighlight = CurrentValueSubject<DeviceLifecycleProgress?, Never>(nil)
+
     func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never> {
         fetch(nil)
     }
@@ -487,6 +496,13 @@ extension BaseDeviceDataManager: PumpManagerDelegate {
             settingsManager.updateInsulinCurve(status.insulinType)
         }
 
+        // Check if manual temp basal is active
+        let manualTempBasalActive = status.basalDeliveryState?.isManualTempBasal ?? false
+        if manualTempBasalActive {
+            debug(.deviceManager, "manual temp basal")
+        }
+        manualTempBasal.send(manualTempBasalActive)
+
         if let medtrumPump = pumpManager as? MedtrumPumpManager {
             storage.save(Decimal(medtrumPump.state.reservoir), as: OpenAPS.Monitor.reservoir)
             broadcaster.notify(PumpReservoirObserver.self, on: processQueue) {
@@ -520,18 +536,6 @@ extension BaseDeviceDataManager: PumpManagerDelegate {
                 $0.pumpReservoirDidChange(Decimal(reservoir))
             }
 
-            // manual temp basal on
-            if let tempBasal = omni.state.podState?.unfinalizedTempBasal, !tempBasal.isFinished(),
-               !tempBasal.automatic
-            {
-                // the manual basal temp is launch - block every thing
-                debug(.deviceManager, "manual temp basal")
-                manualTempBasal.send(true)
-            } else {
-                // no more manual Temp Basal !
-                manualTempBasal.send(false)
-            }
-
             guard let endTime = omni.state.podState?.expiresAt else {
                 pumpExpiresAtDate.send(nil)
                 return
@@ -635,22 +639,13 @@ extension BaseDeviceDataManager: PumpManagerDelegate {
 
 extension BaseDeviceDataManager: DeviceManagerDelegate {
     func issueAlert(_ alert: Alert) {
-        alertHistoryStorage.addAlert(
-            AlertEntry(
-                alertIdentifier: alert.identifier.alertIdentifier,
-                primitiveInterruptionLevel: alert.interruptionLevel.storedValue as? Decimal,
-                issuedDate: Date(),
-                managerIdentifier: alert.identifier.managerIdentifier,
-                triggerType: alert.trigger.storedType,
-                triggerInterval: alert.trigger.storedInterval as? Decimal,
-                contentTitle: alert.foregroundContent?.title,
-                contentBody: alert.foregroundContent?.body
-            )
-        )
+        debug(.deviceManager, "issueAlert \(alert.identifier.value)")
+        trioAlertManager.issueAlert(alert)
     }
 
     func retractAlert(identifier: Alert.Identifier) {
-        alertHistoryStorage.removeAlert(identifier: identifier.alertIdentifier)
+        debug(.deviceManager, "retractAlert \(identifier.value)")
+        trioAlertManager.retractAlert(identifier: identifier)
     }
 
     func doesIssuedAlertExist(identifier _: Alert.Identifier, completion _: @escaping (Result<Bool, Error>) -> Void) {
@@ -705,37 +700,6 @@ extension BaseDeviceDataManager: CGMManagerDelegate {
     func cgmManager(_: CGMManager, didUpdate _: CGMManagerStatus) {}
 }
 
-// MARK: - AlertPresenter
-
-extension BaseDeviceDataManager: AlertObserver {
-    func AlertDidUpdate(_ alerts: [AlertEntry]) {
-        alerts.forEach { alert in
-            if alert.acknowledgedDate == nil {
-                ackAlert(alert: alert)
-            }
-        }
-    }
-
-    private func ackAlert(alert: AlertEntry) {
-        let alertIssueDate = alert.issuedDate
-
-        processQueue.async {
-            self.pumpManager?.acknowledgeAlert(alertIdentifier: alert.alertIdentifier) { error in
-                if let error = error {
-                    self.alertHistoryStorage.acknowledgeAlert(alertIssueDate, error.localizedDescription)
-                    debug(.deviceManager, "acknowledge not succeeded with error \(error)")
-                } else {
-                    self.alertHistoryStorage.acknowledgeAlert(alertIssueDate, nil)
-                }
-            }
-
-            self.broadcaster.notify(pumpNotificationObserver.self, on: self.processQueue) {
-                $0.pumpNotification(alert: alert)
-            }
-        }
-    }
-}
-
 // extension BaseDeviceDataManager: AlertPresenter {
 //    func issueAlert(_: Alert) {}
 //    func retractAlert(identifier _: Alert.Identifier) {}

+ 28 - 1
Trio/Sources/APS/FetchGlucoseManager.swift

@@ -19,6 +19,12 @@ protocol FetchGlucoseManager: SourceInfoProvider {
     var cgmGlucosePluginId: String { get }
     var settingsManager: SettingsManager! { get }
     var shouldSyncToRemoteService: Bool { get }
+    var cgmDisplayState: CurrentValueSubject<CgmDisplayState?, Never> { get }
+    var cgmProgressHighlight: CurrentValueSubject<DeviceLifecycleProgress?, Never> { get }
+    /// Routes CGMManager-issued alerts (sensor failure, signal loss, expiry,
+    /// etc.) into the unified `TrioAlertManager` pipeline. Read by
+    /// `PluginSource.issueAlert` / `retractAlert`.
+    var trioAlertManager: TrioAlertManager! { get }
 }
 
 extension FetchGlucoseManager {
@@ -40,6 +46,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
     @Injected() var deviceDataManager: DeviceDataManager!
     @Injected() var pluginCGMManager: PluginManager!
     @Injected() var calibrationService: CalibrationService!
+    @Injected() var trioAlertManager: TrioAlertManager!
 
     private var lifetime = Lifetime()
     private let timer = DispatchTimer(timeInterval: 1.minutes.timeInterval)
@@ -148,7 +155,27 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         }
     }
 
-    var glucoseSource: GlucoseSource?
+    let cgmDisplayState = CurrentValueSubject<CgmDisplayState?, Never>(nil)
+    let cgmProgressHighlight = CurrentValueSubject<DeviceLifecycleProgress?, Never>(nil)
+    private var cgmStatusSubscriptions = Set<AnyCancellable>()
+
+    var glucoseSource: GlucoseSource? {
+        didSet {
+            // Drop prior subscriptions so source swaps don't dupe emissions.
+            cgmStatusSubscriptions.removeAll()
+            cgmDisplayState.value = glucoseSource?.cgmDisplayState.value
+            cgmProgressHighlight.value = glucoseSource?.cgmProgressHighlight.value
+            guard let glucoseSource else { return }
+            glucoseSource.cgmDisplayState
+                .receive(on: DispatchQueue.main)
+                .sink { [weak self] state in self?.cgmDisplayState.value = state }
+                .store(in: &cgmStatusSubscriptions)
+            glucoseSource.cgmProgressHighlight
+                .receive(on: DispatchQueue.main)
+                .sink { [weak self] progress in self?.cgmProgressHighlight.value = progress }
+                .store(in: &cgmStatusSubscriptions)
+        }
+    }
 
     func removeCalibrations() {
         calibrationService.removeAllCalibrations()

+ 60 - 54
Trio/Sources/APS/OpenAPS/OpenAPS.swift

@@ -385,7 +385,7 @@ final class OpenAPS {
     func determineBasal(
         currentTemp: TempBasal,
         shouldSmoothGlucose: Bool,
-        useSwiftOref: Bool,
+        useJavascriptOref: Bool,
         clock: Date = Date(),
         simulatedCarbsAmount: Decimal? = nil,
         simulatedBolusAmount: Decimal? = nil,
@@ -448,7 +448,7 @@ final class OpenAPS {
             clock: clock,
             carbs: carbsAsJSON,
             glucose: glucoseAsJSON,
-            useSwiftOref: useSwiftOref
+            useJavascriptOref: useJavascriptOref
         )
 
         // IOB calculation
@@ -457,7 +457,7 @@ final class OpenAPS {
             profile: profile,
             clock: clock,
             autosens: autosens.isEmpty ? .null : autosens,
-            useSwiftOref: useSwiftOref
+            useJavascriptOref: useJavascriptOref
         )
 
         // TODO: refactor this to core data
@@ -485,7 +485,7 @@ final class OpenAPS {
             preferences: preferences,
             basalProfile: basalProfile,
             trioCustomOrefVariables: trioCustomOrefVariables,
-            useSwiftOref: useSwiftOref
+            useJavascriptOref: useJavascriptOref
         )
 
         debug(.openAPS, "\(simulation ? "[SIMULATION]" : "") OREF DETERMINATION: \(orefDetermination)")
@@ -578,7 +578,7 @@ final class OpenAPS {
         }
     }
 
-    func autosense(shouldSmoothGlucose: Bool, useSwiftOref: Bool) async throws -> Autosens? {
+    func autosense(shouldSmoothGlucose: Bool, useJavascriptOref: Bool) async throws -> Autosens? {
         debug(.openAPS, "Start autosens")
 
         // Perform asynchronous calls in parallel
@@ -607,7 +607,7 @@ final class OpenAPS {
             profile: profile,
             carbs: carbsAsJSON,
             temptargets: tempTargets,
-            useSwiftOref: useSwiftOref
+            useJavascriptOref: useJavascriptOref
         )
 
         debug(.openAPS, "AUTOSENS: \(autosenseResult)")
@@ -621,7 +621,7 @@ final class OpenAPS {
         }
     }
 
-    func createProfiles(useSwiftOref: Bool) async throws {
+    func createProfiles(useJavascriptOref: Bool) async throws {
         debug(.openAPS, "Start creating pump profile and user profile")
 
         // Load required settings and profiles asynchronously
@@ -698,7 +698,7 @@ final class OpenAPS {
                 model: model,
                 autotune: RawJSON.null,
                 trioSettings: trioSettings,
-                useSwiftOref: useSwiftOref,
+                useJavascriptOref: useJavascriptOref,
                 clock: clock
             )
 
@@ -713,7 +713,7 @@ final class OpenAPS {
                 model: model,
                 autotune: RawJSON.null,
                 trioSettings: trioSettings,
-                useSwiftOref: useSwiftOref,
+                useJavascriptOref: useJavascriptOref,
                 clock: clock
             )
 
@@ -729,20 +729,26 @@ final class OpenAPS {
         }
     }
 
-    private func iob(pumphistory: JSON, profile: JSON, clock: JSON, autosens: JSON, useSwiftOref: Bool) async throws -> RawJSON {
+    private func iob(
+        pumphistory: JSON,
+        profile: JSON,
+        clock: JSON,
+        autosens: JSON,
+        useJavascriptOref: Bool
+    ) async throws -> RawJSON {
         // FIXME: For now we'll just remove duplicate suspends here (ISSUE-399)
         var pumphistory = pumphistory
         if let pumpHistoryArray = try? JSONBridge.pumpHistory(from: pumphistory) {
             pumphistory = pumpHistoryArray.removingDuplicateSuspendResumeEvents().rawJSON
         }
 
-        if useSwiftOref {
+        if useJavascriptOref {
+            let jsResult = await iobJavascript(pumphistory: pumphistory, profile: profile, clock: clock, autosens: autosens)
+            return try jsResult.returnOrThrow()
+        } else {
             let swiftResult = OpenAPSSwift
                 .iob(pumphistory: pumphistory, profile: profile, clock: clock, autosens: autosens)
             return try swiftResult.returnOrThrow()
-        } else {
-            let jsResult = await iobJavascript(pumphistory: pumphistory, profile: profile, clock: clock, autosens: autosens)
-            return try jsResult.returnOrThrow()
         }
     }
 
@@ -777,9 +783,19 @@ final class OpenAPS {
         clock: JSON,
         carbs: JSON,
         glucose: JSON,
-        useSwiftOref: Bool
+        useJavascriptOref: Bool
     ) async throws -> RawJSON {
-        if useSwiftOref {
+        if useJavascriptOref {
+            let jsResult = await mealJavascript(
+                pumphistory: pumphistory,
+                profile: profile,
+                basalProfile: basalProfile,
+                clock: clock,
+                carbs: carbs,
+                glucose: glucose
+            )
+            return try jsResult.returnOrThrow()
+        } else {
             let swiftResult = OpenAPSSwift
                 .meal(
                     pumphistory: pumphistory,
@@ -790,16 +806,6 @@ final class OpenAPS {
                     glucose: glucose
                 )
             return try swiftResult.returnOrThrow()
-        } else {
-            let jsResult = await mealJavascript(
-                pumphistory: pumphistory,
-                profile: profile,
-                basalProfile: basalProfile,
-                clock: clock,
-                carbs: carbs,
-                glucose: glucose
-            )
-            return try jsResult.returnOrThrow()
         }
     }
 
@@ -843,9 +849,19 @@ final class OpenAPS {
         profile: JSON,
         carbs: JSON,
         temptargets: JSON,
-        useSwiftOref: Bool
+        useJavascriptOref: Bool
     ) async throws -> RawJSON {
-        if useSwiftOref {
+        if useJavascriptOref {
+            let jsResult = await autosenseJavascript(
+                glucose: glucose,
+                pumpHistory: pumpHistory,
+                basalprofile: basalprofile,
+                profile: profile,
+                carbs: carbs,
+                temptargets: temptargets
+            )
+            return try jsResult.returnOrThrow()
+        } else {
             let swiftResult = OpenAPSSwift
                 .autosense(
                     glucose: glucose,
@@ -857,16 +873,6 @@ final class OpenAPS {
                     clock: Date()
                 )
             return try swiftResult.returnOrThrow()
-        } else {
-            let jsResult = await autosenseJavascript(
-                glucose: glucose,
-                pumpHistory: pumpHistory,
-                basalprofile: basalprofile,
-                profile: profile,
-                carbs: carbs,
-                temptargets: temptargets
-            )
-            return try jsResult.returnOrThrow()
         }
     }
 
@@ -916,12 +922,12 @@ final class OpenAPS {
         preferences: JSON,
         basalProfile: JSON,
         trioCustomOrefVariables: JSON,
-        useSwiftOref: Bool
+        useJavascriptOref: Bool
     ) async throws -> RawJSON {
         let clock = Date()
 
-        if useSwiftOref {
-            let swiftResult = OpenAPSSwift.determineBasal(
+        if useJavascriptOref {
+            let jsResult = await determineBasalJavascript(
                 glucose: glucose,
                 currentTemp: currentTemp,
                 iob: iob,
@@ -936,9 +942,9 @@ final class OpenAPS {
                 trioCustomOrefVariables: trioCustomOrefVariables,
                 clock: clock
             )
-            return try swiftResult.returnOrThrow()
+            return try jsResult.returnOrThrow()
         } else {
-            let jsResult = await determineBasalJavascript(
+            let swiftResult = OpenAPSSwift.determineBasal(
                 glucose: glucose,
                 currentTemp: currentTemp,
                 iob: iob,
@@ -953,7 +959,7 @@ final class OpenAPS {
                 trioCustomOrefVariables: trioCustomOrefVariables,
                 clock: clock
             )
-            return try jsResult.returnOrThrow()
+            return try swiftResult.returnOrThrow()
         }
     }
 
@@ -1073,11 +1079,11 @@ final class OpenAPS {
         model: JSON,
         autotune: JSON,
         trioSettings: JSON,
-        useSwiftOref: Bool,
+        useJavascriptOref: Bool,
         clock: Date
     ) async throws -> RawJSON {
-        if useSwiftOref {
-            let swiftResult = OpenAPSSwift.makeProfile(
+        if useJavascriptOref {
+            let jsResult = await makeProfileJavascript(
                 preferences: preferences,
                 pumpSettings: pumpSettings,
                 bgTargets: bgTargets,
@@ -1086,12 +1092,12 @@ final class OpenAPS {
                 carbRatio: carbRatio,
                 tempTargets: tempTargets,
                 model: model,
-                trioSettings: trioSettings,
-                clock: clock
+                autotune: autotune,
+                trioSettings: trioSettings
             )
-            return try swiftResult.returnOrThrow()
+            return try jsResult.returnOrThrow()
         } else {
-            let jsResult = await makeProfileJavascript(
+            let swiftResult = OpenAPSSwift.makeProfile(
                 preferences: preferences,
                 pumpSettings: pumpSettings,
                 bgTargets: bgTargets,
@@ -1100,10 +1106,10 @@ final class OpenAPS {
                 carbRatio: carbRatio,
                 tempTargets: tempTargets,
                 model: model,
-                autotune: autotune,
-                trioSettings: trioSettings
+                trioSettings: trioSettings,
+                clock: clock
             )
-            return try jsResult.returnOrThrow()
+            return try swiftResult.returnOrThrow()
         }
     }
 

+ 3 - 3
Trio/Sources/APS/Storage/GlucoseStorage.swift

@@ -214,6 +214,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
         guard context.hasChanges else { return }
         try context.save()
+        updateSubject.send()
     }
 
     private func storeGlucoseBatch(_ glucose: [BloodGlucose]) throws {
@@ -232,7 +233,6 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
             }
         )
         try context.execute(batchInsert)
-        // Only send update for batch insert since regular save triggers CoreData notifications
         updateSubject.send()
     }
 
@@ -771,11 +771,11 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
                 let glucoseValue = glucose.glucose
 
-                if Decimal(glucoseValue) <= settingsManager.settings.lowGlucose {
+                if Decimal(glucoseValue) <= settingsManager.settings.low {
                     return .low
                 }
 
-                if Decimal(glucoseValue) >= settingsManager.settings.highGlucose {
+                if Decimal(glucoseValue) >= settingsManager.settings.high {
                     return .high
                 }
 

+ 1 - 2
Trio/Sources/APS/Storage/PumpHistoryStorage.swift

@@ -274,8 +274,7 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
             onContext: context,
             predicate: NSPredicate.pumpHistoryLast24h,
             key: "timestamp",
-            ascending: false,
-            fetchLimit: 288
+            ascending: false
         )
 
         return await context.perform {

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

@@ -90,6 +90,9 @@ extension Notification.Name {
             _ = resolver.resolve(LiveActivityManager.self)!
         }
         _ = resolver.resolve(IOBService.self)!
+        _ = resolver.resolve(GlucoseAlertCoordinator.self)!
+        _ = resolver.resolve(NotLoopingMonitor.self)!
+        _ = DeviceAlertsStore.shared
     }
 
     init() {

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

@@ -17,6 +17,12 @@ final class ServiceAssembly: Assembly {
         container.register(CalendarManager.self) { r in BaseCalendarManager(resolver: r) }
         container.register(HKHealthStore.self) { _ in HKHealthStore() }
         container.register(HealthKitManager.self) { r in BaseHealthKitManager(resolver: r) }
+        container.register(TrioAlertManager.self) { r in BaseTrioAlertManager(resolver: r) }
+            .inObjectScope(.container)
+        container.register(GlucoseAlertCoordinator.self) { r in GlucoseAlertCoordinator(resolver: r) }
+            .inObjectScope(.container)
+        container.register(NotLoopingMonitor.self) { r in NotLoopingMonitor(resolver: r) }
+            .inObjectScope(.container)
         container.register(UserNotificationsManager.self) { r in BaseUserNotificationsManager(resolver: r) }
         container.register(WatchManager.self) { r in BaseWatchManager(resolver: r) }
         container.register(BolusCalculationManager.self) { r in BaseBolusCalculationManager(resolver: r) }

+ 8 - 0
Trio/Sources/Helpers/BasalDeliveryState+Extension.swift

@@ -0,0 +1,8 @@
+import LoopKit
+
+extension PumpManagerStatus.BasalDeliveryState {
+    var isManualTempBasal: Bool {
+        guard case let .tempBasal(dose) = self else { return false }
+        return !(dose.automatic ?? true)
+    }
+}

+ 1 - 1
Trio/Sources/Helpers/Formatters.swift

@@ -100,7 +100,7 @@ extension Formatter {
         let formatter = NumberFormatter()
         formatter.numberStyle = .decimal
         formatter.locale = .current
-        formatter.minimumIntegerDigits = 0
+        formatter.minimumIntegerDigits = 1
         formatter.maximumFractionDigits = 2
         return formatter
     }()

+ 29 - 0
Trio/Sources/Helpers/TimeInterval+Convenience.swift

@@ -0,0 +1,29 @@
+import Foundation
+
+/// Convenience constructors + getters used across the app for `TimeInterval`
+/// math at minute/hour granularity. Previously lived inside the deleted
+/// `Snooze` module; broken out here because real callers exist outside the
+/// alert pipeline (chart markers, pump-history windows, fetch timers, etc.).
+extension TimeInterval {
+    static func seconds(_ seconds: Double) -> TimeInterval { seconds }
+
+    static func minutes(_ minutes: Double) -> TimeInterval {
+        TimeInterval(minutes: minutes)
+    }
+
+    static func hours(_ hours: Double) -> TimeInterval {
+        TimeInterval(minutes: hours * 60)
+    }
+
+    init(minutes: Double) {
+        self.init(minutes * 60)
+    }
+
+    init(hours: Double) {
+        self.init(minutes: hours * 60)
+    }
+
+    var minutes: Double { self / 60.0 }
+
+    var hours: Double { minutes / 60.0 }
+}

Diferenças do arquivo suprimidas por serem muito extensas
+ 606 - 19
Trio/Sources/Localizations/Main/Localizable.xcstrings


+ 1 - 47
Trio/Sources/Logger/Logger.swift

@@ -5,7 +5,6 @@ import UIKit
 var LoggerTestMode = false
 
 private let baseReporter = TrioApp.resolver.resolve(GroupedIssueReporter.self)!
-private let router = TrioApp.resolver.resolve(Router.self)!
 
 let loggerLock = NSRecursiveLock()
 
@@ -28,37 +27,13 @@ func debug(
 func info(
     _ category: Logger.Category,
     _ message: String,
-    type: MessageType = .info,
     file: String = #file,
     function: String = #function,
     line: UInt = #line
 ) {
     DispatchWorkItem(qos: .background, flags: .enforceQoS) {
         loggerLock.perform {
-            category.logger.info(message, type: type, file: file, function: function, line: line)
-        }
-    }.perform()
-}
-
-func info(
-    _ category: Logger.Category,
-    _ message: String,
-    notificationText: String,
-    type: MessageType = .info,
-    file: String = #file,
-    function: String = #function,
-    line: UInt = #line
-) {
-    DispatchWorkItem(qos: .background, flags: .enforceQoS) {
-        loggerLock.perform {
-            category.logger.info(
-                message,
-                notificationText: notificationText,
-                type: type,
-                file: file,
-                function: function,
-                line: line
-            )
+            category.logger.info(message, file: file, function: function, line: line)
         }
     }.perform()
 }
@@ -268,18 +243,6 @@ final class Logger {
 
     func info(
         _ message: String,
-        type: MessageType = .info,
-        file: String = #file,
-        function: String = #function,
-        line: UInt = #line
-    ) {
-        info(message, notificationText: message, type: type, file: file, function: function, line: line)
-    }
-
-    func info(
-        _ message: String,
-        notificationText: String,
-        type: MessageType = .info,
         file: String = #file,
         function: String = #function,
         line: UInt = #line
@@ -287,8 +250,6 @@ final class Logger {
         let printedMessage = "INFO: \(message)"
         os_log("%@ - %@ - %d %{public}@", log: log, type: .info, file.file, function, line, printedMessage)
         reporter.log(category.name, printedMessage, file: file, function: function, line: line)
-
-        showAlert(notificationText, type: type)
     }
 
     func warning(
@@ -324,13 +285,6 @@ final class Logger {
         )
     }
 
-    private func showAlert(_ message: String, type: MessageType = .info) {
-        DispatchQueue.main.async {
-            let messageCont = MessageContent(content: message, type: type)
-            router.alertMessage.send(messageCont)
-        }
-    }
-
     fileprivate func errorWithoutFatalError(
         _ message: String,
         description: String? = nil,

+ 0 - 2
Trio/Sources/Models/DecimalPickerSettings.swift

@@ -33,8 +33,6 @@ class PickerSettingsProvider: ObservableObject {
 }
 
 struct DecimalPickerSettings {
-    var lowGlucose = PickerSetting(value: 70, step: 5, min: 40, max: 100, type: PickerSetting.PickerSettingType.glucose)
-    var highGlucose = PickerSetting(value: 180, step: 5, min: 100, max: 400, type: PickerSetting.PickerSettingType.glucose)
     var carbsRequiredThreshold = PickerSetting(value: 10, step: 1, min: 0, max: 100, type: PickerSetting.PickerSettingType.gram)
     var individualAdjustmentFactor = PickerSetting(
         value: 0.5,

+ 34 - 0
Trio/Sources/Models/GlucoseAlerts/AlarmSoundCatalog.swift

@@ -0,0 +1,34 @@
+import Foundation
+
+/// Catalog of the bundled critical-alarm sound files (`Trio/Resources/Sounds/`).
+/// Ported from Loop's audio-critical-alerts branch. Single source of truth
+/// for the alarm sound picker.
+enum AlarmSoundCatalog {
+    /// (filename, displayName) tuples in display order.
+    private static let catalog: [(filename: String, displayName: String)] = [
+        ("urgent_low.caf", String(localized: "Urgent Low")),
+        ("critical.caf", String(localized: "Critical")),
+        ("alarm.caf", String(localized: "Alarm")),
+        ("bright_alarm.caf", String(localized: "Bright Alarm")),
+        ("honk.caf", String(localized: "Honk")),
+        ("trill.caf", String(localized: "Trill")),
+        ("chime.caf", String(localized: "Chime")),
+        ("clear_chimes.caf", String(localized: "Clear Chimes")),
+        ("high_chimes.caf", String(localized: "High Chimes")),
+        ("dings.caf", String(localized: "Dings")),
+        ("bloom.caf", String(localized: "Bloom")),
+        ("bloop.caf", String(localized: "Bloop")),
+        ("spring.caf", String(localized: "Spring")),
+        ("minimal.caf", String(localized: "Minimal")),
+        ("simple.caf", String(localized: "Simple")),
+        ("synth.caf", String(localized: "Synth")),
+        ("mood_synth.caf", String(localized: "Mood Synth")),
+        ("crying.caf", String(localized: "Crying"))
+    ]
+
+    static let allFilenames: [String] = catalog.map(\.filename)
+
+    static func displayName(for filename: String) -> String {
+        catalog.first { $0.filename == filename }?.displayName ?? filename
+    }
+}

+ 33 - 0
Trio/Sources/Models/GlucoseAlerts/DayNightOptions.swift

@@ -0,0 +1,33 @@
+import Foundation
+
+protocol DayNightDisplayable {
+    var displayName: String { get }
+}
+
+extension DayNightDisplayable where Self: RawRepresentable, Self.RawValue == String {
+    var displayName: String {
+        rawValue == "always"
+            ? String(localized: "Day & Night")
+            : rawValue.localizedCapitalized
+    }
+}
+
+enum ActiveOption: String, CaseIterable, Codable, Identifiable, DayNightDisplayable {
+    case always
+    case day
+    case night
+
+    var id: String { rawValue }
+}
+
+struct TimeOfDay: Codable, Equatable, Hashable {
+    var hour: Int
+    var minute: Int
+
+    init(hour: Int, minute: Int) {
+        self.hour = max(0, min(23, hour))
+        self.minute = max(0, min(59, minute))
+    }
+
+    var minutesSinceMidnight: Int { hour * 60 + minute }
+}

+ 79 - 0
Trio/Sources/Models/GlucoseAlerts/DeviceAlertSeverity.swift

@@ -0,0 +1,79 @@
+import Foundation
+import LoopKit
+
+/// Coarse-grained tier the user configures for device alarms. Wraps
+/// `Alert.InterruptionLevel` plus its visible behavior:
+/// - `.critical` overrides Silence & Focus Mode (uses the critical-audio
+///   fallback for builds without the Critical Alerts entitlement)
+/// - `.timeSensitive` pierces normal banner suppression but obeys DND/silent
+/// - `.normal` fires only when the device isn't silenced — informational
+enum DeviceAlertSeverity: String, Codable, CaseIterable, Identifiable {
+    case critical
+    case timeSensitive
+    case normal
+
+    var id: String { rawValue }
+
+    init?(level: Alert.InterruptionLevel) {
+        switch level {
+        case .critical: self = .critical
+        case .timeSensitive: self = .timeSensitive
+        case .active: self = .normal
+        }
+    }
+
+    var displayName: String {
+        switch self {
+        case .critical: return String(localized: "Critical")
+        case .timeSensitive: return String(localized: "Time-Sensitive")
+        case .normal: return String(localized: "Normal")
+        }
+    }
+
+    var blurb: String {
+        switch self {
+        case .critical:
+            return String(localized: "Overrides Silence & Focus Mode. For situations requiring immediate attention.")
+        case .timeSensitive:
+            return String(localized: "Pierces banner suppression but obeys Silence & Focus Mode by default.")
+        case .normal:
+            return String(localized: "Default notification banner. Suppressed by Silence & Focus Mode by default.")
+        }
+    }
+
+    var hintText: String {
+        switch self {
+        case .critical:
+            return String(
+                localized: "For situations that require prompt attention. These break through Silent Mode, Do Not Disturb, and any Focus you have enabled. Examples: a pump fault, an occlusion, or Trio not looping for too long. Heads up: if your build of Trio has Apple's Critical Alerts entitlement, iOS plays its own critical alert sound and the sound you picked for this alert category is ignored."
+            )
+        case .timeSensitive:
+            return String(
+                localized: "For things you should know about soon, but not 'act right now'. These can break through banner suppression on the lock screen, but they still obey Silent Mode and Focus by default. Examples: reservoir running low, pod or patch expiring soon, or glucose data going stale."
+            )
+        case .normal:
+            return String(
+                localized: "For everyday heads-up notifications. These behave like a standard banner — they stay quiet when your phone is silenced or a Focus is on. Examples: an algorithm error, a sensor expiration reminder, or a time-zone change being detected."
+            )
+        }
+    }
+
+    var defaultSoundFilename: String {
+        switch self {
+        case .critical: return "alarm.caf"
+        case .timeSensitive: return "chime.caf"
+        case .normal: return "bloop.caf"
+        }
+    }
+
+    /// Default for the per-tier override toggle when seeded. Tier names are
+    /// labels now — the actual `Alert.InterruptionLevel` is derived from the
+    /// override flag at fire time (true → `.critical`, false → `.timeSensitive`).
+    var defaultOverridesSilenceAndDND: Bool {
+        switch self {
+        case .critical: return true
+        case .normal,
+             .timeSensitive: return false
+        }
+    }
+}

+ 63 - 0
Trio/Sources/Models/GlucoseAlerts/DeviceAlertSeverityConfig.swift

@@ -0,0 +1,63 @@
+import Foundation
+
+/// One user-configured behavior variant for a device-alarm severity tier.
+/// Multiple configs per severity are allowed — each with its own
+/// `activeOption` (Day & Night / Day only / Night only) — so the user can
+/// e.g. have a Critical config that overrides Silence during the day and
+/// a second Critical config that goes silent at night.
+///
+/// Lookup at fire time picks the variant whose `activeOption` matches the
+/// current day/night window, falling back to the `.always` variant.
+struct DeviceAlertSeverityConfig: Codable, Equatable, Identifiable {
+    var id: UUID
+    var severity: DeviceAlertSeverity
+    var isEnabled: Bool
+    var soundFilename: String
+    var playsSound: Bool
+    /// When true, alarms in this tier bypass Focus Mode / silent switch
+    /// (maps to `Alert.InterruptionLevel.critical` and engages the in-process
+    /// `CriticalAlertAudioPlayer` fallback if `playsSound` is true).
+    /// When false, the alarm uses `.timeSensitive`.
+    var overridesSilenceAndDND: Bool
+    var activeOption: ActiveOption
+
+    init(
+        id: UUID = UUID(),
+        severity: DeviceAlertSeverity,
+        activeOption: ActiveOption = .always
+    ) {
+        self.id = id
+        self.severity = severity
+        isEnabled = true
+        soundFilename = severity.defaultSoundFilename
+        playsSound = true
+        overridesSilenceAndDND = severity.defaultOverridesSilenceAndDND
+        self.activeOption = activeOption
+    }
+
+    // MARK: - Codable
+
+    private enum CodingKeys: String, CodingKey {
+        case id
+        case severity
+        case isEnabled
+        case soundFilename
+        case playsSound
+        case overridesSilenceAndDND
+        case activeOption
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
+        severity = try container.decode(DeviceAlertSeverity.self, forKey: .severity)
+        isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true
+        soundFilename = try container.decodeIfPresent(String.self, forKey: .soundFilename) ?? severity.defaultSoundFilename
+        playsSound = try container.decodeIfPresent(Bool.self, forKey: .playsSound) ?? true
+        overridesSilenceAndDND = try container.decodeIfPresent(
+            Bool.self,
+            forKey: .overridesSilenceAndDND
+        ) ?? severity.defaultOverridesSilenceAndDND
+        activeOption = try container.decodeIfPresent(ActiveOption.self, forKey: .activeOption) ?? .always
+    }
+}

+ 77 - 0
Trio/Sources/Models/GlucoseAlerts/GlucoseAlert.swift

@@ -0,0 +1,77 @@
+import Foundation
+
+/// A single configurable glucose alarm. Multiple entries of the same type are
+/// allowed — e.g. a Low alarm `active: .day` at 80 mg/dL plus a second Low
+/// `active: .night` at 70 mg/dL.
+struct GlucoseAlert: Identifiable, Codable, Equatable {
+    var id: UUID
+    var type: GlucoseAlertType
+    var name: String
+    var isEnabled: Bool
+    var thresholdMgDL: Decimal
+    var soundFilename: String
+    /// When false, the alarm fires the banner / notification but no sound.
+    /// iOS still drives haptics from the interruption level.
+    var playsSound: Bool
+    /// When true, this alarm bypasses Focus Mode / silent switch
+    /// modes. Maps to `Alert.InterruptionLevel.critical` and triggers the
+    /// in-process `CriticalAlertAudioPlayer` fallback for builds without the
+    /// Critical Alerts entitlement.
+    var overridesSilenceAndDND: Bool
+    var activeOption: ActiveOption
+    /// Per-alarm snooze. Distinct from the global mute on `AlertMuter`.
+    var snoozedUntil: Date?
+
+    init(type: GlucoseAlertType) {
+        id = UUID()
+        self.type = type
+        name = type.displayName
+        isEnabled = true
+        thresholdMgDL = type.defaultThresholdMgDL
+        soundFilename = type.defaultSoundFilename
+        playsSound = true
+        overridesSilenceAndDND = type.defaultOverridesSilenceAndDND
+        activeOption = .always
+        snoozedUntil = nil
+    }
+
+    /// Whether the coordinator should fire this alarm when a reading breaches.
+    /// Urgent-low is the safety floor — the editor hides the Enabled toggle so
+    /// the user can't accidentally turn it off, and stored `isEnabled = false`
+    /// from a prior install is ignored here.
+    var shouldEvaluate: Bool {
+        type == .urgentLow || isEnabled
+    }
+
+    // MARK: - Codable
+
+    private enum CodingKeys: String, CodingKey {
+        case id
+        case type
+        case name
+        case isEnabled
+        case thresholdMgDL
+        case soundFilename
+        case playsSound
+        case overridesSilenceAndDND
+        case activeOption
+        case snoozedUntil
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        id = try container.decode(UUID.self, forKey: .id)
+        type = try container.decode(GlucoseAlertType.self, forKey: .type)
+        name = try container.decode(String.self, forKey: .name)
+        isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true
+        thresholdMgDL = try container.decode(Decimal.self, forKey: .thresholdMgDL)
+        soundFilename = try container.decodeIfPresent(String.self, forKey: .soundFilename) ?? type.defaultSoundFilename
+        playsSound = try container.decodeIfPresent(Bool.self, forKey: .playsSound) ?? true
+        overridesSilenceAndDND = try container.decodeIfPresent(
+            Bool.self,
+            forKey: .overridesSilenceAndDND
+        ) ?? type.defaultOverridesSilenceAndDND
+        activeOption = try container.decodeIfPresent(ActiveOption.self, forKey: .activeOption) ?? .always
+        snoozedUntil = try container.decodeIfPresent(Date.self, forKey: .snoozedUntil)
+    }
+}

+ 63 - 0
Trio/Sources/Models/GlucoseAlerts/GlucoseAlertConfiguration.swift

@@ -0,0 +1,63 @@
+import Foundation
+
+struct GlucoseAlertConfiguration: Codable, Equatable {
+    var dayStart: TimeOfDay
+    var nightStart: TimeOfDay
+    /// Force Trio alarms on even when the CGM advertises its own.
+    var forceTrioAlertsWhenCGMProvidesOwn: Bool
+
+    init(
+        dayStart: TimeOfDay = TimeOfDay(hour: 6, minute: 0),
+        nightStart: TimeOfDay = TimeOfDay(hour: 22, minute: 0),
+        forceTrioAlertsWhenCGMProvidesOwn: Bool = false
+    ) {
+        self.dayStart = dayStart
+        self.nightStart = nightStart
+        self.forceTrioAlertsWhenCGMProvidesOwn = forceTrioAlertsWhenCGMProvidesOwn
+    }
+
+    private enum CodingKeys: String, CodingKey {
+        case dayStart
+        case nightStart
+        case forceTrioAlertsWhenCGMProvidesOwn
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        dayStart = try container.decode(TimeOfDay.self, forKey: .dayStart)
+        nightStart = try container.decode(TimeOfDay.self, forKey: .nightStart)
+        forceTrioAlertsWhenCGMProvidesOwn = try container.decodeIfPresent(
+            Bool.self,
+            forKey: .forceTrioAlertsWhenCGMProvidesOwn
+        ) ?? false
+    }
+
+    /// Resolve whether `date` falls into the user's "night" window. Mirrors
+    /// LoopFollow's logic: handles both same-day (06→22) and wrap-around
+    /// (22→06) ranges. When `nightStart >= dayStart`, night is "later than
+    /// nightStart OR earlier than dayStart"; otherwise night is the slice
+    /// between nightStart and dayStart.
+    func isNight(at date: Date, calendar: Calendar = .current) -> Bool {
+        let startOfDay = calendar.startOfDay(for: date)
+        guard
+            let dayStartDate = calendar.date(
+                bySettingHour: dayStart.hour,
+                minute: dayStart.minute,
+                second: 0,
+                of: startOfDay
+            ),
+            let nightStartDate = calendar.date(
+                bySettingHour: nightStart.hour,
+                minute: nightStart.minute,
+                second: 0,
+                of: startOfDay
+            )
+        else { return false }
+
+        if nightStartDate >= dayStartDate {
+            return date >= nightStartDate || date < dayStartDate
+        } else {
+            return date >= nightStartDate && date < dayStartDate
+        }
+    }
+}

+ 103 - 0
Trio/Sources/Models/GlucoseAlerts/GlucoseAlertType.swift

@@ -0,0 +1,103 @@
+import Foundation
+import LoopKit
+
+/// Glucose-driven alarm types Trio supports today. Order is priority order
+/// (`urgentLow` highest), matching how `AlarmType.priority` works in
+/// LoopFollow — when multiple alarms would fire on the same reading, the
+/// higher-priority one wins and lower types of the same group are skipped.
+enum GlucoseAlertType: String, Codable, CaseIterable, Identifiable {
+    case urgentLow
+    case low
+    case forecastedLow
+    case high
+    /// Driven by `Determination.carbsReq`, not by a glucose reading. Stored
+    /// alongside the other glucose alarms so the user has one place to
+    /// configure schedule/sound/snooze for everything fired by Trio.
+    case carbsRequired
+
+    var id: String { rawValue }
+
+    var priority: Int { Self.allCases.firstIndex(of: self) ?? 0 }
+
+    /// `true` when the alarm fires off a CGM glucose reading. `false` for
+    /// `forecastedLow` (driven by the determination forecast) and
+    /// `carbsRequired` (driven by the determination's `carbsReq` field).
+    var isReadingDriven: Bool {
+        switch self {
+        case .high,
+             .low,
+             .urgentLow:
+            return true
+        case .carbsRequired,
+             .forecastedLow:
+            return false
+        }
+    }
+
+    /// Parses a glucose-alarm slug emitted by `GlucoseAlertCoordinator`
+    /// (`glucose.<type>.<uuid>`). Returns nil for non-glucose alert
+    /// identifiers — used by `BaseTrioAlertManager.requestSnooze` to decide
+    /// between per-type and global mute routing.
+    init?(slug: String) {
+        let parts = slug.split(separator: ".")
+        guard parts.count >= 2, parts[0] == "glucose" else { return nil }
+        guard let parsed = GlucoseAlertType(rawValue: String(parts[1])) else { return nil }
+        self = parsed
+    }
+
+    var displayName: String {
+        switch self {
+        case .urgentLow: return String(localized: "Urgent Low Glucose")
+        case .low: return String(localized: "Low Glucose")
+        case .forecastedLow: return String(localized: "Low Glucose Soon")
+        case .high: return String(localized: "High Glucose")
+        case .carbsRequired: return String(localized: "Carbs Required")
+        }
+    }
+
+    var blurb: String {
+        switch self {
+        case .urgentLow: return String(localized: "Fires when glucose drops to or below an urgent low threshold.")
+        case .low: return String(localized: "Fires when glucose drops to or below a low threshold.")
+        case .forecastedLow: return String(localized: "Fires when glucose is forecasted to be low within the next 20 minutes.")
+        case .high: return String(localized: "Fires when glucose rises to or above a high threshold.")
+        case .carbsRequired: return String(localized: "Fires when oref recommends eating carbs to avoid a low.")
+        }
+    }
+
+    /// Default threshold when adding a new alarm. Mg/dL for glucose types,
+    /// grams for `carbsRequired`.
+    var defaultThresholdMgDL: Decimal {
+        switch self {
+        case .urgentLow: return 54
+        case .low: return 72
+        case .forecastedLow: return 72
+        case .high: return 270
+        case .carbsRequired: return 10
+        }
+    }
+
+    /// Default bundled sound filename. See `Trio/Resources/Sounds/`.
+    var defaultSoundFilename: String {
+        switch self {
+        case .urgentLow: return "urgent_low.caf"
+        case .low: return "trill.caf"
+        case .forecastedLow: return "bloom.caf"
+        case .high: return "chime.caf"
+        case .carbsRequired: return "bloop.caf"
+        }
+    }
+
+    /// Default for `GlucoseAlert.overridesSilenceAndDND` when adding a new
+    /// alarm. Urgent-low defaults to override-on to match Loop's stance;
+    /// others default off (time-sensitive, doesn't pierce DND / Focus).
+    var defaultOverridesSilenceAndDND: Bool {
+        switch self {
+        case .urgentLow: return true
+        case .carbsRequired,
+             .forecastedLow,
+             .high,
+             .low: return false
+        }
+    }
+}

+ 0 - 27
Trio/Sources/Models/GlucoseNotificationsOption.swift

@@ -1,27 +0,0 @@
-//
-//  GlucoseNotificationOption.swift
-//  FreeAPS
-//
-//  Created by Kimberlie Skandis on 1/18/25.
-//
-import Foundation
-import SwiftUI
-
-public enum GlucoseNotificationsOption: String, JSON, CaseIterable, Identifiable, Codable, Hashable {
-    case disabled
-    case alwaysEveryCGM
-    case onlyAlarmLimits
-
-    public var id: String { rawValue }
-
-    var displayName: String {
-        switch self {
-        case .disabled:
-            return String(localized: "Disabled", comment: "Option to disable glucose notifications")
-        case .alwaysEveryCGM:
-            return String(localized: "Always", comment: "Option to always notify on every CGM reading")
-        case .onlyAlarmLimits:
-            return String(localized: "Only Alarm Limits", comment: "Option to notify only when glucose reaches alarm limits")
-        }
-    }
-}

+ 10 - 0
Trio/Sources/Models/GlucoseSourceKey.swift

@@ -0,0 +1,10 @@
+import Foundation
+
+/// Keys used by `GlucoseSource.sourceInfo()` implementations to advertise
+/// metadata about the active CGM (description, transmitter battery,
+/// Nightscout ping). Consumed by status views + diagnostics.
+enum GlucoseSourceKey: String {
+    case transmitterBattery
+    case nightscoutPing
+    case description
+}

+ 11 - 9
Trio/Sources/Models/NotificationIdentifiers.swift

@@ -6,7 +6,7 @@ enum NotificationCategoryIdentifier: String {
 }
 
 enum NotificationResponseAction: String, CaseIterable {
-    case snooze20 = "Trio.snooze20"
+    case snooze15 = "Trio.snooze15"
     case snooze1hr = "Trio.snooze1hr"
     case snooze3hr = "Trio.snooze3hr"
     case snooze6hr = "Trio.snooze6hr"
@@ -17,8 +17,8 @@ enum NotificationResponseAction: String, CaseIterable {
 
     var minutes: Int {
         switch self {
-        case .snooze20:
-            return 20
+        case .snooze15:
+            return 15
         case .snooze1hr:
             return 60
         case .snooze3hr:
@@ -30,14 +30,14 @@ enum NotificationResponseAction: String, CaseIterable {
 
     var localizedTitle: String {
         switch self {
-        case .snooze20:
-            return String(localized: "20 min", comment: "Snooze glucose alerts for 20 minutes")
+        case .snooze15:
+            return String(localized: "Snooze 15 min", comment: "Snooze glucose alerts for 15 minutes")
         case .snooze1hr:
-            return String(localized: "1 hour", comment: "Snooze glucose alerts for 1 hour")
+            return String(localized: "Snooze 1 hr", comment: "Snooze glucose alerts for 1 hour")
         case .snooze3hr:
-            return String(localized: "3 hours", comment: "Snooze glucose alerts for 3 hours")
+            return String(localized: "Snooze 3 hrs", comment: "Snooze glucose alerts for 3 hours")
         case .snooze6hr:
-            return String(localized: "6 hours", comment: "Snooze glucose alerts for 6 hours")
+            return String(localized: "Snooze 6 hrs", comment: "Snooze glucose alerts for 6 hours")
         }
     }
 }
@@ -54,11 +54,13 @@ enum NotificationCategoryFactory {
             )
         }
 
+        // `.customDismissAction` — otherwise iOS swallows swipes silently and
+        // the in-app banner outlives the notification.
         return UNNotificationCategory(
             identifier: NotificationCategoryIdentifier.trioAlert.rawValue,
             actions: snoozeActions,
             intentIdentifiers: [],
-            options: []
+            options: [.customDismissAction]
         )
     }
 }

+ 8 - 49
Trio/Sources/Models/TrioSettings.swift

@@ -30,14 +30,6 @@ struct TrioSettings: JSON, Equatable, Encodable {
     var displayCalendarIOBandCOB: Bool = false
     var displayCalendarEmojis: Bool = false
     var glucoseBadge: Bool = false
-    var notificationsPump: Bool = true
-    var notificationsCgm: Bool = true
-    var notificationsCarb: Bool = true
-    var notificationsAlgorithm: Bool = true
-    var glucoseNotificationsOption: GlucoseNotificationsOption = .onlyAlarmLimits
-    var addSourceInfoToGlucoseNotifications: Bool = false
-    var lowGlucose: Decimal = 72
-    var highGlucose: Decimal = 270
     var carbsRequiredThreshold: Decimal = 10
     var showCarbsRequiredBadge: Bool = true
     var useFPUconversion: Bool = false
@@ -70,13 +62,14 @@ struct TrioSettings: JSON, Equatable, Encodable {
     var sweetMealFactor: Decimal = 1
     var displayPresets: Bool = true
     var confirmBolus: Bool = false
+    var enableQuickBolus: Bool = false
     var useLiveActivity: Bool = false
     var lockScreenView: LockScreenView = .simple
     var smartStackView: LockScreenView = .simple
     var bolusShortcut: BolusShortcutLimit = .notAllowed
     var timeInRangeType: TimeInRangeType = .timeInTightRange
     var requireAdjustmentsConfirmation: Bool = false
-    var useSwiftOref: Bool = false
+    var useJavascriptOref: Bool = false
 
     /// Selected Garmin watchface (Trio or SwissAlpine)
     var garminWatchface: GarminWatchface = .trio
@@ -214,44 +207,6 @@ extension TrioSettings: Decodable {
             settings.delay = delay
         }
 
-        if let notificationsPump = try? container.decode(Bool.self, forKey: .notificationsPump) {
-            settings.notificationsPump = notificationsPump
-        }
-
-        if let notificationsCgm = try? container.decode(Bool.self, forKey: .notificationsCgm) {
-            settings.notificationsCgm = notificationsCgm
-        }
-
-        if let notificationsCarb = try? container.decode(Bool.self, forKey: .notificationsCarb) {
-            settings.notificationsCarb = notificationsCarb
-        }
-
-        if let notificationsAlgorithm = try? container.decode(Bool.self, forKey: .notificationsAlgorithm) {
-            settings.notificationsAlgorithm = notificationsAlgorithm
-        }
-
-        if let glucoseNotificationsOption = try? container.decode(
-            GlucoseNotificationsOption.self,
-            forKey: .glucoseNotificationsOption
-        ) {
-            settings.glucoseNotificationsOption = glucoseNotificationsOption
-        }
-
-        if let addSourceInfoToGlucoseNotifications = try? container.decode(
-            Bool.self,
-            forKey: .addSourceInfoToGlucoseNotifications
-        ) {
-            settings.addSourceInfoToGlucoseNotifications = addSourceInfoToGlucoseNotifications
-        }
-
-        if let lowGlucose = try? container.decode(Decimal.self, forKey: .lowGlucose) {
-            settings.lowGlucose = lowGlucose
-        }
-
-        if let highGlucose = try? container.decode(Decimal.self, forKey: .highGlucose) {
-            settings.highGlucose = highGlucose
-        }
-
         if let carbsRequiredThreshold = try? container.decode(Decimal.self, forKey: .carbsRequiredThreshold) {
             settings.carbsRequiredThreshold = carbsRequiredThreshold
         }
@@ -340,6 +295,10 @@ extension TrioSettings: Decodable {
             settings.confirmBolus = confirmBolus
         }
 
+        if let enableQuickBolus = try? container.decode(Bool.self, forKey: .enableQuickBolus) {
+            settings.enableQuickBolus = enableQuickBolus
+        }
+
         if let useLiveActivity = try? container.decode(Bool.self, forKey: .useLiveActivity) {
             settings.useLiveActivity = useLiveActivity
         }
@@ -364,8 +323,8 @@ extension TrioSettings: Decodable {
             settings.requireAdjustmentsConfirmation = requireAdjustmentsConfirmation
         }
 
-        if let useSwiftOref = try? container.decode(Bool.self, forKey: .useSwiftOref) {
-            settings.useSwiftOref = useSwiftOref
+        if let useJavascriptOref = try? container.decode(Bool.self, forKey: .useJavascriptOref) {
+            settings.useJavascriptOref = useJavascriptOref
         }
 
         if let garminWatchface = try? container.decode(GarminWatchface.self, forKey: .garminWatchface) {

+ 7 - 0
Trio/Sources/Modules/AlarmWindows/AlarmWindowsDataFlow.swift

@@ -0,0 +1,7 @@
+import Combine
+
+enum AlarmWindows {
+    enum Config {}
+}
+
+protocol AlarmWindowsProvider: Provider {}

+ 5 - 0
Trio/Sources/Modules/AlarmWindows/AlarmWindowsProvider.swift

@@ -0,0 +1,5 @@
+import Foundation
+
+extension AlarmWindows {
+    final class Provider: BaseProvider, AlarmWindowsProvider {}
+}

+ 18 - 0
Trio/Sources/Modules/AlarmWindows/AlarmWindowsStateModel.swift

@@ -0,0 +1,18 @@
+import Combine
+import SwiftUI
+
+extension AlarmWindows {
+    final class StateModel: BaseStateModel<Provider> {
+        @Published var units: GlucoseUnits = .mgdL
+
+        override func subscribe() {
+            units = settingsManager.settings.units
+        }
+    }
+}
+
+extension AlarmWindows.StateModel: SettingsObserver {
+    func settingsDidChange(_: TrioSettings) {
+        units = settingsManager.settings.units
+    }
+}

+ 135 - 0
Trio/Sources/Modules/AlarmWindows/View/AlarmWindowsRootView.swift

@@ -0,0 +1,135 @@
+import SwiftUI
+import Swinject
+
+extension AlarmWindows {
+    struct RootView: BaseView {
+        let resolver: Resolver
+        @StateObject var state = StateModel()
+        @StateObject private var store = GlucoseAlertsStore.shared
+
+        @State private var shouldDisplayHint: Bool = false
+        @State private var hintDetent = PresentationDetent.large
+        @State private var selectedVerboseHint: AnyView?
+        @State private var hintLabel: String?
+
+        @Environment(\.colorScheme) var colorScheme
+        @Environment(AppState.self) var appState
+
+        private var dayStart: Binding<Date> {
+            Binding(
+                get: { Self.dateFromTimeOfDay(store.configuration.dayStart) },
+                set: { store.configuration.dayStart = Self.timeOfDay(from: $0) }
+            )
+        }
+
+        private var nightStart: Binding<Date> {
+            Binding(
+                get: { Self.dateFromTimeOfDay(store.configuration.nightStart) },
+                set: { store.configuration.nightStart = Self.timeOfDay(from: $0) }
+            )
+        }
+
+        var body: some View {
+            List {
+                Section(header: Text("Window Boundaries")) {
+                    VStack {
+                        DatePicker(
+                            selection: dayStart,
+                            displayedComponents: .hourAndMinute
+                        ) {
+                            HStack {
+                                Image(systemName: "sun.max.fill").foregroundStyle(.orange)
+                                Text("Day Starts")
+                            }
+                        }
+                        .padding(.top)
+
+                        DatePicker(
+                            selection: nightStart,
+                            displayedComponents: .hourAndMinute
+                        ) {
+                            HStack {
+                                Image(systemName: "moon.stars.fill").foregroundStyle(.indigo)
+                                Text("Night Starts")
+                            }
+                        }
+
+                        HStack(alignment: .center) {
+                            Text("Decides when each alarm's Day or Night setting applies.")
+                                .lineLimit(nil)
+                                .font(.footnote)
+                                .foregroundColor(.secondary)
+
+                            Spacer()
+                            Button(
+                                action: {
+                                    hintLabel = String(localized: "Day and Night Window")
+                                    let dayDefault = Self.formattedTimeOfDay(hour: 6, minute: 0)
+                                    let nightDefault = Self.formattedTimeOfDay(hour: 22, minute: 0)
+                                    selectedVerboseHint = AnyView(
+                                        VStack(alignment: .leading, spacing: 10) {
+                                            Text(
+                                                String(
+                                                    format: String(localized: "Default: Day starts %1$@, Night starts %2$@."),
+                                                    dayDefault,
+                                                    nightDefault
+                                                )
+                                            ).bold()
+                                            Text(
+                                                "These two times define the Day and Night windows. Each alarm's Active setting picks one — Day & Night, Day only, or Night only — and only fires when that window is current."
+                                            )
+                                            Text(
+                                                String(
+                                                    format: String(
+                                                        localized: "The Night window runs from 'Night Starts' back around to 'Day Starts' — so by default, Night covers %1$@ through %2$@ the next morning."
+                                                    ),
+                                                    nightDefault,
+                                                    dayDefault
+                                                )
+                                            )
+                                            Text(
+                                                "These windows are shared between Glucose Alarms and Device Alarms."
+                                            )
+                                        }
+                                    )
+                                    shouldDisplayHint.toggle()
+                                },
+                                label: { Image(systemName: "questionmark.circle") }
+                            ).buttonStyle(BorderlessButtonStyle())
+                        }.padding(.top)
+                    }.padding(.bottom)
+                }.listRowBackground(Color.chart)
+            }
+            .scrollContentBackground(.hidden)
+            .background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle("Day & Night Windows")
+            .navigationBarTitleDisplayMode(.inline)
+            .sheet(isPresented: $shouldDisplayHint) {
+                SettingInputHintView(
+                    hintDetent: $hintDetent,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    hintLabel: hintLabel ?? "",
+                    hintText: selectedVerboseHint ?? AnyView(EmptyView()),
+                    sheetTitle: String(localized: "Help")
+                )
+            }
+            .onAppear(perform: configureView)
+        }
+
+        private static func dateFromTimeOfDay(_ time: TimeOfDay) -> Date {
+            Calendar.current.date(bySettingHour: time.hour, minute: time.minute, second: 0, of: Date()) ?? Date()
+        }
+
+        private static func timeOfDay(from date: Date) -> TimeOfDay {
+            let comps = Calendar.current.dateComponents([.hour, .minute], from: date)
+            return TimeOfDay(hour: comps.hour ?? 0, minute: comps.minute ?? 0)
+        }
+
+        /// Locale-aware HH:mm / h:mm a. Used so 12-hour locales see "10:00 PM"
+        /// instead of "22:00" in hint copy.
+        private static func formattedTimeOfDay(hour: Int, minute: Int) -> String {
+            let date = Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: Date()) ?? Date()
+            return date.formatted(.dateTime.hour().minute())
+        }
+    }
+}

+ 3 - 3
Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsStateModel.swift

@@ -22,7 +22,7 @@ extension AlgorithmAdvancedSettings {
         @Published var remainingCarbsFraction: Decimal = 1.0
         @Published var remainingCarbsCap: Decimal = 90
         @Published var noisyCGMTargetMultiplier: Decimal = 1.3
-        @Published var useSwiftOref: Bool = false
+        @Published var useJavascriptOref: Bool = false
         // preference
         @Published var insulinActionCurve: Decimal = 10
         @Published var smbDeliveryRatio: Decimal = 0.5
@@ -48,8 +48,8 @@ extension AlgorithmAdvancedSettings {
             subscribePreferencesSetting(\.remainingCarbsCap, on: $remainingCarbsCap) { remainingCarbsCap = $0 }
             subscribePreferencesSetting(\.noisyCGMTargetMultiplier, on: $noisyCGMTargetMultiplier) {
                 noisyCGMTargetMultiplier = $0 }
-            subscribeSetting(\.useSwiftOref, on: $useSwiftOref) {
-                useSwiftOref = $0 }
+            subscribeSetting(\.useJavascriptOref, on: $useJavascriptOref) {
+                useJavascriptOref = $0 }
             subscribePreferencesSetting(\.smbDeliveryRatio, on: $smbDeliveryRatio) { smbDeliveryRatio = $0 }
             subscribePreferencesSetting(\.smbInterval, on: $smbInterval) { smbInterval = $0 }
             subscribePreferencesSetting(\.smbDeliveryRatio, on: $smbDeliveryRatio) { smbDeliveryRatio = $0 }

+ 7 - 8
Trio/Sources/Modules/AlgorithmAdvancedSettings/View/AlgorithmAdvancedSettingsRootView.swift

@@ -392,31 +392,30 @@ extension AlgorithmAdvancedSettings {
                 )
                 SettingInputSection(
                     decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.useSwiftOref,
+                    booleanValue: $state.useJavascriptOref,
                     shouldDisplayHint: $shouldDisplayHint,
                     selectedVerboseHint: Binding(
                         get: { selectedVerboseHint },
                         set: {
                             selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = NSLocalizedString("Use Swift Oref", comment: "Use Swift Oref")
+                            hintLabel = NSLocalizedString("Use JavaScript Oref", comment: "Use JavaScript Oref")
                         }
                     ),
                     units: state.units,
                     type: .boolean,
-                    label: NSLocalizedString("Use Swift Oref", comment: "Use Swift Oref"),
+                    label: NSLocalizedString("Use JavaScript Oref", comment: "Use JavaScript Oref"),
                     miniHint: String(
-                        localized: "EXPERIMENTAL FEATURE! Enables new, fully Swift-based algorithm version.",
-                        comment: "Use Swift Oref mini hint"
+                        localized: "Switches back to the legacy JavaScript-based algorithm version.",
+                        comment: "Use JavaScript Oref mini hint"
                     ),
                     verboseHint:
                     VStack(alignment: .leading, spacing: 10) {
                         Text("Default: OFF").bold()
-                        Text("This feature is EXPERIMENTAL and not yet cleared for general use.").bold().foregroundStyle(.orange)
                         Text(
-                            "We're building a faster and more maintainable Swift version of the algorithm (Oref) that runs in Trio. It's faster, more accurate and improves Trio for everyone."
+                            "Trio now uses a fully Swift-based version of the algorithm (Oref) by default. It's faster, more accurate and improves Trio for everyone."
                         )
                         Text(
-                            "When enabled, Trio will no longer use the old JavaScript-based algorithm that runs virtualized on your phone. Instead, it will use a fully Swift-based algorithm."
+                            "When enabled, Trio will instead use the legacy JavaScript-based algorithm that runs virtualized on your phone. Only enable this if you encounter issues with the Swift-based algorithm."
                         )
 
                         Text(

+ 39 - 0
Trio/Sources/Modules/CGMSettings/CGMSettingsDataFlow.swift

@@ -1,5 +1,44 @@
+import LoopKit
+import SwiftUI
+
 enum CGMSettings {
     enum Config {}
 }
 
 protocol CGMSettingsProvider: Provider {}
+
+/// `cgmStatusHighlight` reduced to message + tier — what the home UI needs.
+/// `imageName` is the SF Symbol name the manager wants to associate with this
+/// state (G6/G7/LibreLoop all surface this); empty string when the manager
+/// doesn't provide one.
+struct CgmDisplayState: Equatable {
+    let localizedMessage: String
+    let imageName: String
+    let status: CgmDisplayStatus
+}
+
+enum CgmDisplayStatus {
+    case normal
+    case warning
+    case critical
+
+    var color: Color {
+        switch self {
+        case .critical: return .critical
+        case .warning: return .warning
+        case .normal: return .loopAccent
+        }
+    }
+
+    static func from(_ state: LoopKit.DeviceStatusHighlightState) -> CgmDisplayStatus {
+        switch state {
+        case .normalCGM,
+             .normalPump:
+            return .normal
+        case .warning:
+            return .warning
+        case .critical:
+            return .critical
+        }
+    }
+}

+ 66 - 10
Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift

@@ -1,3 +1,4 @@
+import LoopKit
 import LoopKitUI
 import SwiftUI
 import Swinject
@@ -22,6 +23,30 @@ extension CGMSettings {
         @State private var noiseAmplitude: Double = UserDefaults.standard.double(forKey: "GlucoseSimulator_NoiseAmplitude")
         @State private var produceStaleValues: Bool = UserDefaults.standard.bool(forKey: "GlucoseSimulator_ProduceStaleValues")
 
+        /// Drives the synthetic `cgmStatusHighlight`
+        @State private var simulatedScenarioRaw: String = UserDefaults.standard
+            .string(forKey: "GlucoseSimulator.simulatedScenario") ?? SimulatedSensorScenario.runningNormally.rawValue
+
+        /// Routes "open URL failed" warnings through `TrioAlertManager` so
+        /// they share the same in-app banner UI as the rest of the alert
+        /// pipeline (no more SwiftMessages roundtrip).
+        private func warnOpenFailed(identifier: String, title: String, body: String) {
+            let content = Alert.Content(
+                title: title,
+                body: body,
+                acknowledgeActionButtonLabel: String(localized: "OK")
+            )
+            let alert = Alert(
+                identifier: Alert.Identifier(managerIdentifier: "trio.cgmSettings", alertIdentifier: identifier),
+                foregroundContent: content,
+                backgroundContent: content,
+                trigger: .immediate,
+                interruptionLevel: .active,
+                sound: nil
+            )
+            resolver.resolve(TrioAlertManager.self)?.issueAlert(alert)
+        }
+
         // Initialize state variables with defaults if needed
         private func initializeSimulatorSettings() {
             if centerValue == 0 {
@@ -65,11 +90,11 @@ extension CGMSettings {
                                 Button {
                                     UIApplication.shared.open(appURL, options: [:]) { success in
                                         if !success {
-                                            self.router.alertMessage
-                                                .send(MessageContent(
-                                                    content: "Unable to open the app",
-                                                    type: .warning
-                                                ))
+                                            warnOpenFailed(
+                                                identifier: "cgm.app.open.failed",
+                                                title: String(localized: "Open failed"),
+                                                body: String(localized: "Unable to open the app")
+                                            )
                                         }
                                     }
                                 }
@@ -152,11 +177,11 @@ extension CGMSettings {
                         Button {
                             UIApplication.shared.open(url, options: [:]) { success in
                                 if !success {
-                                    self.router.alertMessage
-                                        .send(MessageContent(
-                                            content: "No URL available",
-                                            type: .warning
-                                        ))
+                                    warnOpenFailed(
+                                        identifier: "nightscout.open.failed",
+                                        title: String(localized: "Open failed"),
+                                        body: String(localized: "No URL available")
+                                    )
                                 }
                             }
                         }
@@ -261,6 +286,37 @@ extension CGMSettings {
                     }
                 }.listRowBackground(Color.chart)
 
+                Section(
+                    header: Text("Sensor Lifecycle Scenario"),
+                    footer: Text(
+                        "Drives the outer-ring + tag on the home screen's glucose bobble."
+                    )
+                ) {
+                    Picker("Scenario", selection: $simulatedScenarioRaw) {
+                        ForEach(SimulatedSensorScenario.allCases) { scenario in
+                            Text(scenario.displayName).tag(scenario.rawValue)
+                        }
+                    }
+                    .pickerStyle(.menu)
+                    .onChange(of: simulatedScenarioRaw) { _, newValue in
+                        UserDefaults.standard.set(newValue, forKey: "GlucoseSimulator.simulatedScenario")
+                        // Push the change through the active simulator
+                        // instance so subjects emit immediately.
+                        if let scenario = SimulatedSensorScenario(rawValue: newValue),
+                           let sim = resolver.resolve(FetchGlucoseManager.self)?.glucoseSource as? GlucoseSimulatorSource
+                        {
+                            sim.applySimulatedScenario(scenario)
+                        }
+                    }
+
+                    if let scenario = SimulatedSensorScenario(rawValue: simulatedScenarioRaw) {
+                        Text(scenario.devNotes)
+                            .font(.footnote)
+                            .foregroundStyle(Color.secondary)
+                            .lineLimit(nil)
+                    }
+                }.listRowBackground(Color.chart)
+
                 if !produceStaleValues {
                     Section {
                         VStack(alignment: .leading, spacing: 10) {

+ 27 - 1
Trio/Sources/Modules/Calibrations/CalibrationsStateModel.swift

@@ -1,4 +1,5 @@
 import CoreData
+import LoopKit
 import Observation
 import SwiftDate
 import SwiftUI
@@ -7,6 +8,7 @@ extension Calibrations {
     @Observable final class StateModel: BaseStateModel<Provider> {
         @ObservationIgnored @Injected() var glucoseStorage: GlucoseStorage!
         @ObservationIgnored @Injected() var calibrationService: CalibrationService!
+        @ObservationIgnored @Injected() var trioAlertManager: TrioAlertManager!
 
         var slope: Double = 1
         var intercept: Double = 1
@@ -77,7 +79,8 @@ extension Calibrations {
 
                     calibrationService.addCalibration(calibration)
                 } else {
-                    info(.service, "Glucose is stale for calibration")
+                    debug(.service, "Glucose is stale for calibration")
+                    issueStaleGlucoseAlert()
                     return
                 }
             } catch {
@@ -85,6 +88,29 @@ extension Calibrations {
             }
         }
 
+        /// Surfaces the "glucose too stale to calibrate against" condition as
+        /// a one-shot info alert through `TrioAlertManager`. Mirrors the old
+        /// `info(.service, …)` banner path that ran via `router.alertMessage`.
+        private func issueStaleGlucoseAlert() {
+            let content = Alert.Content(
+                title: String(localized: "Calibration unavailable"),
+                body: String(localized: "Glucose is stale for calibration"),
+                acknowledgeActionButtonLabel: String(localized: "OK")
+            )
+            let alert = Alert(
+                identifier: Alert.Identifier(
+                    managerIdentifier: "trio.calibration",
+                    alertIdentifier: "glucose.stale"
+                ),
+                foregroundContent: content,
+                backgroundContent: content,
+                trigger: .immediate,
+                interruptionLevel: .active,
+                sound: nil
+            )
+            trioAlertManager?.issueAlert(alert)
+        }
+
         func removeLast() {
             calibrationService.removeLast()
             setupCalibrations()

+ 7 - 0
Trio/Sources/Modules/DeviceAlarms/DeviceAlarmsDataFlow.swift

@@ -0,0 +1,7 @@
+import Combine
+
+enum DeviceAlarms {
+    enum Config {}
+}
+
+protocol DeviceAlarmsProvider: Provider {}

+ 5 - 0
Trio/Sources/Modules/DeviceAlarms/DeviceAlarmsProvider.swift

@@ -0,0 +1,5 @@
+import Foundation
+
+extension DeviceAlarms {
+    final class Provider: BaseProvider, DeviceAlarmsProvider {}
+}

+ 8 - 0
Trio/Sources/Modules/DeviceAlarms/DeviceAlarmsStateModel.swift

@@ -0,0 +1,8 @@
+import Combine
+import SwiftUI
+
+extension DeviceAlarms {
+    final class StateModel: BaseStateModel<Provider> {
+        override func subscribe() {}
+    }
+}

+ 65 - 0
Trio/Sources/Modules/DeviceAlarms/View/AddDeviceAlarmSheet.swift

@@ -0,0 +1,65 @@
+import SwiftUI
+
+struct AddDeviceAlarmSheet: View {
+    let onPick: (DeviceAlertSeverity) -> Void
+
+    @Environment(\.dismiss) private var dismiss
+    @Environment(\.colorScheme) private var colorScheme
+    @Environment(AppState.self) private var appState
+
+    var body: some View {
+        NavigationStack {
+            List {
+                Section {
+                    ForEach(DeviceAlertSeverity.allCases) { severity in
+                        Button {
+                            onPick(severity)
+                            dismiss()
+                        } label: {
+                            HStack(spacing: 12) {
+                                Image(systemName: icon(for: severity))
+                                    .foregroundStyle(tint(for: severity))
+                                    .frame(width: 28)
+                                VStack(alignment: .leading, spacing: 2) {
+                                    Text(severity.displayName)
+                                        .foregroundColor(.primary)
+                                    Text(severity.blurb)
+                                        .font(.footnote)
+                                        .foregroundColor(.secondary)
+                                }
+                                Spacer()
+                                Image(systemName: "chevron.right")
+                                    .font(.footnote)
+                                    .foregroundColor(.secondary)
+                            }
+                        }
+                    }
+                }.listRowBackground(Color.chart)
+            }
+            .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle("Add Variant")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button(String(localized: "Cancel")) { dismiss() }
+                }
+            }
+        }
+    }
+
+    private func icon(for severity: DeviceAlertSeverity) -> String {
+        switch severity {
+        case .critical: return "exclamationmark.triangle.fill"
+        case .timeSensitive: return "bell.badge.fill"
+        case .normal: return "bell.fill"
+        }
+    }
+
+    private func tint(for severity: DeviceAlertSeverity) -> Color {
+        switch severity {
+        case .critical: return .red
+        case .timeSensitive: return .orange
+        case .normal: return .accentColor
+        }
+    }
+}

+ 129 - 0
Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmEditorView.swift

@@ -0,0 +1,129 @@
+import LoopKit
+import SwiftUI
+
+struct DeviceAlarmEditorView: View {
+    @ObservedObject var store: DeviceAlertsStore
+    let configID: UUID
+    let isNew: Bool
+    var onDone: () -> Void
+    var onCancel: () -> Void
+
+    @Environment(\.dismiss) private var dismiss
+    @Environment(\.colorScheme) private var colorScheme
+    @Environment(AppState.self) private var appState
+    @State private var working: DeviceAlertSeverityConfig
+
+    init(
+        store: DeviceAlertsStore,
+        initial: DeviceAlertSeverityConfig,
+        isNew: Bool,
+        onDone: @escaping () -> Void,
+        onCancel: @escaping () -> Void = {}
+    ) {
+        self.store = store
+        configID = initial.id
+        self.isNew = isNew
+        self.onDone = onDone
+        self.onCancel = onCancel
+        _working = State(initialValue: initial)
+    }
+
+    var body: some View {
+        NavigationStack {
+            Form {
+                Section(
+                    header: Text("Behavior"),
+                    footer: Text(working.severity.blurb)
+                ) {
+                    HStack {
+                        Text(working.severity.displayName).font(.headline)
+                        Spacer()
+                        Text(activeLabel)
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                    }
+                    // Critical-tier configs are always armed — the user can
+                    // mute the sound via the Audio section but not turn the
+                    // alarm itself off. Other tiers expose the toggle.
+                    if working.severity != .critical {
+                        Toggle(String(localized: "Enabled"), isOn: $working.isEnabled)
+                    }
+                    Toggle(
+                        String(localized: "Override Silence & Focus Mode"),
+                        isOn: $working.overridesSilenceAndDND
+                    )
+                }.listRowBackground(Color.chart)
+
+                AlarmActiveSection(activeOption: $working.activeOption)
+                AlarmAudioSection(
+                    playsSound: $working.playsSound,
+                    soundFilename: $working.soundFilename
+                )
+
+                Section(header: Text("Applies To (Cannot be changed)")) {
+                    ForEach(conceptsForTier(working.severity), id: \.self) { concept in
+                        Text(concept.displayTitle)
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                    }
+                }.listRowBackground(Color.chart)
+
+                if !isNew, store.canDelete(working) {
+                    Section {
+                        Button(role: .destructive) {
+                            store.remove(working)
+                            dismiss()
+                        } label: {
+                            Text("Delete Variant")
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+            }
+            .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle(working.severity.displayName)
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .confirmationAction) {
+                    Button(isNew ? String(localized: "Add") : String(localized: "Done")) {
+                        if isNew {
+                            store.add(working)
+                        } else {
+                            store.update(working)
+                        }
+                        onDone()
+                        dismiss()
+                    }
+                }
+                ToolbarItem(placement: .cancellationAction) {
+                    Button(String(localized: "Cancel")) {
+                        onCancel()
+                        dismiss()
+                    }
+                }
+            }
+        }
+    }
+
+    private var activeLabel: String {
+        switch working.activeOption {
+        case .always: return String(localized: "Day & Night")
+        case .day: return String(localized: "Day only")
+        case .night: return String(localized: "Night only")
+        }
+    }
+
+    /// Distinct alarm concepts whose catalog entries fall into this tier.
+    /// Sorted by display title so the list is stable across plugin changes.
+    private func conceptsForTier(_ tier: DeviceAlertSeverity) -> [LoopKit.Alert.CatalogConcept] {
+        var seen: Set<LoopKit.Alert.CatalogConcept> = []
+        var ordered: [LoopKit.Alert.CatalogConcept] = []
+        for entry in AlertCatalogRegistry.entries
+            where DeviceAlertSeverity(level: entry.interruptionLevel) == tier
+        {
+            if seen.insert(entry.concept).inserted {
+                ordered.append(entry.concept)
+            }
+        }
+        return ordered.sorted { $0.displayTitle < $1.displayTitle }
+    }
+}

+ 226 - 0
Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmsRootView.swift

@@ -0,0 +1,226 @@
+import SwiftUI
+import Swinject
+
+private enum DeviceAlarmSheet: Identifiable {
+    case picker
+    case editor(DeviceAlertSeverityConfig, isNew: Bool)
+    case help(DeviceAlertSeverity)
+
+    var id: String {
+        switch self {
+        case .picker: return "picker"
+        case let .editor(config, _): return config.id.uuidString
+        case let .help(severity): return "helpSheet_" + severity.id
+        }
+    }
+}
+
+extension DeviceAlarms {
+    struct RootView: BaseView {
+        let resolver: Resolver
+        @StateObject var state = StateModel()
+        @StateObject private var store = DeviceAlertsStore.shared
+
+        @State private var sheet: DeviceAlarmSheet?
+        @State private var pendingNewSeverity: DeviceAlertSeverity?
+
+        @State private var shouldDisplayHint: Bool = false
+        @State var hintDetent = PresentationDetent.large
+        @State var selectedVerboseHint: AnyView?
+
+        @Environment(\.colorScheme) var colorScheme
+        @Environment(AppState.self) var appState
+
+        var body: some View {
+            List {
+                ForEach(DeviceAlertSeverity.allCases) { severity in
+                    Section {
+                        VStack(alignment: .leading, spacing: 5) {
+                            HStack {
+                                Image(systemName: severityIcon(for: severity))
+                                    .foregroundStyle(severityTint(for: severity))
+                                Text(severity.displayName)
+                                Spacer()
+                            }.font(.headline)
+
+                            HStack(alignment: .center) {
+                                Text(severity.blurb)
+                                    .font(.footnote)
+                                    .foregroundColor(.secondary)
+                                    .lineLimit(nil)
+                                    .fixedSize(horizontal: false, vertical: true)
+                                Spacer()
+                                Button(action: {
+                                    sheet = .help(severity)
+                                }) {
+                                    HStack {
+                                        Image(systemName: "questionmark.circle")
+                                    }
+                                }
+                                .buttonStyle(BorderlessButtonStyle())
+                            }.padding(.vertical, 5)
+                        }
+
+                        ForEach(store.configs(in: severity)) { config in
+                            row(for: config)
+                                .opacity(config.isEnabled ? 1 : 0.5)
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+
+                Section {
+                    Text("Day & Night Windows")
+                        .foregroundStyle(Color.accentColor)
+                        .navigationLink(to: .alarmWindows, from: self)
+                }.listRowBackground(Color.chart)
+            }
+            .scrollContentBackground(.hidden)
+            .background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle("Device Alarms")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .primaryAction) {
+                    Button { sheet = .picker } label: { Image(systemName: "plus") }
+                }
+            }
+            .sheet(item: $sheet, onDismiss: handleSheetDismiss) { which in
+                switch which {
+                case .picker:
+                    AddDeviceAlarmSheet { severity in
+                        pendingNewSeverity = severity
+                        sheet = nil
+                    }
+                case let .editor(config, isNew):
+                    DeviceAlarmEditorView(
+                        store: store,
+                        initial: config,
+                        isNew: isNew,
+                        onDone: { sheet = nil },
+                        onCancel: { sheet = nil }
+                    )
+                case let .help(severity):
+                    SettingInputHintView(
+                        hintDetent: $hintDetent,
+                        shouldDisplayHint: Binding(
+                            get: { sheet != nil },
+                            set: { if !$0 { sheet = nil } }
+                        ),
+                        hintLabel: String(
+                            localized: "\(severity.displayName) Device Alerts",
+                            comment: "Device Alerts help sheet label; text reads: '<severity level> Device Alerts'."
+                        ),
+                        hintText: selectedVerboseHint ?? AnyView(
+                            VStack(alignment: .leading, spacing: 10) {
+                                Text(severity.hintText)
+                            }
+                        ),
+                        sheetTitle: String(localized: "Help", comment: "Help sheet title")
+                    )
+                }
+            }
+            .onAppear(perform: configureView)
+        }
+
+        private func handleSheetDismiss() {
+            guard let severity = pendingNewSeverity else { return }
+            pendingNewSeverity = nil
+            DispatchQueue.main.async {
+                let new = DeviceAlertSeverityConfig(
+                    severity: severity,
+                    activeOption: nextAvailableOption(for: severity)
+                )
+                sheet = .editor(new, isNew: true)
+            }
+        }
+
+        // MARK: - Row
+
+        @ViewBuilder private func row(for config: DeviceAlertSeverityConfig) -> some View {
+            Button {
+                sheet = .editor(config, isNew: false)
+            } label: {
+                HStack(spacing: 12) {
+                    AlarmWindowIcon(option: config.activeOption)
+                        .font(.title3)
+                    VStack(alignment: .leading, spacing: 2) {
+                        Text(windowLabel(for: config.activeOption))
+                            .foregroundColor(.primary)
+                        soundSummary(for: config)
+                    }
+                    Spacer()
+                    Image(systemName: "chevron.right")
+                        .font(.footnote)
+                        .foregroundColor(.secondary)
+                }
+            }
+            .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+                if store.canDelete(config) {
+                    Button(role: .destructive) {
+                        store.remove(config)
+                    } label: {
+                        Label("Delete", systemImage: "trash")
+                    }
+                }
+            }
+        }
+
+        private func soundSummary(for config: DeviceAlertSeverityConfig) -> some View {
+            // Show the sound and override facts independently. Previously
+            // the override badge was hidden when sound was off, but "sound
+            // off + override on" is a valid combo (silent + haptic that
+            // breaks through Focus / Sleep) and the user needs to see it.
+            HStack(spacing: 6) {
+                HStack(spacing: 4) {
+                    Image(systemName: config.playsSound ? "speaker.wave.2.fill" : "speaker.slash.fill")
+                    Text(config.playsSound ? "Sound on" : "Sound off")
+                }
+                if config.overridesSilenceAndDND {
+                    Text("·")
+                    HStack(spacing: 4) {
+                        Image(systemName: "bell.badge.fill")
+                        Text("Overrides Focus")
+                    }
+                }
+            }
+            .font(.footnote)
+            .foregroundColor(.secondary)
+        }
+
+        // MARK: - Helpers
+
+        private func windowLabel(for option: ActiveOption) -> String {
+            switch option {
+            case .always: return String(localized: "Day & Night")
+            case .day: return String(localized: "Day only")
+            case .night: return String(localized: "Night only")
+            }
+        }
+
+        private func severityIcon(for severity: DeviceAlertSeverity) -> String {
+            switch severity {
+            case .critical: return "exclamationmark.triangle.fill"
+            case .timeSensitive: return "bell.badge.fill"
+            case .normal: return "bell.fill"
+            }
+        }
+
+        private func severityTint(for severity: DeviceAlertSeverity) -> Color {
+            switch severity {
+            case .critical: return .red
+            case .timeSensitive: return .orange
+            case .normal: return .accentColor
+            }
+        }
+
+        /// Suggest an `ActiveOption` not yet used in this severity tier so the
+        /// editor opens on a meaningful new variant instead of duplicating
+        /// the existing `.always` row.
+        private func nextAvailableOption(for severity: DeviceAlertSeverity) -> ActiveOption {
+            let used = Set(store.configs(in: severity).map(\.activeOption))
+            for candidate in [ActiveOption.day, .night, .always] where !used.contains(candidate) {
+                return candidate
+            }
+            return .day
+        }
+    }
+}

+ 7 - 0
Trio/Sources/Modules/GlucoseAlerts/GlucoseAlertsDataFlow.swift

@@ -0,0 +1,7 @@
+import Combine
+
+enum GlucoseAlerts {
+    enum Config {}
+}
+
+protocol GlucoseAlertsProvider: Provider {}

+ 5 - 0
Trio/Sources/Modules/GlucoseAlerts/GlucoseAlertsProvider.swift

@@ -0,0 +1,5 @@
+import Foundation
+
+extension GlucoseAlerts {
+    final class Provider: BaseProvider, GlucoseAlertsProvider {}
+}

+ 36 - 0
Trio/Sources/Modules/GlucoseAlerts/GlucoseAlertsStateModel.swift

@@ -0,0 +1,36 @@
+import Combine
+import SwiftUI
+
+extension GlucoseAlerts {
+    final class StateModel: BaseStateModel<Provider> {
+        @Injected() var fetchGlucoseManager: FetchGlucoseManager!
+
+        @Published var units: GlucoseUnits = .mgdL
+        @Published var glucoseBadge = false
+        @Published var cgmAppInfo: CGMManagerAlertOwnership.OwningApp?
+
+        var cgmProvidesOwnAlerts: Bool { cgmAppInfo != nil }
+
+        override func subscribe() {
+            units = settingsManager.settings.units
+            refreshCGMOwnership()
+            subscribeSetting(\.glucoseBadge, on: $glucoseBadge) { glucoseBadge = $0 }
+        }
+
+        /// Re-read whether the active CGM owns its own glucose alerts. The
+        /// active manager can change while the user is in CGM settings, so
+        /// the alarms view calls this on appear.
+        func refreshCGMOwnership() {
+            cgmAppInfo = CGMManagerAlertOwnership.owningApp(
+                manager: fetchGlucoseManager?.cgmManager,
+                sourceType: fetchGlucoseManager?.cgmGlucoseSourceType ?? .none
+            )
+        }
+    }
+}
+
+extension GlucoseAlerts.StateModel: SettingsObserver {
+    func settingsDidChange(_: TrioSettings) {
+        units = settingsManager.settings.units
+    }
+}

+ 87 - 0
Trio/Sources/Modules/GlucoseAlerts/View/AddGlucoseAlertSheet.swift

@@ -0,0 +1,87 @@
+import SwiftUI
+
+struct AddGlucoseAlertSheet: View {
+    @ObservedObject var store: GlucoseAlertsStore
+    let onPick: (GlucoseAlertType) -> Void
+
+    @Environment(\.dismiss) private var dismiss
+    @Environment(\.colorScheme) private var colorScheme
+    @Environment(AppState.self) private var appState
+
+    var body: some View {
+        NavigationStack {
+            List {
+                if !availableTypes.isEmpty {
+                    Section(header: Text("Available")) {
+                        ForEach(availableTypes) { type in
+                            availableRow(for: type)
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+                if !unavailableTypes.isEmpty {
+                    Section(
+                        header: Text("Unavailable"),
+                        footer: Text(
+                            "Already set for Day & Night. Change the alarm time window to add a second alarm for the same type."
+                        )
+                    ) {
+                        ForEach(unavailableTypes) { type in
+                            unavailableRow(for: type)
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+            }
+            .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle("Add Alarm")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button(String(localized: "Cancel")) { dismiss() }
+                }
+            }
+        }
+    }
+
+    private var availableTypes: [GlucoseAlertType] {
+        GlucoseAlertType.allCases.filter { !store.availableActiveOptions(forNewAlarmOfType: $0).isEmpty }
+    }
+
+    private var unavailableTypes: [GlucoseAlertType] {
+        GlucoseAlertType.allCases.filter { store.availableActiveOptions(forNewAlarmOfType: $0).isEmpty }
+    }
+
+    private func availableRow(for type: GlucoseAlertType) -> some View {
+        Button {
+            onPick(type)
+            dismiss()
+        } label: {
+            HStack(spacing: 12) {
+                VStack(alignment: .leading, spacing: 2) {
+                    Text(type.displayName)
+                        .foregroundColor(.primary)
+                    Text(type.blurb)
+                        .font(.footnote)
+                        .foregroundColor(.secondary)
+                }
+                Spacer()
+                Image(systemName: "chevron.right")
+                    .font(.footnote)
+                    .foregroundColor(.secondary)
+            }
+        }
+    }
+
+    private func unavailableRow(for type: GlucoseAlertType) -> some View {
+        HStack(spacing: 12) {
+            VStack(alignment: .leading, spacing: 2) {
+                Text(type.displayName)
+                    .foregroundColor(.primary)
+                Text(type.blurb)
+                    .font(.footnote)
+                    .foregroundColor(.secondary)
+            }
+            Spacer()
+        }
+        .opacity(0.5)
+    }
+}

+ 23 - 0
Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmActiveSection.swift

@@ -0,0 +1,23 @@
+import SwiftUI
+
+struct AlarmActiveSection: View {
+    @Binding var activeOption: ActiveOption
+    /// Subset of `ActiveOption` cases the picker is allowed to offer.
+    /// Defaults to all cases when omitted.
+    var allowed: [ActiveOption] = ActiveOption.allCases
+
+    var body: some View {
+        Section(
+            header: Text("Active During"),
+            footer: Text(
+                "Day and Night windows are configured globally on the Alarms screen."
+            )
+        ) {
+            AlarmEnumMenuPicker(
+                title: String(localized: "Active"),
+                selection: $activeOption,
+                allowed: allowed
+            )
+        }.listRowBackground(Color.chart)
+    }
+}

+ 168 - 0
Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmAudioSection.swift

@@ -0,0 +1,168 @@
+import AVFoundation
+import SwiftUI
+
+struct AlarmAudioSection: View {
+    @Binding var playsSound: Bool
+    @Binding var soundFilename: String
+
+    @State private var showTonePicker = false
+
+    var body: some View {
+        Section(header: Text("Alert Sound")) {
+            Toggle("Play Sound", isOn: $playsSound)
+
+            if playsSound {
+                Button {
+                    showTonePicker = true
+                } label: {
+                    HStack {
+                        Text("Tone")
+                        Spacer()
+                        Text(AlarmSoundCatalog.displayName(for: soundFilename))
+                            .foregroundColor(.secondary)
+                        Image(systemName: "chevron.right")
+                            .foregroundColor(.secondary)
+                            .font(.footnote)
+                    }
+                }
+                .buttonStyle(.plain)
+                .sheet(isPresented: $showTonePicker) {
+                    TonePickerSheet(selected: $soundFilename)
+                }
+            }
+        }
+        .listRowBackground(Color.chart)
+    }
+}
+
+private struct TonePickerSheet: View {
+    @Binding var selected: String
+    @Environment(\.dismiss) private var dismiss
+    @Environment(\.colorScheme) private var colorScheme
+    @Environment(AppState.self) private var appState
+    @StateObject private var previewer = SoundPreviewPlayer()
+
+    var body: some View {
+        NavigationStack {
+            List {
+                Section {
+                    ForEach(AlarmSoundCatalog.allFilenames, id: \.self) { filename in
+                        TonePickerRow(
+                            filename: filename,
+                            selected: $selected,
+                            previewer: previewer
+                        )
+                    }
+                }.listRowBackground(Color.chart)
+            }
+            .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle("Choose Tone")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("Done") {
+                        previewer.stop()
+                        dismiss()
+                    }
+                }
+            }
+            .onDisappear { previewer.stop() }
+        }
+    }
+}
+
+private struct TonePickerRow: View {
+    let filename: String
+    @Binding var selected: String
+    @ObservedObject var previewer: SoundPreviewPlayer
+
+    var body: some View {
+        HStack(spacing: 12) {
+            Button {
+                selected = filename
+            } label: {
+                Text(AlarmSoundCatalog.displayName(for: filename))
+                    .foregroundColor(.primary)
+                    .frame(maxWidth: .infinity, alignment: .leading)
+                    .contentShape(Rectangle())
+            }
+            .buttonStyle(.plain)
+
+            Button {
+                previewer.toggle(filename: filename)
+            } label: {
+                Image(systemName: isPlaying ? "stop.circle.fill" : "play.circle.fill")
+                    .font(.title3)
+                    .foregroundColor(.accentColor)
+            }
+            .buttonStyle(.plain)
+
+            Spacer()
+
+            Image(systemName: "checkmark")
+                .foregroundColor(.accentColor)
+                .opacity(filename == selected ? 1 : 0)
+                .frame(width: 20)
+        }
+    }
+
+    private var isPlaying: Bool { previewer.playing == filename }
+}
+
+/// Plays bundled alarm `.caf` files for in-picker auditioning. Mixable
+/// `.playback` session so it ducks other audio without taking it over.
+/// Not the critical-alert player — this one obeys the silent switch.
+@MainActor private final class SoundPreviewPlayer: ObservableObject {
+    @Published private(set) var playing: String?
+    private var player: AVAudioPlayer?
+
+    func toggle(filename: String) {
+        if playing == filename {
+            stop()
+        } else {
+            play(filename: filename)
+        }
+    }
+
+    func play(filename: String) {
+        stop()
+        let resource = (filename as NSString).deletingPathExtension
+        let ext = (filename as NSString).pathExtension.isEmpty ? "caf" : (filename as NSString).pathExtension
+        guard let url = Bundle.main.url(forResource: resource, withExtension: ext, subdirectory: "Sounds") else {
+            return
+        }
+        do {
+            try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [.mixWithOthers])
+            try AVAudioSession.sharedInstance().setActive(true, options: [])
+            let p = try AVAudioPlayer(contentsOf: url)
+            p.delegate = previewDelegate
+            p.prepareToPlay()
+            guard p.play() else { return }
+            player = p
+            playing = filename
+        } catch {
+            debug(.service, "Audio preview failed for \(filename): \(error)")
+        }
+    }
+
+    func stop() {
+        player?.stop()
+        player = nil
+        playing = nil
+        try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
+    }
+
+    private lazy var previewDelegate: PreviewDelegate = {
+        let delegate = PreviewDelegate()
+        delegate.onFinish = { [weak self] in self?.stop() }
+        return delegate
+    }()
+}
+
+private final class PreviewDelegate: NSObject, AVAudioPlayerDelegate {
+    var onFinish: (() -> Void)?
+
+    func audioPlayerDidFinishPlaying(_: AVAudioPlayer, successfully _: Bool) {
+        Task { @MainActor in self.onFinish?() }
+    }
+}

+ 54 - 0
Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmBGSection.swift

@@ -0,0 +1,54 @@
+import SwiftUI
+
+struct AlarmBGSection: View {
+    let header: String
+    let footer: String?
+    let title: String
+    let range: ClosedRange<Decimal>
+    let step: Decimal
+    let units: GlucoseUnits
+    @Binding var valueMgDL: Decimal
+
+    @State private var showPicker = false
+
+    var body: some View {
+        Section(
+            header: Text(header),
+            footer: footer.map { Text($0) }
+        ) {
+            VStack(spacing: 0) {
+                HStack {
+                    Text(title)
+                    Spacer()
+                    Text(valueMgDL.formatted(for: units))
+                        .foregroundColor(showPicker ? .accentColor : .primary)
+                    Text(units.rawValue)
+                        .foregroundColor(.secondary)
+                }
+                .contentShape(Rectangle())
+                .onTapGesture { showPicker.toggle() }
+
+                if showPicker {
+                    Picker(title, selection: $valueMgDL) {
+                        ForEach(pickerValues, id: \.self) { value in
+                            Text(value.formatted(for: units)).tag(value)
+                        }
+                    }
+                    .pickerStyle(.wheel)
+                    .frame(maxWidth: .infinity)
+                }
+            }
+        }.listRowBackground(Color.chart)
+    }
+
+    private var pickerValues: [Decimal] {
+        let setting = PickerSetting(
+            value: valueMgDL,
+            step: step,
+            min: range.lowerBound,
+            max: range.upperBound,
+            type: .glucose
+        )
+        return PickerSettingsProvider.shared.generatePickerValues(from: setting, units: units)
+    }
+}

+ 39 - 0
Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmEnumMenuPicker.swift

@@ -0,0 +1,39 @@
+import SwiftUI
+
+struct AlarmEnumMenuPicker<E: CaseIterable & Hashable & DayNightDisplayable>: View {
+    let title: String
+    @Binding var selection: E
+    let allowed: [E]
+
+    init(title: String, selection: Binding<E>, allowed: [E]) {
+        self.title = title
+        _selection = selection
+        self.allowed = allowed
+    }
+
+    init(title: String, selection: Binding<E>) where E.AllCases: RandomAccessCollection {
+        self.title = title
+        _selection = selection
+        allowed = Array(E.allCases)
+    }
+
+    var body: some View {
+        HStack {
+            Text(title)
+            Spacer()
+            Picker("", selection: $selection) {
+                ForEach(allowed, id: \.self) { option in
+                    Text(option.displayName).tag(option)
+                }
+            }
+            .pickerStyle(.menu)
+            .onAppear(perform: validate)
+            .onChange(of: allowed) { _, _ in validate() }
+        }
+    }
+
+    private func validate() {
+        guard !allowed.contains(selection), let first = allowed.first else { return }
+        selection = first
+    }
+}

+ 47 - 0
Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmGramsSection.swift

@@ -0,0 +1,47 @@
+import SwiftUI
+
+/// Mirror of `AlarmBGSection` but for gram-valued thresholds (carbsRequired).
+/// Single fixed unit, no mg/dL ↔︎ mmol/L conversion.
+struct AlarmGramsSection: View {
+    let header: String
+    let footer: String?
+    let title: String
+    let range: ClosedRange<Int>
+    let step: Int
+    @Binding var valueGrams: Decimal
+
+    @State private var showPicker = false
+
+    var body: some View {
+        Section(
+            header: Text(header),
+            footer: footer.map { Text($0) }
+        ) {
+            VStack(spacing: 0) {
+                HStack {
+                    Text(title)
+                    Spacer()
+                    Text("\(Int(NSDecimalNumber(decimal: valueGrams).intValue))")
+                        .foregroundColor(showPicker ? .accentColor : .primary)
+                    Text(String(localized: "g", comment: "Abbreviation for grams"))
+                        .foregroundColor(.secondary)
+                }
+                .contentShape(Rectangle())
+                .onTapGesture { showPicker.toggle() }
+
+                if showPicker {
+                    Picker(title, selection: Binding(
+                        get: { Int(NSDecimalNumber(decimal: valueGrams).intValue) },
+                        set: { valueGrams = Decimal($0) }
+                    )) {
+                        ForEach(Array(stride(from: range.lowerBound, through: range.upperBound, by: step)), id: \.self) { v in
+                            Text("\(v)").tag(v)
+                        }
+                    }
+                    .pickerStyle(.wheel)
+                    .frame(maxWidth: .infinity)
+                }
+            }
+        }.listRowBackground(Color.chart)
+    }
+}

+ 44 - 0
Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmWindowIcon.swift

@@ -0,0 +1,44 @@
+import SwiftUI
+
+/// Small SF Symbol that visually conveys an alarm's `ActiveOption` window:
+/// sun for day-only, moon-and-stars for night-only, overlapped pair for both.
+/// The frame is locked to the widest variant (the overlapped pair) so alarm
+/// titles don't shift horizontally when alarms with different windows are
+/// listed together.
+struct AlarmWindowIcon: View {
+    let option: ActiveOption
+
+    var body: some View {
+        ZStack {
+            // Hidden width anchor — always the widest variant. `.hidden()`
+            // keeps it in the layout but invisible, so the frame size scales
+            // with the caller's font (Dynamic Type-friendly) without
+            // hardcoding a width.
+            HStack(spacing: -10) {
+                Image(systemName: "sun.max.fill")
+                Image(systemName: "moon.stars.fill")
+            }
+            .hidden()
+
+            content
+        }
+    }
+
+    @ViewBuilder private var content: some View {
+        switch option {
+        case .day:
+            Image(systemName: "sun.max.fill")
+                .foregroundStyle(.orange)
+        case .night:
+            Image(systemName: "moon.stars.fill")
+                .foregroundStyle(.indigo)
+        case .always:
+            HStack(spacing: -10) {
+                Image(systemName: "sun.max.fill")
+                    .foregroundStyle(.orange)
+                Image(systemName: "moon.stars.fill")
+                    .foregroundStyle(.indigo)
+            }
+        }
+    }
+}

+ 191 - 0
Trio/Sources/Modules/GlucoseAlerts/View/GlucoseAlertEditorView.swift

@@ -0,0 +1,191 @@
+import SwiftUI
+
+struct GlucoseAlertEditorView: View {
+    @ObservedObject var store: GlucoseAlertsStore
+    let alertID: UUID
+    let isNew: Bool
+
+    /// Windows the user can still pick without overlapping another alarm of
+    /// the same type. The being-edited alarm is excluded from "taken" so its
+    /// current option stays valid.
+    private var allowedActiveOptions: [ActiveOption] {
+        let available = store.availableActiveOptions(
+            forType: working.type,
+            excludingAlertID: isNew ? nil : alertID
+        )
+        return ActiveOption.allCases.filter { available.contains($0) }
+    }
+
+    let units: GlucoseUnits
+    var onDone: () -> Void
+    var onCancel: () -> Void
+
+    @Environment(\.dismiss) private var dismiss
+    @Environment(\.colorScheme) private var colorScheme
+    @Environment(AppState.self) private var appState
+    @State private var working: GlucoseAlert
+
+    init(
+        store: GlucoseAlertsStore,
+        initial: GlucoseAlert,
+        isNew: Bool,
+        units: GlucoseUnits,
+        onDone: @escaping () -> Void,
+        onCancel: @escaping () -> Void = {}
+    ) {
+        self.store = store
+        alertID = initial.id
+        self.isNew = isNew
+        self.units = units
+        self.onDone = onDone
+        self.onCancel = onCancel
+        _working = State(initialValue: initial)
+    }
+
+    var body: some View {
+        NavigationStack {
+            Form {
+                generalSection
+
+                switch working.type {
+                case .urgentLow: urgentLowBody
+                case .low: lowBody
+                case .forecastedLow: forecastedLowBody
+                case .high: highBody
+                case .carbsRequired: carbsRequiredBody
+                }
+
+                AlarmActiveSection(
+                    activeOption: $working.activeOption,
+                    allowed: allowedActiveOptions
+                )
+                AlarmAudioSection(
+                    playsSound: $working.playsSound,
+                    soundFilename: $working.soundFilename
+                )
+
+                if !isNew, store.canDelete(working) {
+                    Section {
+                        Button(role: .destructive) {
+                            store.remove(working)
+                            dismiss()
+                        } label: {
+                            Text("Delete Alarm")
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+            }
+            .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle(working.type.displayName)
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .confirmationAction) {
+                    Button(isNew ? String(localized: "Add") : String(localized: "Done")) {
+                        if isNew {
+                            store.add(working)
+                        } else {
+                            store.update(working)
+                        }
+                        onDone()
+                        dismiss()
+                    }
+                }
+                ToolbarItem(placement: .cancellationAction) {
+                    Button(String(localized: "Cancel")) {
+                        onCancel()
+                        dismiss()
+                    }
+                }
+            }
+        }
+    }
+
+    // MARK: - Sections
+
+    private var generalSection: some View {
+        Section(
+            header: Text("General"),
+            footer: Text(working.type.blurb)
+        ) {
+            TextField(String(localized: "Name"), text: $working.name)
+            // Urgent-low is the safety floor — the user can mute the sound
+            // from the Audio section but the alarm itself is always armed.
+            if working.type != .urgentLow {
+                Toggle(String(localized: "Enabled"), isOn: $working.isEnabled)
+            }
+            Toggle(
+                String(localized: "Override Silence & Focus Mode"),
+                isOn: $working.overridesSilenceAndDND
+            )
+        }.listRowBackground(Color.chart)
+    }
+
+    private var urgentLowBody: some View {
+        AlarmBGSection(
+            header: String(localized: "Urgent Low Threshold"),
+            footer: String(
+                localized: "Recommended to always override silence and Focus mode."
+            ),
+            title: String(localized: "Glucose"),
+            range: 54 ... 80,
+            step: 1,
+            units: units,
+            valueMgDL: $working.thresholdMgDL
+        )
+    }
+
+    private var lowBody: some View {
+        AlarmBGSection(
+            header: String(localized: "Low Threshold"),
+            footer: String(
+                localized: "Fires when glucose is at or below this value."
+            ),
+            title: String(localized: "Glucose"),
+            range: 54 ... 100,
+            step: 1,
+            units: units,
+            valueMgDL: $working.thresholdMgDL
+        )
+    }
+
+    private var forecastedLowBody: some View {
+        AlarmBGSection(
+            header: String(localized: "Low Threshold"),
+            footer: String(
+                localized: "Fires when the forecast at +20 minutes (blended across all available prediction curves) is at or below this value."
+            ),
+            title: String(localized: "Glucose"),
+            range: 54 ... 100,
+            step: 1,
+            units: units,
+            valueMgDL: $working.thresholdMgDL
+        )
+    }
+
+    private var highBody: some View {
+        AlarmBGSection(
+            header: String(localized: "High Threshold"),
+            footer: String(
+                localized: "Fires when glucose is at or above this value."
+            ),
+            title: String(localized: "Glucose"),
+            range: 100 ... 400,
+            step: 1,
+            units: units,
+            valueMgDL: $working.thresholdMgDL
+        )
+    }
+
+    private var carbsRequiredBody: some View {
+        AlarmGramsSection(
+            header: String(localized: "Carbs Required Threshold"),
+            footer: String(
+                localized: "Fires when the algorithm suggests to eat at least this many grams of carbs to avoid a low."
+            ),
+            title: String(localized: "Carbs"),
+            range: 5 ... 50,
+            step: 1,
+            valueGrams: $working.thresholdMgDL
+        )
+    }
+}

+ 345 - 0
Trio/Sources/Modules/GlucoseAlerts/View/GlucoseAlertsRootView.swift

@@ -0,0 +1,345 @@
+import SwiftUI
+import Swinject
+
+private enum AlertSheet: Identifiable {
+    case picker
+    case editor(GlucoseAlert, isNew: Bool)
+
+    var id: String {
+        switch self {
+        case .picker: return "picker"
+        case let .editor(alert, _): return alert.id.uuidString
+        }
+    }
+}
+
+extension GlucoseAlerts {
+    struct RootView: BaseView {
+        let resolver: Resolver
+        @StateObject var state = StateModel()
+        @StateObject private var store = GlucoseAlertsStore.shared
+
+        @State private var sheet: AlertSheet?
+        @State private var pendingNewType: GlucoseAlertType?
+
+        @Environment(\.colorScheme) var colorScheme
+        @Environment(AppState.self) var appState
+
+        @State private var shouldDisplayHint: Bool = false
+        @State var hintDetent = PresentationDetent.large
+        @State var selectedVerboseHint: AnyView?
+        @State var hintLabel: String?
+        @State private var decimalPlaceholder: Decimal = 0.0
+        @State private var booleanPlaceholder: Bool = false
+        @State private var displayPickerLowGlucose: Bool = false
+        @State private var displayPickerHighGlucose: Bool = false
+
+        var body: some View {
+            List {
+                if !enabledAlerts.isEmpty {
+                    Section(header: Text("Enabled")) {
+                        ForEach(enabledAlerts) { alarm in
+                            row(for: alarm)
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+                if !disabledAlerts.isEmpty {
+                    Section(header: Text("Disabled")) {
+                        ForEach(disabledAlerts) { alarm in
+                            row(for: alarm).opacity(0.6)
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+                if !cgmHandledAlerts.isEmpty {
+                    Section(
+                        header: Text("Handled by CGM App"),
+                        footer: cgmHandledFooter
+                    ) {
+                        ForEach(cgmHandledAlerts) { alarm in
+                            row(for: alarm).opacity(0.6)
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+
+                // FIXME: make this into a nice setting with mini and verbose hint
+                Section {
+                    Text("Day & Night Windows")
+                        .foregroundStyle(Color.accentColor)
+                        .navigationLink(to: .alarmWindows, from: self)
+                }.listRowBackground(Color.chart)
+
+                Section(footer: Text(useCGMAlertsFooter)) {
+                    Toggle(isOn: Binding(
+                        // When the active CGM has no companion app to defer
+                        // to, force the visible state OFF regardless of the
+                        // stored preference — there's nothing for the toggle
+                        // to control, so showing it ON would mislead.
+                        get: {
+                            guard state.cgmProvidesOwnAlerts else { return false }
+                            return !store.configuration.forceTrioAlertsWhenCGMProvidesOwn
+                        },
+                        set: { store.configuration.forceTrioAlertsWhenCGMProvidesOwn = !$0 }
+                    )) {
+                        Text("Use CGM App Alerts")
+                    }
+                    .disabled(!state.cgmProvidesOwnAlerts)
+                }.listRowBackground(Color.chart)
+
+                SettingInputSection(
+                    decimalValue: $decimalPlaceholder,
+                    booleanValue: $state.glucoseBadge,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0.map { AnyView($0) }
+                            hintLabel = String(localized: "Show Glucose App Badge")
+                        }
+                    ),
+                    units: state.units,
+                    type: .boolean,
+                    label: String(localized: "Show Glucose App Badge"),
+                    miniHint: String(localized: "Show your current glucose on Trio app icon."),
+                    verboseHint: VStack(alignment: .leading, spacing: 10) {
+                        Text("Default: OFF").bold()
+                        Text(
+                            "This will add your current glucose on the top right of your Trio icon as a red notification badge. Changing setting takes effect on next Glucose reading."
+                        )
+                    },
+                    headerText: String(localized: "Glucose App Badge")
+                )
+            }
+            .scrollContentBackground(.hidden)
+            .background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle("Glucose Alarms")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .primaryAction) {
+                    Button { sheet = .picker } label: { Image(systemName: "plus") }
+                }
+            }
+            .sheet(item: $sheet, onDismiss: handleSheetDismiss) { which in
+                switch which {
+                case .picker:
+                    AddGlucoseAlertSheet(store: store) { type in
+                        pendingNewType = type
+                        sheet = nil
+                    }
+                case let .editor(alarm, isNew):
+                    GlucoseAlertEditorView(
+                        store: store,
+                        initial: alarm,
+                        isNew: isNew,
+                        units: state.units,
+                        onDone: { sheet = nil },
+                        onCancel: { sheet = nil }
+                    )
+                }
+            }
+            .sheet(isPresented: $shouldDisplayHint) {
+                SettingInputHintView(
+                    hintDetent: $hintDetent,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    hintLabel: hintLabel ?? "",
+                    hintText: selectedVerboseHint ?? AnyView(EmptyView()),
+                    sheetTitle: String(localized: "Help", comment: "Help sheet title")
+                )
+            }
+            .onAppear {
+                configureView()
+                state.refreshCGMOwnership()
+            }
+        }
+
+        private func handleSheetDismiss() {
+            guard let type = pendingNewType else { return }
+            pendingNewType = nil
+            DispatchQueue.main.async {
+                var seed = GlucoseAlert(type: type)
+                // Default new alarm to the first available window so it
+                // doesn't overlap with whatever is already configured.
+                let available = store.availableActiveOptions(forNewAlarmOfType: type)
+                if let first = ActiveOption.allCases.first(where: available.contains) {
+                    seed.activeOption = first
+                }
+                sheet = .editor(seed, isNew: true)
+            }
+        }
+
+        // MARK: - Sorted lists
+
+        /// Mirror of `GlucoseAlertCoordinator.shouldRespect(alarm:)`'s
+        /// CGM-ownership branch: when "Use CGM App Alerts" is ON and the
+        /// active CGM provides its own glucose alerts, the coordinator
+        /// silences reading-driven types. The view surfaces this by moving
+        /// those alarms into a dedicated section.
+        private var isCGMSuppressionActive: Bool {
+            !store.configuration.forceTrioAlertsWhenCGMProvidesOwn && state.cgmProvidesOwnAlerts
+        }
+
+        private var cgmHandledAlerts: [GlucoseAlert] {
+            guard isCGMSuppressionActive else { return [] }
+            return store.alerts
+                .filter { $0.isEnabled && $0.type.isReadingDriven }
+                .sorted { lhs, rhs in
+                    lhs.type.priority < rhs.type.priority
+                }
+        }
+
+        /// Footer for the "Use CGM App Alerts" toggle. Names the eligible
+        /// CGMs when one of them is active and the user can decide; when
+        /// the active CGM has no companion app the toggle is disabled and
+        /// the footer says Trio is handling alarms.
+        private var useCGMAlertsFooter: String {
+            if state.cgmProvidesOwnAlerts {
+                return String(
+                    localized:
+                    "Your CGM app handles alerts (Dexcom G6 / One, G7 / One+, or xDrip4iOS). Turn off to let Trio alert you."
+                )
+            }
+            return String(
+                localized:
+                "Your CGM has no companion app, so Trio handles alarms."
+            )
+        }
+
+        /// Footer for the "Handled by CGM App" section. Names the specific
+        /// companion app, and renders its name as a deep link when a URL
+        /// scheme is known for that app (see CGMManagerAlertOwnership).
+        @ViewBuilder private var cgmHandledFooter: some View {
+            if let info = state.cgmAppInfo {
+                Text(handledFooterMarkdown(for: info))
+            } else {
+                Text(
+                    "These alarms are silenced because the CGM app handles them. To have Trio notify you instead, turn off \"Use CGM App Alerts\" below."
+                )
+            }
+        }
+
+        private func handledFooterMarkdown(for info: CGMManagerAlertOwnership.OwningApp) -> AttributedString {
+            let body = String(
+                format: String(
+                    localized:
+                    "These alarms are silenced because the %@ app handles CGM alerts. To have Trio notify you instead, turn off \"Use CGM App Alerts\" below."
+                ),
+                "{{NAME}}"
+            )
+            var result = AttributedString(body)
+            if let range = result.range(of: "{{NAME}}") {
+                var name = AttributedString(info.name)
+                if let url = info.deepLink {
+                    name.link = url
+                }
+                result.replaceSubrange(range, with: name)
+            }
+            return result
+        }
+
+        private var enabledAlerts: [GlucoseAlert] {
+            let handled = Set(cgmHandledAlerts.map(\.id))
+            return store.alerts
+                .filter(\.isEnabled)
+                .filter { !handled.contains($0.id) }
+                .sorted { lhs, rhs in
+                    lhs.type.priority < rhs.type.priority
+                }
+        }
+
+        private var disabledAlerts: [GlucoseAlert] {
+            store.alerts
+                .filter { !$0.isEnabled }
+                .sorted { lhs, rhs in
+                    lhs.type.priority < rhs.type.priority
+                }
+        }
+
+        // MARK: - Row
+
+        @ViewBuilder private func row(for alarm: GlucoseAlert) -> some View {
+            Button {
+                sheet = .editor(alarm, isNew: false)
+            } label: {
+                HStack(spacing: 12) {
+                    AlarmWindowIcon(option: alarm.activeOption)
+                        .font(.title3)
+
+                    VStack(alignment: .leading, spacing: 2) {
+                        Text(alarm.name)
+                            .foregroundColor(.primary)
+                        Text(summary(for: alarm))
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                        soundSummary(for: alarm)
+                    }
+
+                    Spacer()
+
+                    Image(systemName: "chevron.right")
+                        .font(.footnote)
+                        .foregroundColor(.secondary)
+                }
+            }
+            .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+                if store.canDelete(alarm) {
+                    Button(role: .destructive) {
+                        store.remove(alarm)
+                    } label: {
+                        Label("Delete", systemImage: "trash")
+                    }
+                }
+            }
+        }
+
+        private func summary(for alarm: GlucoseAlert) -> String {
+            let comparator: String = {
+                switch alarm.type {
+                case .high: return String(localized: "above")
+                case .carbsRequired: return String(localized: "at least")
+                default: return String(localized: "below")
+                }
+            }()
+            let threshold: String = {
+                // `thresholdMgDL` stores grams for carbsRequired — no
+                // mg/dL ↔ mmol/L conversion and a fixed "g" unit label.
+                if alarm.type == .carbsRequired {
+                    return "\(alarm.thresholdMgDL) \(String(localized: "g", comment: "gram of carbs"))"
+                }
+                return "\(alarm.thresholdMgDL.formatted(for: state.units)) \(state.units.rawValue)"
+            }()
+            let window = AlarmEnumDescription.description(for: alarm.activeOption)
+            return "\(comparator.localizedCapitalized) \(threshold) • \(window)"
+        }
+
+        private func soundSummary(for alarm: GlucoseAlert) -> some View {
+            // Show the sound and override facts independently. Previously
+            // the override badge was hidden when sound was off, but "sound
+            // off + override on" is a valid combo (silent + haptic that
+            // breaks through Focus / Sleep) and the user needs to see it.
+            HStack(spacing: 6) {
+                HStack(spacing: 4) {
+                    Image(systemName: alarm.playsSound ? "speaker.wave.2.fill" : "speaker.slash.fill")
+                    Text(alarm.playsSound ? "Sound on" : "Sound off")
+                }
+                if alarm.overridesSilenceAndDND {
+                    Text("·")
+                    HStack(spacing: 4) {
+                        Image(systemName: "bell.badge.fill")
+                        Text("Overrides Focus")
+                    }
+                }
+            }
+            .font(.footnote)
+            .foregroundColor(.secondary)
+        }
+    }
+}
+
+private enum AlarmEnumDescription {
+    static func description(for option: ActiveOption) -> String {
+        switch option {
+        case .always: return String(localized: "Day & Night")
+        case .day: return String(localized: "Day only")
+        case .night: return String(localized: "Night only")
+        }
+    }
+}

+ 0 - 5
Trio/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsDataFlow.swift

@@ -1,5 +0,0 @@
-enum GlucoseNotificationSettings {
-    enum Config {}
-}
-
-protocol GlucoseNotificationSettingsProvider {}

+ 0 - 3
Trio/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsProvider.swift

@@ -1,3 +0,0 @@
-extension GlucoseNotificationSettings {
-    final class Provider: BaseProvider, GlucoseNotificationSettingsProvider {}
-}

+ 0 - 53
Trio/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsStateModel.swift

@@ -1,53 +0,0 @@
-import SwiftUI
-
-extension GlucoseNotificationSettings {
-    final class StateModel: BaseStateModel<Provider> {
-        @Published var glucoseBadge = false
-        @Published var glucoseNotificationsOption: GlucoseNotificationsOption = .onlyAlarmLimits
-        @Published var addSourceInfoToGlucoseNotifications = false
-        @Published var lowGlucose: Decimal = 0
-        @Published var highGlucose: Decimal = 0
-
-        @Published var notificationsPump = true
-        @Published var notificationsCgm = true
-        @Published var notificationsCarb = true
-        @Published var notificationsAlgorithm = true
-
-        var units: GlucoseUnits = .mgdL
-
-        override func subscribe() {
-            let units = settingsManager.settings.units
-            self.units = units
-
-            subscribeSetting(\.notificationsPump, on: $notificationsPump) { notificationsPump = $0 }
-            subscribeSetting(\.notificationsCgm, on: $notificationsCgm) { notificationsCgm = $0 }
-            subscribeSetting(\.notificationsCarb, on: $notificationsCarb) { notificationsCarb = $0 }
-            subscribeSetting(\.notificationsAlgorithm, on: $notificationsAlgorithm) { notificationsAlgorithm = $0 }
-
-            subscribeSetting(\.glucoseBadge, on: $glucoseBadge) { glucoseBadge = $0 }
-            subscribeSetting(\.glucoseNotificationsOption, on: $glucoseNotificationsOption) { glucoseNotificationsOption = $0 }
-            subscribeSetting(\.addSourceInfoToGlucoseNotifications, on: $addSourceInfoToGlucoseNotifications) {
-                addSourceInfoToGlucoseNotifications = $0 }
-
-            subscribeSetting(\.lowGlucose, on: $lowGlucose, initial: {
-                lowGlucose = $0
-            }, map: {
-                let clampedValue = max(min($0, 400), 40)
-                return clampedValue
-            })
-
-            subscribeSetting(\.highGlucose, on: $highGlucose, initial: {
-                highGlucose = $0
-            }, map: {
-                let clampedValue = max(min($0, 400), 40)
-                return clampedValue
-            })
-        }
-    }
-}
-
-extension GlucoseNotificationSettings.StateModel: SettingsObserver {
-    func settingsDidChange(_: TrioSettings) {
-        units = settingsManager.settings.units
-    }
-}

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

@@ -1,389 +0,0 @@
-import ActivityKit
-import Combine
-import SwiftUI
-import Swinject
-
-extension GlucoseNotificationSettings {
-    struct RootView: BaseView {
-        let resolver: Resolver
-        @StateObject var state = StateModel()
-
-        @State private var shouldDisplayHint: Bool = false
-        @State var hintDetent = PresentationDetent.large
-        @State var selectedVerboseHint: AnyView?
-        @State var hintLabel: String?
-        @State private var decimalPlaceholder: Decimal = 0.0
-        @State private var booleanPlaceholder: Bool = false
-        @State private var displayPickerLowGlucose: Bool = false
-        @State private var displayPickerHighGlucose: Bool = false
-
-        private var glucoseFormatter: NumberFormatter {
-            let formatter = NumberFormatter()
-            formatter.numberStyle = .decimal
-            formatter.maximumFractionDigits = 0
-            if state.units == .mmolL {
-                formatter.maximumFractionDigits = 1
-            }
-            formatter.roundingMode = .halfUp
-            return formatter
-        }
-
-        private var carbsFormatter: NumberFormatter {
-            let formatter = NumberFormatter()
-            formatter.numberStyle = .decimal
-            formatter.maximumFractionDigits = 0
-            return formatter
-        }
-
-        @Environment(\.colorScheme) var colorScheme
-        @Environment(AppState.self) var appState
-
-        var body: some View {
-            List {
-                SettingInputSection(
-                    decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.notificationsPump,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    selectedVerboseHint: Binding(
-                        get: { selectedVerboseHint },
-                        set: {
-                            selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Always Notify Pump")
-                        }
-                    ),
-                    units: state.units,
-                    type: .boolean,
-                    label: String(localized: "Always Notify Pump"),
-                    miniHint: String(localized: "Always Notify Pump Warnings."),
-                    verboseHint:
-                    VStack(alignment: .leading, spacing: 10) {
-                        Text("Default: ON").bold()
-                        Text(
-                            "With iOS Trio Notifications enabled, you can let Trio display most Pump Notifications in iOS Notification Center as a Banner, List and on the Lock Screen. It allows you to refer to Trio Information at a glance and troubleshoot any informational issue. Set iOS Notifications Banner Style to Persistent to display banners in the app until dismissed."
-                        )
-                        Text("If iOS Trio Notifications is disabled, Trio will display these messages in-app as a banner only.")
-                        Text("An example of a Pump Warning is 'Pod Expiration Reminder'")
-                    },
-                    headerText: String(localized: "Trio Information Notifications")
-                )
-                SettingInputSection(
-                    decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.notificationsCgm,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    selectedVerboseHint: Binding(
-                        get: { selectedVerboseHint },
-                        set: {
-                            selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Always Notify CGM")
-                        }
-                    ),
-                    units: state.units,
-                    type: .boolean,
-                    label: String(localized: "Always Notify CGM"),
-                    miniHint: String(localized: "Always Notify CGM Warnings."),
-                    verboseHint:
-                    VStack(alignment: .leading, spacing: 10) {
-                        Text("Default: ON").bold()
-                        Text(
-                            "With iOS Trio Notifications enabled, you can let Trio display most CGM Notifications in iOS Notification Center as a Banner, List and on the Lock Screen. It allows you to refer to Trio Information at a glance and troubleshoot any informational issue. Set iOS Notifications Banner Style to Persistent to display banners in the app until dismissed."
-                        )
-                        Text("If iOS Trio Notifications is disabled, Trio will display these messages in-app as a banner only.")
-                        Text("An example of a CGM Warning is 'Unable to open the app'")
-                    }
-                )
-                SettingInputSection(
-                    decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.notificationsCarb,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    selectedVerboseHint: Binding(
-                        get: { selectedVerboseHint },
-                        set: {
-                            selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Always Notify Carb")
-                        }
-                    ),
-                    units: state.units,
-                    type: .boolean,
-                    label: String(localized: "Always Notify Carb"),
-                    miniHint: String(localized: "Always Notify Carb Warnings."),
-                    verboseHint:
-                    VStack(alignment: .leading, spacing: 10) {
-                        Text("Default: ON").bold()
-                        Text(
-                            "With iOS Trio Notifications enabled, you can let Trio display most Carb Notifications in iOS Notification Center as a Banner, List and on the Lock Screen. It allows you to refer to Trio Information at a glance and troubleshoot any informational issue. Set iOS Notifications Banner Style to Persistent to display banners in the app until dismissed."
-                        )
-                        Text("If iOS Trio Notifications is disabled, Trio will display these messages in-app as a banner only.")
-                        Text("An example of a Carb Warning is 'Carbs required: 30 g'")
-                    }
-                )
-                SettingInputSection(
-                    decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.notificationsAlgorithm,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    selectedVerboseHint: Binding(
-                        get: { selectedVerboseHint },
-                        set: {
-                            selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Always Notify Algorithm")
-                        }
-                    ),
-                    units: state.units,
-                    type: .boolean,
-                    label: String(localized: "Always Notify Algorithm"),
-                    miniHint: String(localized: "Always Notify Algorithm Warnings."),
-                    verboseHint:
-                    VStack(alignment: .leading, spacing: 10) {
-                        Text("Default: ON").bold()
-                        Text(
-                            "With iOS Trio Notifications enabled, you can let Trio display most Algorithm Notifications in iOS Notification Center as a Banner, List and on the Lock Screen. It allows you to refer to Trio Information at a glance and troubleshoot any informational issue. Set iOS Notifications Banner Style to Persistent to display banners in the app until dismissed."
-                        )
-                        Text("If iOS Trio Notifications is disabled, Trio will display these messages in-app as a banner only.")
-                        Text(
-                            "An example of an Algorithm Warning is 'Error: Invalid glucose: Not enough glucose data'"
-                        )
-                    }
-                )
-
-                SettingInputSection(
-                    decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.glucoseBadge,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    selectedVerboseHint: Binding(
-                        get: { selectedVerboseHint },
-                        set: {
-                            selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Show Glucose App Badge")
-                        }
-                    ),
-                    units: state.units,
-                    type: .boolean,
-                    label: String(localized: "Show Glucose App Badge"),
-                    miniHint: String(localized: "Show your current glucose on Trio app icon."),
-                    verboseHint: VStack(alignment: .leading, spacing: 10) {
-                        Text("Default: OFF").bold()
-                        Text(
-                            "This will add your current glucose on the top right of your Trio icon as a red notification badge. Changing setting takes effect on next Glucose reading."
-                        )
-                    },
-                    headerText: String(localized: "Various Glucose Notifications")
-                )
-
-                Section {
-                    VStack {
-                        Picker(
-                            selection: $state.glucoseNotificationsOption,
-                            label: Text("Glucose Notifications")
-                        ) {
-                            ForEach(GlucoseNotificationsOption.allCases) { selection in
-                                Text(selection.displayName).tag(selection)
-                            }
-                        }.padding(.top)
-
-                        HStack(alignment: .center) {
-                            Text(
-                                "Choose glucose notifications option. See hint for more details."
-                            )
-                            .font(.footnote)
-                            .foregroundColor(.secondary)
-                            .lineLimit(nil)
-                            Spacer()
-                            Button(
-                                action: {
-                                    hintLabel = String(localized: "Glucose Notifications")
-                                    selectedVerboseHint =
-                                        AnyView(
-                                            VStack(alignment: .leading, spacing: 10) {
-                                                Text(
-                                                    "Set the Glucose Notifications Option. Descriptions for each option found below."
-                                                )
-                                                VStack(alignment: .leading, spacing: 5) {
-                                                    Text("Disabled:").bold()
-                                                    Text("No Glucose Notifications will be triggered.")
-                                                }
-                                                VStack(alignment: .leading, spacing: 5) {
-                                                    Text("Always:").bold()
-                                                    Text(
-                                                        "A notification will be triggered every time your glucose is updated in Trio."
-                                                    )
-                                                }
-                                                VStack(alignment: .leading, spacing: 5) {
-                                                    Text("Only Alarm Limits:").bold()
-                                                    Text(
-                                                        "A notification will be triggered only when glucose levels are below the LOW limit or above the HIGH limit, as specified in Glucose Alarm Limits below."
-                                                    )
-                                                }
-                                            }
-                                        )
-                                    shouldDisplayHint.toggle()
-                                },
-                                label: {
-                                    HStack {
-                                        Image(systemName: "questionmark.circle")
-                                    }
-                                }
-                            ).buttonStyle(BorderlessButtonStyle())
-                        }.padding(.top)
-                    }.padding(.bottom)
-                }.listRowBackground(Color.chart)
-
-                if state.glucoseNotificationsOption != GlucoseNotificationsOption.disabled {
-                    self.lowAndHighGlucoseAlertSection
-                    SettingInputSection(
-                        decimalValue: $decimalPlaceholder,
-                        booleanValue: $state.addSourceInfoToGlucoseNotifications,
-                        shouldDisplayHint: $shouldDisplayHint,
-                        selectedVerboseHint: Binding(
-                            get: { selectedVerboseHint },
-                            set: {
-                                selectedVerboseHint = $0.map { AnyView($0) }
-                                hintLabel = String(localized: "Add Glucose Source to Alarm")
-                            }
-                        ),
-                        units: state.units,
-                        type: .boolean,
-                        label: String(localized: "Add Glucose Source to Alarm"),
-                        miniHint: String(localized: "Source of the glucose reading will be added to the notification."),
-                        verboseHint: VStack(alignment: .leading, spacing: 10) {
-                            Text("Default: OFF").bold()
-                            Text("The source of the glucose reading will be added to the notification.")
-                        }
-                    )
-                }
-            }
-            .listSectionSpacing(sectionSpacing)
-            .sheet(isPresented: $shouldDisplayHint) {
-                SettingInputHintView(
-                    hintDetent: $hintDetent,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    hintLabel: hintLabel ?? "",
-                    hintText: selectedVerboseHint ?? AnyView(EmptyView()),
-                    sheetTitle: String(localized: "Help", comment: "Help sheet title")
-                )
-            }
-            .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
-            .onAppear(perform: configureView)
-            .navigationBarTitle("Trio Notifications")
-            .navigationBarTitleDisplayMode(.automatic)
-            .settingsHighlightScroll()
-        }
-
-        var lowAndHighGlucoseAlertSection: some View {
-            Section {
-                VStack {
-                    VStack {
-                        HStack {
-                            Text("Low Glucose Alarm Limit")
-
-                            Spacer()
-
-                            Group {
-                                Text(
-                                    state.units == .mgdL ? state.lowGlucose.description : state.lowGlucose.formattedAsMmolL
-                                )
-                                .foregroundColor(!displayPickerLowGlucose ? .primary : .accentColor)
-
-                                Text(state.units == .mgdL ? " mg/dL" : " mmol/L").foregroundColor(.secondary)
-                            }
-                        }
-                        .onTapGesture {
-                            displayPickerLowGlucose.toggle()
-                        }
-                    }
-                    .padding(.top)
-
-                    if displayPickerLowGlucose {
-                        let setting = PickerSettingsProvider.shared.settings.lowGlucose
-
-                        Picker(selection: $state.lowGlucose, label: Text("")) {
-                            ForEach(
-                                PickerSettingsProvider.shared.generatePickerValues(from: setting, units: state.units),
-                                id: \.self
-                            ) { value in
-                                let displayValue = state.units == .mgdL ? value.description : value.formattedAsMmolL
-                                Text(displayValue).tag(value)
-                            }
-                        }
-                        .pickerStyle(WheelPickerStyle())
-                        .frame(maxWidth: .infinity)
-                    }
-
-                    VStack {
-                        HStack {
-                            Text("High Glucose Alarm Limit")
-
-                            Spacer()
-
-                            Group {
-                                Text(
-                                    state.units == .mgdL ? state.highGlucose.description : state.highGlucose.formattedAsMmolL
-                                )
-                                .foregroundColor(!displayPickerHighGlucose ? .primary : .accentColor)
-
-                                Text(state.units == .mgdL ? " mg/dL" : " mmol/L").foregroundColor(.secondary)
-                            }
-                        }
-                        .onTapGesture {
-                            displayPickerHighGlucose.toggle()
-                        }
-                    }
-                    .padding(.top)
-
-                    if displayPickerHighGlucose {
-                        let setting = PickerSettingsProvider.shared.settings.highGlucose
-                        Picker(selection: $state.highGlucose, label: Text("")) {
-                            ForEach(
-                                PickerSettingsProvider.shared.generatePickerValues(from: setting, units: state.units),
-                                id: \.self
-                            ) { value in
-                                let displayValue = state.units == .mgdL ? value.description : value.formattedAsMmolL
-                                Text(displayValue).tag(value)
-                            }
-                        }
-                        .pickerStyle(WheelPickerStyle())
-                        .frame(maxWidth: .infinity)
-                    }
-
-                    HStack(alignment: .center) {
-                        Text(
-                            "Sets the lower and upper limit for glucose alarms."
-                        )
-                        .lineLimit(nil)
-                        .font(.footnote)
-                        .foregroundColor(.secondary)
-                        Spacer()
-                        Button(
-                            action: {
-                                hintLabel = String(localized: "Low and High Glucose Alarm Limits")
-                                selectedVerboseHint =
-                                    AnyView(VStack(alignment: .leading, spacing: 10) {
-                                        let low: Decimal = 70
-                                        let high: Decimal = 180
-                                        let labelLow = (state.units == .mgdL ? low.description : low.formattedAsMmolL) + " " +
-                                            state.units.rawValue
-                                        let labelHigh = (state.units == .mgdL ? high.description : high.formattedAsMmolL) + " " +
-                                            state.units.rawValue
-                                        Text("Low Default: " + labelLow).bold()
-                                        Text("High Default: " + labelHigh).bold()
-                                        VStack(alignment: .leading, spacing: 10) {
-                                            Text(
-                                                "These two settings determine the range outside of which you will be notified via push notifications."
-                                            )
-                                            Text(
-                                                "If your CGM readings are below the Low value or above the High value, you will receive a glucose alarm."
-                                            )
-                                        }
-                                    })
-                                shouldDisplayHint.toggle()
-                            },
-                            label: {
-                                HStack {
-                                    Image(systemName: "questionmark.circle")
-                                }
-                            }
-                        ).buttonStyle(BorderlessButtonStyle())
-                    }.padding(.top)
-                }.padding(.bottom)
-            }.listRowBackground(Color.chart)
-        }
-    }
-}

+ 3 - 0
Trio/Sources/Modules/Home/HomeProvider.swift

@@ -13,6 +13,9 @@ extension Home {
         }
 
         func heartbeatNow() {
+            // User-initiated force-loop: bypass dwell suppression so transient
+            // errors surface immediately rather than waiting for count/dwell.
+            apsManager.markNextLoopUserInitiated()
             apsManager.heartbeat(date: Date())
         }
 

+ 226 - 3
Trio/Sources/Modules/Home/HomeStateModel.swift

@@ -1,7 +1,10 @@
+import CGMBLEKit
 import CGMBLEKitUI
 import Combine
 import CoreData
 import Foundation
+import G7SensorKit
+import LibreTransmitter
 import LoopKit
 import LoopKitUI
 import Observation
@@ -22,12 +25,13 @@ extension Home {
         @ObservationIgnored @Injected() var overrideStorage: OverrideStorage!
         @ObservationIgnored @Injected() var bluetoothManager: BluetoothStateManager!
         @ObservationIgnored @Injected() var iobService: IOBService!
+        @ObservationIgnored @Injected() var unlockmanager: UnlockManager!
 
         var cgmStateModel: CGMSettings.StateModel {
             CGMSettings.StateModel.shared
         }
 
-        private let timer = DispatchTimer(timeInterval: 5)
+        private let timer = DispatchTimer(timeInterval: 30)
         private(set) var filteredHours = 24
         var startMarker = Date(timeIntervalSinceNow: TimeInterval(hours: -24))
         var endMarker = Date(timeIntervalSinceNow: TimeInterval(hours: 3))
@@ -107,12 +111,18 @@ extension Home {
         var pumpStatusBadgeImage: UIImage?
         var pumpStatusBadgeColor: Color?
         var cgmAvailable: Bool = false
+        var cgmDisplayState: CgmDisplayState?
+        var cgmProgressHighlight: DeviceLifecycleProgress?
+        var cgmSensorExpiresAt: Date?
+        var cgmWarmupEndsAt: Date?
         var listOfCGM: [CGMModel] = []
         var cgmCurrent = cgmDefaultModel
         var pumpInitialSettings = PumpConfig.PumpInitialSettings.default
         var shouldRunDeleteOnSettingsChange = true
 
         var showCarbsRequiredBadge: Bool = true
+        var enableQuickBolus: Bool = false
+        var quickBolusHistory: [Decimal] = []
         private(set) var setupPumpType: PumpConfig.PumpType = .minimed
         var minForecast: [Int] = []
         var maxForecast: [Int] = []
@@ -321,11 +331,68 @@ extension Home {
 
             timer.eventHandler = {
                 DispatchQueue.main.async { [weak self] in
-                    self?.timerDate = Date()
+                    guard let self else { return }
+                    self.timerDate = Date()
+                    // The publisher only re-emits on state changes; re-pull
+                    // so the arc + countdowns + status text advance during
+                    // warmup / stabilizing / expiry. Simulator has no
+                    // CGMManager, so fall back to reading its synthetic
+                    // lifecycle / highlight so the bobble sees the same
+                    // data shape a real CGM would deliver.
+                    let manager = self.fetchGlucoseManager.cgmManager
+                    let source = self.fetchGlucoseManager.glucoseSource
+                    let progress: DeviceLifecycleProgress?
+                    let highlight: DeviceStatusHighlight?
+                    if let manager {
+                        progress = manager.cgmLifecycleProgress
+                        highlight = manager.cgmStatusHighlight
+                    } else if let sim = source as? GlucoseSimulatorSource {
+                        progress = sim.cgmLifecycleProgress
+                        highlight = sim.cgmStatusHighlight
+                    } else {
+                        progress = nil
+                        highlight = nil
+                    }
+                    self.cgmProgressHighlight = progress
+                    if let highlight {
+                        self.cgmDisplayState = CgmDisplayState(
+                            localizedMessage: highlight.localizedMessage,
+                            imageName: highlight.imageName,
+                            status: CgmDisplayStatus.from(highlight.state)
+                        )
+                    } else {
+                        self.cgmDisplayState = nil
+                    }
+                    self.cgmSensorExpiresAt = Self.resolveSensorExpiresAt(
+                        manager: manager,
+                        glucoseSource: source,
+                        lifecycle: progress
+                    )
+                    self.cgmWarmupEndsAt = Self.resolveWarmupEndsAt(manager: manager)
                 }
             }
             timer.resume()
 
+            fetchGlucoseManager.cgmDisplayState
+                .receive(on: DispatchQueue.main)
+                .sink { [weak self] state in self?.cgmDisplayState = state }
+                .store(in: &lifetime)
+            fetchGlucoseManager.cgmProgressHighlight
+                .receive(on: DispatchQueue.main)
+                .sink { [weak self] progress in
+                    guard let self else { return }
+                    self.cgmProgressHighlight = progress
+                    self.cgmSensorExpiresAt = Self.resolveSensorExpiresAt(
+                        manager: self.fetchGlucoseManager.cgmManager,
+                        glucoseSource: self.fetchGlucoseManager.glucoseSource,
+                        lifecycle: progress
+                    )
+                    self.cgmWarmupEndsAt = Self.resolveWarmupEndsAt(
+                        manager: self.fetchGlucoseManager.cgmManager
+                    )
+                }
+                .store(in: &lifetime)
+
             apsManager.isLooping
                 .receive(on: DispatchQueue.main)
                 .weakAssign(to: \.isLooping, on: self)
@@ -356,7 +423,7 @@ extension Home {
                 .map { [weak self] error in
                     self?.errorDate = error == nil ? nil : Date()
                     if let error = error {
-                        info(.default, String(describing: error), notificationText: error.localizedDescription)
+                        debug(.default, "APSManager lastError: \(String(describing: error))")
                     }
                     return error?.localizedDescription
                 }
@@ -414,6 +481,7 @@ extension Home {
             bolusDisplayThreshold = settingsManager.settings.bolusDisplayThreshold
             thresholdLines = settingsManager.settings.rulerMarks
             showCarbsRequiredBadge = settingsManager.settings.showCarbsRequiredBadge
+            enableQuickBolus = settingsManager.settings.enableQuickBolus
             forecastDisplayType = settingsManager.settings.forecastDisplayType
             isExerciseModeActive = settingsManager.preferences.exerciseMode
             highTTraisesSens = settingsManager.preferences.highTemptargetRaisesSensitivity
@@ -470,6 +538,98 @@ extension Home {
             }
         }
 
+        func loadQuickBolusSuggestions() async {
+            guard enableQuickBolus else { return }
+            let cutoff = Calendar.current.date(byAdding: .day, value: -90, to: Date()) ?? Date()
+            let predicate = NSPredicate(
+                format: "isSMB == false AND isExternal == false AND pumpEvent.timestamp >= %@",
+                cutoff as NSDate
+            )
+            do {
+                let results: Any = try await CoreDataStack.shared.fetchEntitiesAsync(
+                    ofType: BolusStored.self,
+                    onContext: pumpHistoryFetchContext,
+                    predicate: predicate,
+                    key: "pumpEvent.timestamp",
+                    ascending: false,
+                    batchSize: 100
+                )
+
+                let suggestions: [Decimal] = await pumpHistoryFetchContext.perform {
+                    guard let boluses = results as? [BolusStored] else { return [] }
+
+                    let now = Date()
+                    let cal = Calendar.current
+                    let nowMinute = cal.component(.hour, from: now) * 60 + cal.component(.minute, from: now)
+                    let nowDOW = cal.component(.weekday, from: now)
+                    let sigma: Double = 60.0
+                    let halfLife: Double = 10.0
+
+                    var groups: [Decimal: Double] = [:]
+                    for bolus in boluses {
+                        guard let nsAmount = bolus.amount, nsAmount.doubleValue > 0,
+                              let timestamp = bolus.pumpEvent?.timestamp else { continue }
+
+                        var roundedKey = Decimal()
+                        var tempAmount = nsAmount as Decimal
+                        NSDecimalRound(&roundedKey, &tempAmount, 2, .plain)
+
+                        let entryMinute = cal.component(.hour, from: timestamp) * 60 + cal.component(.minute, from: timestamp)
+                        let entryDOW = cal.component(.weekday, from: timestamp)
+
+                        let diff = abs(entryMinute - nowMinute)
+                        let circularDiff = Double(min(diff, 1440 - diff))
+                        let t = exp(-(circularDiff * circularDiff) / (2.0 * sigma * sigma))
+
+                        let d: Double
+                        if entryDOW == nowDOW {
+                            d = 1.0
+                        } else {
+                            let nowWeekend = nowDOW == 1 || nowDOW == 7
+                            let entryWeekend = entryDOW == 1 || entryDOW == 7
+                            d = nowWeekend == entryWeekend ? 0.7 : 0.15
+                        }
+
+                        let daysAgo = now.timeIntervalSince(timestamp) / 86400.0
+                        let r = pow(0.5, daysAgo / halfLife)
+
+                        groups[roundedKey, default: 0] += t * d * r
+                    }
+
+                    return groups
+                        .filter { $0.value >= 0.1 }
+                        .sorted { $0.value > $1.value }
+                        .prefix(5)
+                        .map(\.key)
+                }
+
+                await MainActor.run {
+                    quickBolusHistory = suggestions
+                }
+            } catch {
+                debug(.default, "\(DebuggingIdentifiers.failed) failed to fetch quick bolus history: \(error)")
+            }
+        }
+
+        func enactQuickBolus(amount: Decimal) async -> Bool {
+            guard amount > 0 else { return false }
+            let delivery = min(
+                Double(truncating: amount as NSDecimalNumber),
+                pumpInitialSettings.maxBolusUnits
+            )
+            do {
+                let authenticated = try await unlockmanager.unlock()
+                if authenticated {
+                    await apsManager.enactBolus(amount: delivery, isSMB: false, callback: nil)
+                    return true
+                }
+                return false
+            } catch {
+                debug(.bolusState, "Quick bolus authentication error: \(error)")
+                return false
+            }
+        }
+
         func addPump(_ type: PumpConfig.PumpType) {
             setupPumpType = type
             shouldDisplayPumpSetupSheet = true
@@ -642,6 +802,68 @@ extension Home {
                 }
             }
         }
+
+        /// Sensor expiration for the home label. Prefers manager-reported
+        /// dates; reverse-derives from `lifecycle.percentComplete` when not.
+        /// `activatedAt` must be session start, not transmitter activation.
+        private static func resolveSensorExpiresAt(
+            manager: CGMManagerUI?,
+            glucoseSource: GlucoseSource?,
+            lifecycle: DeviceLifecycleProgress?
+        ) -> Date? {
+            if let sim = glucoseSource as? GlucoseSimulatorSource {
+                return sim.simulatedSensorExpiresAt
+            }
+            guard let manager else { return nil }
+            // Once a G7 enters grace period, `sensorExpiresAt` is in the past
+            // and would collapse the bobble countdown to "<1m" while the arc
+            // (driven by lifecycle.percentComplete against `sensorEndsAt`) is
+            // still mid-progress. Fall back to `sensorEndsAt` so bobble and
+            // arc agree, and the user sees grace-period time remaining.
+            if let g7 = manager as? G7CGMManager {
+                let now = Date()
+                if let exp = g7.sensorExpiresAt, exp > now { return exp }
+                return g7.sensorEndsAt ?? g7.sensorExpiresAt
+            }
+            if let g6 = manager as? G6CGMManager, let exp = g6.latestReading?.sessionExpDate { return exp }
+            if let g5 = manager as? G5CGMManager, let exp = g5.latestReading?.sessionExpDate { return exp }
+
+            let activatedAt: Date?
+            if let g7 = manager as? G7CGMManager {
+                activatedAt = g7.sensorActivatedAt
+            } else if let libre = manager as? LibreTransmitterManagerV3 {
+                activatedAt = libre.sensorInfoObservable.activatedAt
+            } else {
+                activatedAt = nil
+            }
+
+            guard let activatedAt,
+                  let lifecycle,
+                  lifecycle.percentComplete > 0.001
+            else { return nil }
+            let elapsed = Date().timeIntervalSince(activatedAt)
+            guard elapsed > 0 else { return nil }
+            return activatedAt.addingTimeInterval(elapsed / lifecycle.percentComplete)
+        }
+
+        /// Wall-clock end of the sensor's warmup window; `nil` when not warming up.
+        private static func resolveWarmupEndsAt(manager: CGMManagerUI?) -> Date? {
+            guard let manager else { return nil }
+            if let g7 = manager as? G7CGMManager {
+                guard let ends = g7.sensorFinishesWarmupAt, ends > Date() else { return nil }
+                return ends
+            }
+            if let g6 = manager as? G6CGMManager, let start = g6.latestReading?.sessionStartDate {
+                let window: TimeInterval = g6.isAnubis ? 50 * 60 : 2 * 60 * 60
+                let ends = start.addingTimeInterval(window)
+                return ends > Date() ? ends : nil
+            }
+            if let g5 = manager as? G5CGMManager, let start = g5.latestReading?.sessionStartDate {
+                let ends = start.addingTimeInterval(2 * 60 * 60)
+                return ends > Date() ? ends : nil
+            }
+            return nil
+        }
     }
 }
 
@@ -678,6 +900,7 @@ extension Home.StateModel:
         thresholdLines = settingsManager.settings.rulerMarks
         bolusDisplayThreshold = settingsManager.settings.bolusDisplayThreshold
         showCarbsRequiredBadge = settingsManager.settings.showCarbsRequiredBadge
+        enableQuickBolus = settingsManager.settings.enableQuickBolus
         forecastDisplayType = settingsManager.settings.forecastDisplayType
         cgmAvailable = (fetchGlucoseManager.cgmGlucoseSourceType != CGMType.none)
         displayPumpStatusHighlightMessage()

+ 31 - 26
Trio/Sources/Modules/Home/View/Chart/ChartElements/GlucoseChartView.swift

@@ -15,42 +15,47 @@ struct GlucoseChartView: ChartContent {
         drawGlucoseChart()
     }
 
+    /// Dynamic point color for a CGM reading. Manual readings render red and skip this.
+    private func pointColor(for item: GlucoseStored) -> Color {
+        // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose
+        let hardCodedLow = Decimal(55)
+        let hardCodedHigh = Decimal(220)
+        let isDynamicColorScheme = glucoseColorScheme == .dynamicColor
+
+        return Trio.getDynamicGlucoseColor(
+            glucoseValue: Decimal(item.glucose),
+            highGlucoseColorValue: isDynamicColorScheme ? hardCodedHigh : highGlucose,
+            lowGlucoseColorValue: isDynamicColorScheme ? hardCodedLow : lowGlucose,
+            targetGlucose: currentGlucoseTarget,
+            glucoseColorScheme: glucoseColorScheme
+        )
+    }
+
     private func drawGlucoseChart() -> some ChartContent {
         ForEach(glucoseData) { item in
             let glucoseToDisplay = units == .mgdL ? Decimal(item.glucose) : Decimal(item.glucose).asMmolL
 
-            // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose
-            let hardCodedLow = Decimal(55)
-            let hardCodedHigh = Decimal(220)
-            let isDynamicColorScheme = glucoseColorScheme == .dynamicColor
-
-            let pointMarkColor: Color = Trio.getDynamicGlucoseColor(
-                glucoseValue: Decimal(item.glucose),
-                highGlucoseColorValue: isDynamicColorScheme ? hardCodedHigh : highGlucose,
-                lowGlucoseColorValue: isDynamicColorScheme ? hardCodedLow : lowGlucose,
-                targetGlucose: currentGlucoseTarget,
-                glucoseColorScheme: glucoseColorScheme
-            )
-
-            PointMark(
-                x: .value("Time", item.date ?? Date(), unit: .second),
-                y: .value("Value", glucoseToDisplay)
-            )
-            .foregroundStyle(pointMarkColor)
-            .symbolSize(20)
-            .symbol {
-                if item.isManual {
+            if item.isManual {
+                PointMark(
+                    x: .value("Time", item.date ?? Date(), unit: .second),
+                    y: .value("Value", glucoseToDisplay)
+                )
+                .symbolSize(20)
+                .symbol {
                     Image(systemName: "drop.fill")
                         .font(.caption2)
                         .symbolRenderingMode(.monochrome)
                         .bold()
                         .foregroundStyle(.red)
-                } else {
-                    Image(systemName: "circle.fill")
-                        .font(.system(size: 5))
-                        .bold()
-                        .foregroundStyle(pointMarkColor)
                 }
+            } else {
+                PointMark(
+                    x: .value("Time", item.date ?? Date(), unit: .second),
+                    y: .value("Value", glucoseToDisplay)
+                )
+                .foregroundStyle(pointColor(for: item))
+                .symbolSize(20)
+                .symbol(.circle)
             }
 
             if isSmoothingEnabled, let smoothedGlucose = item.smoothedGlucose, smoothedGlucose != 0 {

+ 233 - 72
Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift

@@ -1,4 +1,5 @@
 import CoreData
+import LoopKit
 import SwiftUI
 
 struct CurrentGlucoseView: View {
@@ -11,6 +12,16 @@ struct CurrentGlucoseView: View {
     var currentGlucoseTarget: Decimal
     let glucoseColorScheme: GlucoseColorScheme
     let glucose: [GlucoseStored] // This contains the last two glucose values, no matter if its manual or a cgm reading
+
+    /// Drives the outer ring.
+    var cgmProgress: DeviceLifecycleProgress?
+    /// CGM status highlight, rendered verbatim.
+    var cgmStatus: CgmDisplayState?
+    /// Sensor expiration — fallback tag when `cgmStatus` is nil.
+    var cgmSensorExpiresAt: Date?
+    /// Wall-clock end of the warmup window. Drives the warmup countdown tag.
+    var cgmWarmupEndsAt: Date?
+
     @State private var rotationDegrees: Double = 0.0
     @State private var angularGradient = AngularGradient(colors: [
         Color(red: 0.7215686275, green: 0.3411764706, blue: 1),
@@ -42,81 +53,28 @@ struct CurrentGlucoseView: View {
         let triangleColor = Color(red: 0.262745098, green: 0.7333333333, blue: 0.9137254902)
 
         if cgmAvailable {
-            ZStack {
-                TrendShape(gradient: angularGradient, color: triangleColor)
-                    .rotationEffect(.degrees(rotationDegrees))
-
-                VStack(alignment: .center) {
+            if let stale = stalenessState {
+                // Compact error/transition state — same styles as the
+                // empty-state "Add CGM" layout below, just colored by tier.
+                VStack(alignment: .center, spacing: 12) {
                     HStack {
-                        if let glucoseValue = glucose.last?.glucose {
-                            let displayGlucose = units == .mgdL ? Decimal(glucoseValue).description : Decimal(glucoseValue)
-                                .formattedAsMmolL
-
-                            var glucoseDisplayColor = Color.primary
-
-                            // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose
-                            let hardCodedLow = Decimal(55)
-                            let hardCodedHigh = Decimal(220)
-                            let isDynamicColorScheme = glucoseColorScheme == .dynamicColor
-
-                            if Decimal(glucoseValue) <= lowGlucose || Decimal(glucoseValue) >= highGlucose {
-                                glucoseDisplayColor = Trio.getDynamicGlucoseColor(
-                                    glucoseValue: Decimal(glucoseValue),
-                                    highGlucoseColorValue: isDynamicColorScheme ? hardCodedHigh : highGlucose,
-                                    lowGlucoseColorValue: isDynamicColorScheme ? hardCodedLow : lowGlucose,
-                                    targetGlucose: currentGlucoseTarget,
-                                    glucoseColorScheme: glucoseColorScheme
-                                )
-                            }
-
-                            return Text(
-                                glucoseValue == 400 ? "HIGH" : displayGlucose
-                            )
-                            .font(.system(size: 40, weight: .bold, design: .rounded))
-                            .foregroundStyle(glucoseDisplayColor)
-                        } else {
-                            return Text("--")
-                                .font(.system(size: 40, weight: .bold, design: .rounded))
-                                .foregroundStyle(.secondary)
-                        }
+                        Image(systemName: stale.imageName)
+                            .font(.body)
+                            .imageScale(.large)
+                            .foregroundStyle(stale.color)
                     }
                     HStack {
-                        let minutesAgoString = TimeAgoFormatter.minutesAgo(from: glucose.last?.date)
-                        Group {
-                            Text(minutesAgoString)
-                            Text(delta)
-                        }
-                        .font(.callout).fontWeight(.bold)
-                        .foregroundStyle(colorScheme == .dark ? Color.white.opacity(0.9) : Color.secondary)
-                    }
-                    .frame(alignment: .top)
-                }
-            }
-            .onChange(of: glucose.last?.directionEnum) {
-                withAnimation {
-                    switch glucose.last?.directionEnum {
-                    case .doubleUp,
-                         .singleUp,
-                         .tripleUp:
-                        rotationDegrees = -90
-                    case .fortyFiveUp:
-                        rotationDegrees = -45
-                    case .flat:
-                        rotationDegrees = 0
-                    case .fortyFiveDown:
-                        rotationDegrees = 45
-                    case .doubleDown,
-                         .singleDown,
-                         .tripleDown:
-                        rotationDegrees = 90
-                    case nil,
-                         .notComputable,
-                         .rateOutOfRange:
-                        rotationDegrees = 0
-                    default:
-                        rotationDegrees = 0
+                        Text(stale.label)
+                            .font(.caption).bold()
+                            .foregroundStyle(stale.color)
                     }
-                }
+                }.frame(alignment: .top)
+            } else {
+                // Bobble renders at 0.9 to leave breathing room for the right
+                // panel + pump view siblings; the compact and empty-state
+                // branches above and below already fit at 1.0.
+                bobbleAndTag(triangleColor: triangleColor)
+                    .scaleEffect(0.9)
             }
         } else {
             VStack(alignment: .center, spacing: 12) {
@@ -132,6 +90,59 @@ struct CurrentGlucoseView: View {
         }
     }
 
+    @ViewBuilder private func bobbleAndTag(triangleColor: Color) -> some View {
+        ZStack {
+            if let progress = cgmProgress, shouldShowArc {
+                SensorLifecycleArcView(
+                    progress: progress.percentComplete,
+                    progressState: progress.progressState
+                )
+            }
+
+            TrendShape(gradient: angularGradient, color: triangleColor, showArrow: true)
+                .rotationEffect(.degrees(rotationDegrees))
+
+            VStack(alignment: .center) {
+                bobbleContent()
+            }
+        }
+        .overlay(alignment: .bottom) {
+            // Overlay (not VStack) so the tag doesn't push siblings down;
+            // hidden when the trend arrow rotates toward 6 o'clock.
+            if let tag = tagLabel, !trendIsDownward {
+                SensorStatusTagView(text: tag.text, theme: tag.theme, iconSystemName: tag.icon)
+                    .offset(y: 14)
+                    .zIndex(1)
+            }
+        }
+        .onChange(of: glucose.last?.directionEnum) {
+            withAnimation {
+                switch glucose.last?.directionEnum {
+                case .doubleUp,
+                     .singleUp,
+                     .tripleUp:
+                    rotationDegrees = -90
+                case .fortyFiveUp:
+                    rotationDegrees = -45
+                case .flat:
+                    rotationDegrees = 0
+                case .fortyFiveDown:
+                    rotationDegrees = 45
+                case .doubleDown,
+                     .singleDown,
+                     .tripleDown:
+                    rotationDegrees = 90
+                case nil,
+                     .notComputable,
+                     .rateOutOfRange:
+                    rotationDegrees = 0
+                default:
+                    rotationDegrees = 0
+                }
+            }
+        }
+    }
+
     private var delta: String {
         guard glucose.count >= 2 else {
             return "--"
@@ -146,6 +157,153 @@ struct CurrentGlucoseView: View {
         let delta = lastGlucose - secondLastGlucose
         return deltaFormatter.string(from: delta as NSNumber) ?? "--"
     }
+
+    @ViewBuilder private func bobbleContent() -> some View {
+        HStack {
+            if let glucoseValue = glucose.last?.glucose, isReadingFresh {
+                let displayGlucose = units == .mgdL
+                    ? Decimal(glucoseValue).description
+                    : Decimal(glucoseValue).formattedAsMmolL
+                Text(glucoseValue == 400 ? "HIGH" : displayGlucose)
+                    .font(.system(size: 40, weight: .bold, design: .rounded))
+                    .foregroundStyle(glucoseColor(for: glucoseValue))
+            } else {
+                Text("– –")
+                    .font(.system(size: 40, weight: .bold, design: .rounded))
+                    .foregroundStyle(.secondary)
+            }
+        }
+        if isReadingFresh {
+            HStack {
+                let minutesAgoString = TimeAgoFormatter.minutesAgo(from: glucose.last?.date)
+                Group {
+                    Text(minutesAgoString)
+                    Text(delta)
+                }
+                .font(.callout).fontWeight(.bold)
+                .foregroundStyle(colorScheme == .dark ? Color.white.opacity(0.9) : Color.secondary)
+            }
+            .frame(alignment: .top)
+        }
+    }
+
+    /// Matches `APSManager`'s loop-input freshness gate — readings older than
+    /// 12 minutes (one missed CGM transmission on a 5-min schedule) get
+    /// masked to dashes. Handles warmup + sensor failure naturally: no
+    /// fresh data → no number on the bobble.
+    private var isReadingFresh: Bool {
+        guard let date = glucose.last?.date else { return false }
+        return Date().timeIntervalSince(date) < 12 * 60
+    }
+
+    private var trendIsDownward: Bool { rotationDegrees >= 90 }
+
+    /// Error/transition states (sensor expired, sensor failure, signal loss,
+    /// stabilizing, etc.) collapse the bobble to a compact symbol-over-label
+    /// view — the bobble's purpose is to show glucose, so a fresh-reading-less
+    /// bobble with arc + dashes obscures rather than informs. Warmup is the
+    /// exception: it's a short, expected lifecycle phase and the arc + tag
+    /// countdown carry useful info, so the bobble stays.
+    private var stalenessState: (imageName: String, label: String, color: Color)? {
+        guard !isReadingFresh,
+              !isInWarmup,
+              let status = cgmStatus,
+              !status.imageName.isEmpty
+        else { return nil }
+        let color: Color
+        switch status.status {
+        case .critical: color = .loopRed
+        case .warning: color = .orange
+        case .normal: color = .secondary
+        }
+        // LibreLoop/G7 split labels with "\n" for their two-line native pills;
+        // collapse to spaces so the compact label reads cleanly.
+        let oneLine = status.localizedMessage.replacingOccurrences(of: "\n", with: " ")
+        return (status.imageName, oneLine, color)
+    }
+
+    /// Arc shown for warmup, the last 48 h of a time-based sensor (incl.
+    /// grace period), or any non-normal state from a battery-based manager.
+    private var shouldShowArc: Bool {
+        if isInWarmup { return true }
+        if let expiresAt = cgmSensorExpiresAt {
+            return expiresAt.timeIntervalSinceNow <= 48 * 60 * 60
+        }
+        return cgmProgress?.progressState != .normalCGM
+    }
+
+    /// String sniff — loopandlearn LoopKit has no structural warmup flag.
+    private var isInWarmup: Bool {
+        guard let message = cgmStatus?.localizedMessage else { return false }
+        let lowered = message.lowercased()
+        return lowered.contains("warming up") || lowered.contains("warmup")
+    }
+
+    private var isStabilizing: Bool {
+        cgmStatus?.localizedMessage.lowercased().contains("stabilizing") ?? false
+    }
+
+    /// Warmup → hourglass + countdown; stabilizing → hourglass + "Stabilizing"
+    /// (no countdown — duration is sensor-driven); outside warmup/stabilizing
+    /// the tag is gated to the same 48h window as the arc.
+    private var tagLabel: (text: String, theme: SensorStatusTagTheme, icon: String?)? {
+        if isInWarmup {
+            let text: String
+            if let endsAt = cgmWarmupEndsAt {
+                text = SensorRemainingTimeFormatter.format(until: endsAt)
+            } else {
+                text = "Warming up"
+            }
+            return (text, .orange, "hourglass")
+        }
+        if isStabilizing {
+            return ("Stabilizing", .orange, "hourglass")
+        }
+        guard shouldShowArc else { return nil }
+        if let status = cgmStatus {
+            // LibreLoop's cgmStatusHighlight uses "\n" to split two-line pill
+            // labels ("Signal\nLoss", "Sensor\nWarmup"); we render in a
+            // single-line tag, so collapse newlines to spaces here.
+            let oneLine = status.localizedMessage.replacingOccurrences(of: "\n", with: " ")
+            return (oneLine, theme(for: status.status), nil)
+        }
+        if let expiresAt = cgmSensorExpiresAt {
+            let text = SensorRemainingTimeFormatter.format(until: expiresAt)
+            let theme: SensorStatusTagTheme
+            switch cgmProgress?.progressState {
+            case .critical: theme = .red
+            case .warning: theme = .orange
+            default: theme = .green
+            }
+            return (text, theme, nil)
+        }
+        return nil
+    }
+
+    private func theme(for status: CgmDisplayStatus) -> SensorStatusTagTheme {
+        switch status {
+        case .critical: return .red
+        case .warning: return .orange
+        case .normal: return .secondary
+        }
+    }
+
+    private func glucoseColor(for glucoseValue: Int16) -> Color {
+        // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose
+        let hardCodedLow = Decimal(55)
+        let hardCodedHigh = Decimal(220)
+        let isDynamicColorScheme = glucoseColorScheme == .dynamicColor
+        guard Decimal(glucoseValue) <= lowGlucose || Decimal(glucoseValue) >= highGlucose else {
+            return Color.primary
+        }
+        return Trio.getDynamicGlucoseColor(
+            glucoseValue: Decimal(glucoseValue),
+            highGlucoseColorValue: isDynamicColorScheme ? hardCodedHigh : highGlucose,
+            lowGlucoseColorValue: isDynamicColorScheme ? hardCodedLow : lowGlucose,
+            targetGlucose: currentGlucoseTarget,
+            glucoseColorScheme: glucoseColorScheme
+        )
+    }
 }
 
 struct Triangle: Shape {
@@ -168,13 +326,16 @@ struct TrendShape: View {
 
     let gradient: AngularGradient
     let color: Color
+    var showArrow: Bool = true
 
     var body: some View {
         HStack(alignment: .center) {
             ZStack {
                 Group {
                     CircleShape(gradient: gradient)
-                    TriangleShape(color: color)
+                    if showArrow {
+                        TriangleShape(color: color)
+                    }
                 }.shadow(color: Color.black.opacity(colorScheme == .dark ? 0.75 : 0.33), radius: colorScheme == .dark ? 5 : 3)
                 CircleShape(gradient: gradient)
             }

+ 1 - 1
Trio/Sources/Modules/Home/View/Header/LoopStatusView.swift

@@ -289,7 +289,7 @@ struct LoopStatusView: View {
         }
 
         // FIXME: remove this before feat/dev-oref-swift is merged to dev
-        if state.settingsManager.settings.useSwiftOref {
+        if !state.settingsManager.settings.useJavascriptOref {
             tags.append("Swift Oref")
         }
 

+ 22 - 0
Trio/Sources/Modules/Home/View/Header/SensorLifecycle/CGMSensorDisplayState.swift

@@ -0,0 +1,22 @@
+import Foundation
+
+/// Compact `5d 14h` / `22h` / `45m` remaining-time formatter. Used as a
+/// fallback tag label when the CGM doesn't surface a `cgmStatusHighlight`
+/// but the lifecycle progress lets us derive an expiration date.
+enum SensorRemainingTimeFormatter {
+    static func format(until expiresAt: Date, now: Date = Date()) -> String {
+        let remaining = max(0, expiresAt.timeIntervalSince(now))
+        let totalMinutes = Int(remaining) / 60
+        let days = totalMinutes / (60 * 24)
+        let hours = (totalMinutes / 60) % 24
+        let minutes = totalMinutes % 60
+        let d = String(localized: "d", comment: "Abbreviation for Days")
+        let h = String(localized: "h", comment: "Abbreviation for Hours")
+        let m = String(localized: "m", comment: "Abbreviation for Minutes")
+        if days > 0, hours > 0 { return "\(days)\(d) \(hours)\(h)" }
+        if days > 0 { return "\(days)\(d)" }
+        if hours > 0 { return "\(hours)\(h)" }
+        if minutes > 0 { return "\(minutes)\(m)" }
+        return "<" + "\u{00A0}" + "1" + "\u{00A0}" + m
+    }
+}

+ 55 - 0
Trio/Sources/Modules/Home/View/Header/SensorLifecycle/SensorLifecycleArcView.swift

@@ -0,0 +1,55 @@
+import LoopKit
+import LoopKitUI
+import SwiftUI
+
+/// Concentric outer ring around the glucose bobble that drains clockwise from
+/// 12 o'clock as the sensor session ages. Always renders a faint track behind
+/// the fill so the indicator's footprint is visible even at `progress == 0`.
+struct SensorLifecycleArcView: View {
+    let progress: Double
+    let progressState: DeviceLifecycleProgressState
+
+    /// Outer diameter — bobble is ~130pt; arc sits 10pt outside the ring stroke.
+    static let diameter: CGFloat = 146
+    static let strokeWidth: CGFloat = 4
+
+    private var arcColor: Color {
+        switch progressState {
+        case .critical:
+            return Color.loopRed
+        case .warning:
+            return Color.orange
+        case .dimmed,
+             .normalCGM,
+             .normalPump:
+            return Color.loopGreen
+        }
+    }
+
+    var body: some View {
+        ZStack {
+            Circle()
+                .stroke(Color.secondary.opacity(0.4), lineWidth: Self.strokeWidth)
+
+            Circle()
+                .trim(from: 0, to: max(0, min(1, progress)))
+                .stroke(arcColor, style: StrokeStyle(lineWidth: Self.strokeWidth, lineCap: .round))
+                .rotationEffect(.degrees(-90))
+                .animation(.easeInOut(duration: 0.25), value: progress)
+        }
+        .frame(width: Self.diameter, height: Self.diameter)
+        .allowsHitTesting(false)
+    }
+}
+
+#Preview("Arc — progress states") {
+    VStack(spacing: 24) {
+        SensorLifecycleArcView(progress: 0.15, progressState: .normalCGM)
+        SensorLifecycleArcView(progress: 0.55, progressState: .normalCGM)
+        SensorLifecycleArcView(progress: 0.92, progressState: .warning)
+        SensorLifecycleArcView(progress: 1.0, progressState: .critical)
+    }
+    .padding(40)
+    .background(Color.black)
+    .preferredColorScheme(.dark)
+}

+ 56 - 0
Trio/Sources/Modules/Home/View/Header/SensorLifecycle/SensorStatusTagView.swift

@@ -0,0 +1,56 @@
+import SwiftUI
+
+/// Status badge below the glucose bobble — text-only, no fill or border.
+struct SensorStatusTagView: View {
+    let text: String
+    let theme: SensorStatusTagTheme
+    var iconSystemName: String?
+
+    var body: some View {
+        HStack(spacing: 4) {
+            if let iconSystemName {
+                Image(systemName: iconSystemName)
+                    .font(.callout)
+                    .fontWeight(.bold)
+                    .foregroundStyle(theme.textColor)
+            }
+            Text(text)
+                .font(.callout)
+                .fontWeight(.bold)
+                .fontDesign(.rounded)
+                .foregroundStyle(theme.textColor)
+                .lineLimit(1)
+        }
+        .padding(.bottom, -16)
+    }
+}
+
+enum SensorStatusTagTheme {
+    case green
+    case orange
+    case red
+    case secondary
+
+    var textColor: Color {
+        switch self {
+        case .green: return Color.loopGreen
+        case .orange: return Color.orange
+        case .red: return Color.loopRed
+        case .secondary: return Color.secondary
+        }
+    }
+}
+
+#Preview("Tag — all themes") {
+    VStack(spacing: 12) {
+        SensorStatusTagView(text: "5d 14h", theme: .green)
+        SensorStatusTagView(text: "22h left", theme: .orange)
+        SensorStatusTagView(text: "warming up", theme: .secondary)
+        SensorStatusTagView(text: "calibrate", theme: .secondary)
+        SensorStatusTagView(text: "sensor expired", theme: .red)
+        SensorStatusTagView(text: "sensor error", theme: .red)
+    }
+    .padding(40)
+    .background(Color.black)
+    .preferredColorScheme(.dark)
+}

+ 62 - 23
Trio/Sources/Modules/Home/View/HomeRootView.swift

@@ -32,8 +32,11 @@ extension Home {
         @State var isMenuPresented = false
         @State var showTreatments = false
         @State var selectedTab: Int = 0
+        @State var showQuickBolusPicker = false
+        @State var showQuickBolusNoHistory = false
         @State var showPumpSelection: Bool = false
         @State var showCGMSelection: Bool = false
+        @State var showSnoozeSheet: Bool = false
         @State var notificationsDisabled = false
         @State var timeButtons: [TimePicker] = [
             TimePicker(active: false, hours: 4),
@@ -133,20 +136,24 @@ extension Home {
                 cgmAvailable: state.cgmAvailable,
                 currentGlucoseTarget: state.currentGlucoseTarget,
                 glucoseColorScheme: state.glucoseColorScheme,
-                glucose: state.latestTwoGlucoseValues
-            ).scaleEffect(0.9)
-                .onTapGesture {
-                    if !state.cgmAvailable {
-                        showCGMSelection.toggle()
-                    } else {
-                        state.shouldDisplayCGMSetupSheet.toggle()
-                    }
-                }
-                .onLongPressGesture {
-                    let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
-                    impactHeavy.impactOccurred()
-                    state.showModal(for: .snooze)
+                glucose: state.latestTwoGlucoseValues,
+                cgmProgress: state.cgmProgressHighlight,
+                cgmStatus: state.cgmDisplayState,
+                cgmSensorExpiresAt: state.cgmSensorExpiresAt,
+                cgmWarmupEndsAt: state.cgmWarmupEndsAt
+            )
+            .onTapGesture {
+                if !state.cgmAvailable {
+                    showCGMSelection.toggle()
+                } else {
+                    state.shouldDisplayCGMSetupSheet.toggle()
                 }
+            }
+            .onLongPressGesture {
+                let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
+                impactHeavy.impactOccurred()
+                showSnoozeSheet = true
+            }
         }
 
         var pumpView: some View {
@@ -991,6 +998,9 @@ extension Home {
             .sheet(isPresented: $state.isLegendPresented) {
                 ChartLegendView(state: state)
             }
+            .sheet(isPresented: $showSnoozeSheet) {
+                SnoozeAlertsSheetView(resolver: resolver, isPresented: $showSnoozeSheet)
+            }
             // PUMP RELATED
             .confirmationDialog("Pump Model", isPresented: $showPumpSelection) {
                 Button("Medtronic") { state.addPump(.minimed) }
@@ -1105,17 +1115,28 @@ extension Home {
                 }
                 .tint(Color.tabBar)
 
-                Button(
-                    action: {
-                        state.showModal(for: .treatmentView) },
-                    label: {
-                        Image(systemName: "plus.circle.fill")
-                            .font(.system(size: 40))
-                            .foregroundStyle(Color.tabBar)
-                            .padding(.vertical, 2)
-                            .padding(.horizontal, 24)
+                Image(systemName: "plus.circle.fill")
+                    .font(.system(size: 40))
+                    .foregroundStyle(Color.tabBar)
+                    .padding(.vertical, 2)
+                    .padding(.horizontal, 24)
+                    .contentShape(Rectangle())
+                    .onTapGesture {
+                        state.showModal(for: .treatmentView)
+                    }
+                    .onLongPressGesture(minimumDuration: 0.5) {
+                        guard state.enableQuickBolus else { return }
+                        let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
+                        impactHeavy.impactOccurred()
+                        Task {
+                            await state.loadQuickBolusSuggestions()
+                            if state.quickBolusHistory.isEmpty {
+                                showQuickBolusNoHistory = true
+                            } else {
+                                showQuickBolusPicker = true
+                            }
+                        }
                     }
-                )
             }.ignoresSafeArea(.keyboard, edges: .bottom).blur(radius: state.waitForSuggestion ? 8 : 0)
                 .onChange(of: selectedTab) {
                     if !settingsPath.isEmpty {
@@ -1132,6 +1153,24 @@ extension Home {
                     CustomProgressView(text: String(localized: "Updating IOB...", comment: "Progress text when updating IOB"))
                 }
             }
+            .sheet(isPresented: $showQuickBolusPicker) {
+                QuickPickBolusesView(
+                    suggestions: state.quickBolusHistory,
+                    onEnact: { amount in await state.enactQuickBolus(amount: amount) },
+                    isPresented: $showQuickBolusPicker
+                )
+            }
+            .alert(
+                String(localized: "No bolus history yet", comment: "Alert title when no quick-pick boluses history exists"),
+                isPresented: $showQuickBolusNoHistory
+            ) {
+                Button(String(localized: "OK"), role: .cancel) {}
+            } message: {
+                Text(String(
+                    localized: "Quick-Pick Boluses learns from your manual boluses over time. Once you've delivered a few boluses, it will suggest amounts based on what you typically enact at this time of day.",
+                    comment: "Alert body explaining that quick-pick boluses history is empty"
+                ))
+            }
         }
     }
 }

+ 35 - 0
Trio/Sources/Modules/Home/View/QuickPickBoluses/QuickPickBolusesInfoView.swift

@@ -0,0 +1,35 @@
+import SwiftUI
+
+struct QuickPickBolusesInfoView: View {
+    @Binding var isPresented: Bool
+
+    var body: some View {
+        NavigationStack {
+            ScrollView {
+                Text(String(
+                    localized: "Quick-Pick Boluses looks at your manual boluses from the past 90 days and suggests the amounts you most commonly enact at this time of day.\n\nIt gives more weight to boluses from similar times of day, and treats weekdays and weekends separately. Older entries gradually count less.\n\nTap a suggestion to select it, then slide to confirm. Your normal Face ID or Touch ID approval always applies.",
+                    comment: "Info sheet body explaining how quick-pick boluses scoring works"
+                ))
+                    .padding()
+            }
+            .navigationTitle(String(
+                localized: "About Quick-Pick Boluses",
+                comment: "Info sheet title for quick-pick boluses feature"
+            ))
+            .navigationBarTitleDisplayMode(.inline)
+
+            Button {
+                isPresented = false
+            } label: {
+                Text("Got it!", comment: "Dismiss button for Quick-Pick Boluses info sheet")
+                    .bold()
+                    .frame(maxWidth: .infinity, minHeight: 30, alignment: .center)
+            }
+            .buttonStyle(.bordered)
+            .padding([.horizontal, .bottom])
+            .padding(.top, 4)
+        }
+        .presentationDetents([.medium])
+        .presentationDragIndicator(.visible)
+    }
+}

+ 149 - 0
Trio/Sources/Modules/Home/View/QuickPickBoluses/QuickPickBolusesView.swift

@@ -0,0 +1,149 @@
+import SwiftUI
+
+struct QuickPickBolusesView: View {
+    let suggestions: [Decimal]
+    let onEnact: (Decimal) async -> Bool
+    @Binding var isPresented: Bool
+
+    @State private var selectedAmount: Decimal?
+    @State private var showInfo = false
+    @State private var isEnacting = false
+    @State private var showAuthFailedAlert = false
+
+    var body: some View {
+        let titleText = String(localized: "Quick-Pick Boluses", comment: "Title of the quick-pick boluses sheet")
+        NavigationStack {
+            HStack(alignment: .top) {
+                VStack(alignment: .leading) {
+                    pillRow
+                        .padding(.top)
+
+                    Text(
+                        "Your most-used bolus amounts at similar times on similar days. Tap one to pick it.",
+                        comment: "Subtitle of the quick-pick boluses pill row"
+                    )
+                    .font(.subheadline)
+                    .foregroundStyle(.secondary)
+                    .multilineTextAlignment(.leading)
+                    .padding()
+
+                    Spacer()
+                }
+            }.padding(.horizontal)
+                .frame(maxWidth: .infinity)
+                .safeAreaInset(edge: .bottom) {
+                    VStack {
+                        SlideToConfirmView(
+                            label: String(
+                                localized: "Slide to Enact Bolus",
+                                comment: "Slide to confirm label for quick-pick boluses"
+                            ),
+                            isEnabled: selectedAmount != nil && !isEnacting
+                        ) {
+                            guard let amount = selectedAmount, !isEnacting else { return }
+                            isEnacting = true
+                            Task {
+                                let success = await onEnact(amount)
+                                await MainActor.run {
+                                    if success {
+                                        isPresented = false
+                                    } else {
+                                        isEnacting = false
+                                        showAuthFailedAlert = true
+                                    }
+                                }
+                            }
+                        }.padding(.horizontal)
+                    }
+                    .padding(.horizontal)
+                }
+                .navigationTitle(titleText)
+                .navigationBarTitleDisplayMode(.inline)
+                .toolbar {
+                    ToolbarItem(placement: .principal) {
+                        Text(titleText)
+                            .font(.title3.bold())
+                    }
+                    ToolbarItem(placement: .topBarTrailing) {
+                        Button {
+                            showInfo = true
+                        } label: {
+                            Image(systemName: "questionmark.circle")
+                        }
+                    }
+                }
+                .sheet(isPresented: $showInfo) {
+                    QuickPickBolusesInfoView(isPresented: $showInfo)
+                }
+                .alert(
+                    String(
+                        localized: "Could not authenticate",
+                        comment: "Alert title when biometric auth fails for quick-pick boluses"
+                    ),
+                    isPresented: $showAuthFailedAlert
+                ) {
+                    Button(String(localized: "OK"), role: .cancel) {}
+                } message: {
+                    Text(String(
+                        localized: "Face ID or Touch ID did not succeed. The bolus was not enacted.",
+                        comment: "Alert body when biometric auth fails for quick-pick boluses"
+                    ))
+                }
+        }
+        .presentationDetents([.height(320)])
+        .presentationDragIndicator(.visible)
+        .interactiveDismissDisabled(isEnacting)
+    }
+
+    private var displayedSuggestions: [Decimal] {
+        var seen = Set<Decimal>()
+        return suggestions.prefix(3).filter { seen.insert($0).inserted }.sorted()
+    }
+
+    private var pillRow: some View {
+        let pills = displayedSuggestions
+        let isCompact = pills.count < 2
+        return HStack(spacing: 16) {
+            if isCompact { Spacer() }
+
+            ForEach(pills, id: \.self) { amount in
+                bolusAmountPill(amount)
+                    .frame(maxWidth: isCompact ? 160 : .infinity)
+            }
+
+            if isCompact { Spacer() }
+        }
+        .padding(.horizontal)
+    }
+
+    private func bolusAmountPill(_ amount: Decimal) -> some View {
+        let isSelected = selectedAmount == amount
+        let formatted = Formatter.bolusFormatter.string(from: amount as NSDecimalNumber) ?? amount.description
+
+        return Button {
+            selectedAmount = amount
+        } label: {
+            HStack(alignment: .firstTextBaseline, spacing: 2) {
+                Text(formatted)
+                    .font(.title2.bold())
+                Text("U")
+                    .font(.title2)
+                    .foregroundStyle(isSelected ? .white.opacity(0.85) : .secondary)
+            }
+            .padding(.vertical, 12)
+            .padding(.horizontal, 24)
+            .frame(maxWidth: .infinity, minHeight: 44)
+            .background(isSelected ? Color.accentColor : Color(.secondarySystemFill))
+            .foregroundStyle(isSelected ? .white : .primary)
+            .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
+            .overlay {
+                if isSelected {
+                    RoundedRectangle(cornerRadius: 18, style: .continuous)
+                        .strokeBorder(Color.accentColor, lineWidth: 2.5)
+                }
+            }
+        }
+        .buttonStyle(.plain)
+        .animation(.easeInOut(duration: 0.15), value: isSelected)
+    }
+}

+ 58 - 0
Trio/Sources/Modules/Home/View/QuickPickBoluses/SlideToConfirmView.swift

@@ -0,0 +1,58 @@
+import SwiftUI
+
+struct SlideToConfirmView: View {
+    let label: String
+    let isEnabled: Bool
+    let action: () -> Void
+
+    @State private var dragOffset: CGFloat = 0
+
+    private let thumbSize: CGFloat = 52
+    private let trackHeight: CGFloat = 56
+    private let completionThreshold: CGFloat = 0.85
+
+    var body: some View {
+        GeometryReader { geo in
+            let maxDrag = geo.size.width - thumbSize - 8
+            let progress = maxDrag > 0 ? dragOffset / maxDrag : 0
+
+            ZStack(alignment: .leading) {
+                Capsule()
+                    .fill(isEnabled ? Color.accentColor.opacity(0.25) : Color.secondary.opacity(0.2))
+
+                Text(label)
+                    .font(.headline)
+                    .foregroundStyle(isEnabled ? .white.opacity(1 - progress) : .secondary)
+                    .frame(maxWidth: .infinity)
+
+                RoundedRectangle(cornerRadius: thumbSize / 2)
+                    .fill(isEnabled ? Color.accentColor : Color.secondary.opacity(0.4))
+                    .frame(width: thumbSize, height: thumbSize)
+                    .overlay {
+                        Image(systemName: "chevron.right.2")
+                            .foregroundStyle(.white)
+                            .font(.system(size: 18, weight: .semibold))
+                    }
+                    .offset(x: 4 + dragOffset)
+                    .gesture(
+                        isEnabled ? DragGesture()
+                            .onChanged { value in
+                                dragOffset = min(max(0, value.translation.width), maxDrag)
+                            }
+                            .onEnded { _ in
+                                guard maxDrag > 0 else { return }
+                                if dragOffset >= maxDrag * completionThreshold {
+                                    UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
+                                    withAnimation(.spring()) { dragOffset = 0 }
+                                    action()
+                                } else {
+                                    withAnimation(.spring()) { dragOffset = 0 }
+                                }
+                            } : nil
+                    )
+            }
+            .frame(height: trackHeight)
+        }
+        .frame(height: trackHeight)
+    }
+}

+ 0 - 0
Trio/Sources/Modules/LiveActivitySettings/View/LiveActivityWidgetConfiguration.swift


Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff