Skip to content

Release v6.2.0#684

Merged
marionbarker merged 34 commits into
mainfrom
release/v6.2.0
Jun 21, 2026
Merged

Release v6.2.0#684
marionbarker merged 34 commits into
mainfrom
release/v6.2.0

Conversation

@marionbarker

Copy link
Copy Markdown
Collaborator

Release v6.2.0. Merging this PR triggers the tagging workflow, which creates tag v6.2.0 from LOOP_FOLLOW_MARKETING_VERSION in Config.xcconfig. Use rebase-merge (not squash or merge-commit) so dev and main end up at the same commit SHA after the release.

bjorkert and others added 30 commits May 30, 2026 22:25
#649)

* Move release flow to PR-based and add CODEOWNERS for branch protection

- Add .github/CODEOWNERS
- Cut a release branch in release.sh and open two PRs (sync to dev,
  release to main) instead of merging dev into main locally
- Move tag creation into a workflow that fires on push to main
- Skip auto_version_dev when Config.xcconfig was changed in this push,
  so merging the release sync-PR into dev does not double-bump

* Make workflow guards portable via fork check

Replace the hardcoded 'loopandlearn' owner check in workflows with a
fork check, so the workflows run on any non-fork repository (including
a test org) while still skipping on contributor forks.

* Allow skipping sister repo updates in release.sh

Set SKIP_SISTER_REPOS=1 to bypass the LoopFollow_Second / LoopFollow_Third
update_follower steps. Default behavior is unchanged: both sister repos
are updated as today and missing directories still cause a hard error,
so a forgotten clone in production fails fast.

* Revert "Allow skipping sister repo updates in release.sh"

This reverts commit c2792b8.

* Skip patch hunks for files missing from sister repos

Sister repos (LoopFollow_Second / LoopFollow_Third) are intentionally
stripped of dev-only files like release.sh, auto_version_dev.yml,
lint.yml, and warn_main_pr.yml. Any release patch that touches one of
those files used to abort the sister-repo update with 'No such file or
directory'. Now update_follower runs git apply --check first, parses
the missing-file errors, and re-applies with --exclude for each, so the
sister patch covers the files that actually exist.

* Revert "Skip patch hunks for files missing from sister repos"

This reverts commit 45b9871.
* Diagnose and skip rogue Nightscout profile records

Profile fetch now uses /api/v1/profiles?count=1 with find[startDate][$lte]=now,
so future-dated records can no longer block the active profile. Adds a
"Run diagnostics" button in the Remote Settings Debug section that fetches
14 days of profile history and surfaces three failure modes:

- Bundle ID mismatch when Loop and Trio share a Nightscout
- Alternating device tokens from multiple installations
- Future-dated profile records left over from a wrong-clock uploader

The bouncing-tokens check compresses consecutive same-token runs and only
warns on actual token alternation, not normal token rotation.

* Widen bouncing-token check and surface shift history

Three changes to the profile diagnostics:

- Drop the 14-day find[startDate][$gte] filter. A slow A→B→A pattern
  spread across months only registers as one transition inside a 14-day
  window, so the bouncing-tokens check would silently miss it on servers
  that honor the filter. The existing 1000-record cap now defines the
  scope, which goes back as far as upload frequency allows.
- Fall back to created_at when sorting profile records, so uploaders
  that omit startDate don't cluster at .distantPast and corrupt the
  run-length compression.
- Include the chronological list of token shifts in the bouncing-tokens
  warning. Each row shows when the shift happened and the abbreviated
  from→to tokens, so users can see at a glance which devices are
  competing instead of just "3 tokens involved across N records".
…wed LA (#656)

pendingForegroundRestart could outlive the condition that triggered it:
a brief foreground entry while the renewal overlay was up latched the
intent, the user backgrounded before didBecomeActive ran, the background
renewal then replaced the LA, and the next foreground entry minutes later
fired the deferred restart against an already-fresh LA.

- adoptPushToStartActivity clears pendingForegroundRestart on every
  adoption — a freshly-adopted LA resolves the renewal-window condition
  that latched the intent.
- performForegroundRestart re-checks renewalFailed / overlayShowing /
  pushToStartLooksStuck before tearing down; bails if none still hold.
- Deferred-foreground-restart push-to-start is tagged
  reason="deferred-foreground-restart" via a single-shot
  nextStartReasonOverride, so the stale-latch event is no longer
  indistinguishable from a real user start in logs.
A 6.1.0 user reported the Live Activity vanishing and refusing to come
back without a manual Restart. Trace: APNs returned 410 on the per-
activity push token at 04:42; handleExpiredToken ended the activity but
the eventual iOS .dismissed (4 h later, under the default dismissal
policy) was classified as a user swipe and locked dismissedByUser=true.

Root cause is two cooperating bugs around an app-initiated end():

- end() nulls `current` and clears laRenewBy. handleExpiredToken's
  comment said "Activity will restart on next BG refresh via
  refreshFromCurrentState()", but renewIfNeeded short-circuits when
  current is nil and performRefresh's bind-existing path rebinds to the
  just-ended activity. bind() then clears endingForRestart, so the late
  .dismissed reads as renewBy=0 / renewalFailed=false / endingForRestart=
  false — branch (c) "USER" in the classifier.

- The classifier had no way to recognize a stale observer firing for an
  activity the app no longer tracks.

Fixes:

- handleExpiredToken drives the restart synchronously on iOS 17.2+
  (attemptPushToStartCreate "expired-token"), so the orphaned post-410
  state is short-lived and adoption of the fresh activity cancels the
  old observer.

- performRefresh / update bind-existing only to activities in
  .active state. Binding to an .ended/.dismissed corpse would clear
  endingForRestart and re-attach an observer that only ever delivers
  .dismissed.

- .dismissed classifier gains branch (d): if the dismissed activity is
  not the one we currently track, log and take no action — only the
  foreground LA can be user-swiped, so a stale-observer delivery for an
  already-replaced activity must not latch dismissedByUser=true.
When iOS reaches the Live Activity lifetime cap before renewal fires it
delivers .ended, not .dismissed. The state observer only ran restart
logic on .dismissed, so handleForeground saw renewalFailed=false and
renewBy still in the future and returned "no action needed", leaving
the LA dark until manual force-restart.

Mark laRenewalFailed=true on the .ended path (gated on wasCurrent and
!endingForRestart) so the next foreground entry triggers
performForegroundRestart, which sweeps the corpse activity and pushes a
fresh one.
)

Dexcom Share returns each reading twice when both the iPhone Dexcom
app and the Apple Watch app upload to the same account (~9-10 s apart,
same SGV). Without deduplication the two most recent entries in bgData
were always identical, producing delta = 0.

The NS fetch path already had inline deduplication. Extract it into a
shared helper (deduplicateBGReadings) and apply it to the Dexcom-only
path as well.
* Remove Main.storyboard and migrate to SwiftUI app lifecycle

Replace UIKit storyboard/SceneDelegate architecture with SwiftUI App
entry point (LoopFollowApp.swift) and TabView (MainTabView.swift).
Convert MoreMenuViewController to SwiftUI (MoreMenuView.swift). Add
SwiftUI wrappers for Remote and Nightscout tabs. Remove 6 obsolete
UIKit wrapper view controllers and ~300 lines of tab management code
from MainViewController.

* Migrate info table from UITableView to SwiftUI List

Replace UITableView with SwiftUI InfoTableView hosted in
MainViewController. Make InfoManager an ObservableObject so data
updates trigger SwiftUI rebuilds automatically. Remove
UITableViewDataSource conformance and table delegate methods.
No changes needed to the 10 Nightscout controller files that
populate the table data.

* Migrate statistics and pie chart from UIKit to SwiftUI

Replace 7 UILabel properties and DGCharts PieChartView with a
StatsDisplayModel ObservableObject and hosted StatsDisplayView.
The pie chart uses a UIViewRepresentable wrapper for DGCharts
since the Charts pod name shadows Swift Charts. Remove ~60 lines
of UIKit stack layout code from MainViewController setupUI().

* Migrate BG display area from UIKit labels to SwiftUI

Replace BGText, DirectionText, DeltaText, MinAgoText, serverText,
LoopStatusLabel, and PredictionLabel with a SwiftUI BGDisplayView.
Add pull-to-refresh via .refreshable modifier. Move loop status and
prediction text updates to Observable values across DeviceStatus,
DeviceStatusLoop, DeviceStatusOpenAPS, and BGData. Remove
UIScrollView overlay and UIScrollViewDelegate conformance.

* Migrate main layout to SwiftUI with UIKit charts embedded

Replace UIStackView layout with MainHomeView SwiftUI view that composes
BGDisplayView, InfoTableView, LineChartWrapper (UIViewRepresentable for
DGCharts), and StatsDisplayView. MainViewController now hosts a single
UIHostingController instead of managing individual UIView containers.

Visibility of info table, small graph, and stats is now reactive via
Storage observables in SwiftUI, removing several Combine subscriptions.
BG text uses lineLimit + minimumScaleFactor instead of manual font sizing.

* Clean up migration artifacts and fix post-migration bugs

- Fix AVSpeechSynthesizer temporary in AppDelegate that would be
  deallocated before speech completes; use stored property instead
- Fix appMovedToBackground tab switching to use Observable instead of
  dead UIKit tabBarController reference
- Remove dead code: rebuildTabsIfNeeded(), updateNightscoutTabState(),
  traitCollectionDidChange notification relay, UIViewExtension.addBorder
- Remove unused imports (Charts, UIKit, Combine) from migrated files
- Remove unused synthesizer from LoopFollowApp
- Remove redundant .appearanceDidChange subscription from NightscoutVC
- Add missing super calls in viewWillAppear/viewDidAppear

* Replace view hierarchy walking with MainViewController.shared

The getMainViewController() methods in TreatmentsView, SettingsMenuView,
and BackgroundRefreshManager tried to find MainViewController by casting
rootViewController as UITabBarController, which always fails with the
SwiftUI lifecycle. Add a weak static shared reference set during
viewDidLoad and use it everywhere instead.

* Fix MainViewController.shared references for stats and treatments

Pass MainViewController.shared instead of nil when creating
AggregatedStatsContentView in MainTabView and MoreMenuView. Replace
view-hierarchy-walking getMainViewController() in TreatmentsViewModel
with MainViewController.shared.

* Fix info table font size to match storyboard

The storyboard used system 17pt for both title and detail labels.
The SwiftUI migration used .subheadline (~15pt) making text smaller.

* Fix Share Logs sheet rendering blank

Present UIActivityViewController via UIApplication.topMost instead of
wrapping it in a SwiftUI .sheet, which rendered an empty view.

* Fix back navigation from Settings sub-pages

SettingsMenuView declared its own NavigationStack(path:) while already
being pushed onto the outer NavigationStack from MainTabView, so sub-page
back buttons popped the outer stack and jumped past Settings to Menu.

Drop the nested NavigationStack and route Settings entries through the
ambient stack: a single SettingsRoute enum drives a navigationDestination
attached at the MoreMenuView root. The Settings entry itself becomes a
NavigationLink(value:) so it doesn't compete with a navigationDestination
(isPresented:) modifier, which was re-asserting Settings as the top of
stack whenever a sub-page was pushed.

* Harden post-storyboard migration

* MainViewController is now a strong static singleton bootstrapped from
  LoopFollowApp.init(). Lifecycle work in viewDidLoad (Combine sinks,
  observers, scheduleAllTasks, migrations) runs at launch regardless of
  whether the Home tab is rendered, and HomeContentView reuses the
  singleton instead of instantiating a fresh VC each time.

* MoreMenuView's eight .navigationDestination(isPresented:) modifiers are
  collapsed to a single MenuRoute enum routed through one
  .navigationDestination(for:), preventing the same destination-slot
  contention that previously caused Settings → Graph back navigation to
  jump past Settings.

* MainTabView observes Storage.shared.appearanceMode so theme changes
  propagate; the orphaned .appearanceDidChange notification name is
  removed.

* OPEN_APP_ACTION notification taps now dismiss any presented modal
  before switching to Home, matching prior SceneDelegate behavior.

* Drop the unused Core Data stack (NSPersistentCloudKitContainer,
  saveContext) from AppDelegate, the dead AppDelegate.window property,
  and the legacy UIRequiredDeviceCapabilities=armv7 /
  UIStatusBarTintParameters keys from Info.plist. Switch
  AlarmSound's keyWindow access to the connected-scenes API and
  generalize UIApplication.topMost likewise so it works on Mac
  Catalyst.

* Strip redundant inner NavigationView wrappers from settings sub-views
  pushed onto the outer NavigationStack: Graph, General, Advanced,
  Calendar, Contact, Dexcom, Nightscout, BackgroundRefresh,
  InfoDisplay, ImportExport. Drop unused onBack parameters from
  AlarmsContainerView and SettingsMenuView, the unused
  isPresentedAsModal flag from MainViewController, and the leftover
  debug print in ObservableValue.set.

* LineChartWrapper.updateUIView now flushes the chart on SwiftUI
  re-render. MainViewController.deinit removes all observers, not
  just the custom "refresh" one. MoreMenuView caches the app version
  in @State instead of constructing AppVersionManager on every body
  re-render. HomeModalView uses NavigationStack (not deprecated
  NavigationView).

* MoreMenuView: render tab-switch buttons in primary color

Buttons in a List inherit the accent tint, so the Features rows that switch tabs appeared blue while the NavigationLink rows that push appeared white. Use .buttonStyle(.plain) to suppress the tint and drop the now-redundant .foregroundStyle(.primary) calls.

* Revert MainViewController singleton bootstrap

Constructing MainViewController.shared from LoopFollowApp.init() — and
reusing the same VC across HomeContentView re-creations — caused tapping
the BG chart to crash with `-[__NSArrayM insertObject:atIndex:]: object
cannot be nil`. Bisected to the singleton+bootstrap piece of the
post-storyboard hardening; the rest of that commit (programmatic UI,
MoreMenuView routing, NavigationView strip-out) is retained.

Restore the prior behavior: shared is a weak static set in viewDidLoad,
HomeContentView constructs a fresh MainViewController each time, and the
LoopFollowApp.init() bootstrap is removed.

Known follow-up: lifecycle work in viewDidLoad (Combine sinks,
scheduleAllTasks, migrations) again only runs when the Home view is
first rendered, so a user who has moved Home off the tab bar gets
degraded behavior until they navigate to it.

* MoreMenuView: make tab-switch rows full-row tappable

Wrap Button labels in an HStack with a trailing Spacer and contentShape
so the entire row is tappable, matching the hit area of NavigationLink
rows. Extract the pattern into a small FullRowButton helper, used for
both tab-switch rows and Share Logs.

* Align units-selection conflict resolution with integration branch

Move the Diagnostics section out of Section("Speak BG") (was nested at
the wrong indent), match StatsDisplayModel field order, and add the
spacing line in updateStats.

* MoreMenuView: fix cross-row tap routing in Features section

Mixing Button and NavigationLink rows in the same List ForEach caused
taps on a NavigationLink row to fire a sibling Button row's action —
e.g. tapping Alarms with Stats in the tab bar would switch to the Stats
tab instead of pushing the alarms detail.

Make every row in the menu's List a uniform FullRowButton and drive
pushes from state via .navigationDestination(isPresented:). Add an
opt-in chevron to FullRowButton so navigating rows render the standard
disclosure indicator.

* MoreMenuView: keep Settings as a value-based NavigationLink

Mixing .navigationDestination(isPresented:) with
.navigationDestination(for:) on the same view shadowed the value-based
SettingsRoute registration once SettingsMenuView was on the stack, so
sub-rows like Units and Metrics couldn't push.

Settings sits alone in its section, so it doesn't need the uniform-Button
treatment used in Features and Logging — restore it to a NavigationLink
and route it through the existing .navigationDestination(for:) channel.

* Fix navigation between alarms and menu

* Drive Before-First-Unlock recovery from AppDelegate

Move BFU recovery (Storage.reloadAll) out of MainViewController and into
AppDelegate so it runs even when the home tab's UIHostingController has
not yet materialized — necessary under the SwiftUI App lifecycle, where a
BG-only launch (BGAppRefreshTask, BLE wake, prewarming) may complete and
the device may unlock without MainViewController ever being created.

AppDelegate observes protectedDataDidBecomeAvailable (authoritative signal)
and willEnterForeground (fallback), with a race-guard re-check immediately
after observer registration. Recovery is idempotent via needsBFUReload.

MainViewController now reacts to a new .bfuReloadCompleted notification by
showing the loading overlay and rescheduling tasks; if it is not alive when
the notification fires, its viewDidLoad will later see the already-reloaded
Storage values and schedule tasks correctly on first load.

* Keep MainViewController alive regardless of tab layout

Make MainViewController.shared a strong, long-lived singleton created
once via bootstrap() on first foreground, so the data pipeline, alarms
and background audio run even when Home is moved into the Menu rather
than a tab. Home views reuse the single instance instead of creating
new ones, so the singleton is never displaced.

Defer the one-shot BG graph zoom until the chart has a real frame and
re-render the graph on every appearance, so the curve stays visible
when Home is reached from the Menu or moved between tab bar and Menu.

Restore the one-time telemetry consent prompt that was lost when
SceneDelegate was removed, presenting it from MainTabView on first
appearance for undecided installs.

* Fix issues found in post-migration review

- Speak BG quick action: under the SwiftUI scene lifecycle UIKit
  delivers Home Screen quick actions to the window scene delegate and
  never calls application(_:performActionFor:). Install a scene
  delegate via configurationForConnecting that handles warm taps,
  cold-launch shortcut delivery, and mirrors the Live Activity la-tap
  URL handling that moves with it.
- Nightscout tab: show a hint instead of a blank page when no URL is
  configured, and recreate the web view when the URL or token changes
  (the page was loaded once in viewDidLoad and stayed stale until app
  restart).
- Stats: resolve MainViewController lazily with a fallback to the
  shared instance, so stats work when the tab is built before
  bootstrap() has run (cold launch with Statistics as selected tab).
- Remove the unwired NightscoutSettingsViewModel delegate chain.
- Make LineChartWrapper.updateUIView a no-op; MainViewController
  already notifies the charts whenever it mutates their data.

* Fix squished Home layout caused by phantom keyboard inset

iOS sometimes replays a stale keyboard frame when the app returns to the
foreground, compressing the Home screen by a keyboard's height until a
rotation forces the safe area to recompute. Home has no text input, so
opt out of keyboard avoidance at both hosting layers.
UNUserNotificationCenterDelegate.willPresent returned
[.banner, .sound, .badge] unconditionally, which meant any notification
iOS routed through this handler while the app was foregrounded produced
sound — including the Live Activity push-to-start payload, which is
intentionally silent (interruption-level: passive, empty title/body).

Now returns [] for passive notifications and for ones with empty
title/body. The four intentional alerts (renewal-failed, APNs
credentials missing, push-to-start token missing, alarms) all use
non-empty title/body and the default .active interruption level, so
they continue to surface.

Also expanded the willPresent log line with interruptionLevel and
title/body presence so future reports can confirm whether iOS routed
a given payload here.
* Bump fastlane to 2.235.0 and jwt to 3.2.0 (CVE-2026-45363)

jwt < 3.2.0 accepts attacker-forged tokens when an empty or nil key is
used with HMAC algorithms (GHSA-c32j-vqhx-rx3x). fastlane 2.233.1 pinned
jwt < 3, blocking the fix. fastlane 2.235.0 relaxes that to jwt < 4,
allowing the upgrade to 3.2.0.

* Remove json and addressable pins no longer needed with fastlane 2.235.0
…uploaders (#662)

* Increase Nightscout BG entry count to handle multiple uploaders

Some users have multiple sources uploading BG entries to Nightscout for
the same sensor (e.g. a closed-loop system plus two Dexcom platform apps),
resulting in up to 3 entries per 5-minute slot. The previous count cap of
days × 2 × 288 was too low in those cases, silently truncating the oldest
portion of the requested time window before deduplication.

Raise the multiplier from 2 to 4 in both BGData.swift and
StatsDataFetcher.swift, giving headroom for up to 4 uploaders. The
date[$gte] filter still bounds the window, so no extra data is returned
when fewer entries exist.

* Supplement Dex Share with NS data when Dex doesn't cover the full window

When Dexcom credentials are present, LoopFollow fetches from Dex Share
first. If the Dex data doesn't reach back to the start of the requested
window (e.g. Dex Share's 24h cap when more days are configured, or any
other gap), fall through to webLoadNSBGData so Nightscout fills in the
older portion. The existing merge logic already handles stitching the two
sources together correctly.

* Deduplicate Dex Share readings before use

Multiple Dexcom uploaders (e.g. G7 iPhone app + Apple Watch) each write
readings to Dexcom's cloud, causing the Share API to return 2+ entries
per 5-minute slot. With the API's hard cap of 288 readings, duplicates
consume the budget and the returned data covers only ~15h instead of 24h.

Dedup Dex Share data with the same 30-second window used for Nightscout,
so both the NS-supplement path and the direct ProcessDexBGData path
receive clean, deduplicated readings.

* Extract BG entry-count multiplier into named globalVariables.maxExpectedUploaders

* Reuse deduplicateBGReadings helper for Dexcom Share dedup
* Common remote commands

* Help texts

* Rename Common to Quick-Pick throughout

Renames all files, classes, types, and UI labels introduced by the
quick-pick feature from "Common" to "Quick-Pick":

- CommonBoluses/ → QuickPickBoluses/
- CommonMeals/ → QuickPickMeals/
- CommonBolusesManager → QuickPickBolusesManager
- CommonMealsManager → QuickPickMealsManager
- CommonSectionHeader → QuickPickSectionHeader
- UI labels: "Common Boluses/Meals" → "Quick-Pick Boluses/Meals"
* Add Nightscout WebSocket (Socket.IO) support for real-time data updates

Connect to Nightscout's Socket.IO endpoint to receive push notifications
when new data arrives, instead of waiting for the next poll cycle. The
WebSocket acts as a smart trigger: when a dataUpdate event arrives, only
the relevant data types (BG, treatments, device status, profile) are
fetched based on which keys are present in the delta payload.

When WebSocket is connected and authenticated, polling intervals are
extended (BG 3x, device status 3x, treatments 2→10 min, profile 10→30
min) so HTTP polling becomes a safety net. On disconnect, polling
immediately reverts to normal intervals.

The feature is always-on when Nightscout is configured — no user setting
needed. A read-only connection status is shown in Nightscout settings.

- Add Socket.IO-Client-Swift 16.1.1 via SPM
- Add NightscoutSocketManager for connection lifecycle
- Add NightscoutSocketDataHandler for selective push-trigger logic
- Extend polling intervals when WebSocket is authenticated
- Show WebSocket status in Nightscout settings
- Wire up lifecycle in MainViewController (init, foreground, refresh)
- Add staleness detection (10 min fallback to polling)

* Add opt-in WebSocket toggle with info sheet and refresh on disconnect

- Add webSocketEnabled storage property (default off) so users opt in
- Replace read-only status with toggle + info button in Nightscout settings
- Info sheet explains real-time updates, polling fallback, and battery impact
- On toggle off: disconnect socket and trigger full refresh to restore
  normal polling intervals immediately
- On unexpected socket disconnect: post refresh notification so extended
  polling intervals revert to normal without waiting for them to expire

* Fix stale WebSocket session on config change, remove redundant staleness timer

- Disconnect WebSocket when Nightscout URL/token validation fails,
  preventing the old session from streaming data from a previous server
  while polling has switched to the new config
- Reorder removeAllHandlers() before disconnect() so intentional
  disconnects don't fire the event handler that would reconnect to
  an invalid URL
- Remove staleness timer — the extended polling intervals (3x/5x)
  already serve as the safety net when WebSocket data stops flowing

* Limit WebSocket to foreground and default-on

Disconnect the Nightscout WebSocket when LoopFollow moves to the
background and re-establish it when returning to the foreground.
Polling continues to handle background updates, so the persistent
connection no longer holds the cellular radio out of idle while the
app is not in use. With the battery cost bounded to foreground time,
the toggle now defaults to on. Inline documentation updated to
describe the foreground/background behavior.

* Resume Nightscout polling promptly when the WebSocket disconnects

While the socket is authenticated, each REST poll is rescheduled with a
multiplier on the assumption WS will publish the next reading before the
poll fires. Without an explicit catch-up, those long delays carry over
when the socket goes away — most visibly on background entry, where the
user could sit on stale data for 10–15 minutes after the screen locked.

Fire each Nightscout poll immediately on disconnect. Their actions then
reschedule on the normal un-multiplied cadence, since connectionState is
no longer .authenticated. Gate on the previous state so the reconnect
dance in connectIfNeeded() and other no-op disconnect paths don't trigger
spurious REST round-trips.
…#627)

Flip the debugLogLevel default to true and add a migration step so
existing users with it stored as false also get it enabled, ensuring
shared logs contain useful detail when reporting problems.

When the user taps Share Logs, present a sheet asking for a short
description of the issue. The description is written to a
ShareNotice_<timestamp>.txt file (date, app version, branch+sha,
user description) and included alongside the log files in the iOS
share sheet.
Adds a Graph Settings toggle (Nightscout only) that overlays yesterday's
BG curve on the main graph, time-shifted by 24h so it aligns with the same
clock time today. Drawn as a thin dimmed gray line with no dots, purely for
comparison. When enabled, one extra day of history is fetched and the overlay
is capped to now plus the configured hours of prediction so it never extends
further into the future than the prediction line.
* Remove Main.storyboard and migrate to SwiftUI app lifecycle

Replace UIKit storyboard/SceneDelegate architecture with SwiftUI App
entry point (LoopFollowApp.swift) and TabView (MainTabView.swift).
Convert MoreMenuViewController to SwiftUI (MoreMenuView.swift). Add
SwiftUI wrappers for Remote and Nightscout tabs. Remove 6 obsolete
UIKit wrapper view controllers and ~300 lines of tab management code
from MainViewController.

* Migrate info table from UITableView to SwiftUI List

Replace UITableView with SwiftUI InfoTableView hosted in
MainViewController. Make InfoManager an ObservableObject so data
updates trigger SwiftUI rebuilds automatically. Remove
UITableViewDataSource conformance and table delegate methods.
No changes needed to the 10 Nightscout controller files that
populate the table data.

* Migrate statistics and pie chart from UIKit to SwiftUI

Replace 7 UILabel properties and DGCharts PieChartView with a
StatsDisplayModel ObservableObject and hosted StatsDisplayView.
The pie chart uses a UIViewRepresentable wrapper for DGCharts
since the Charts pod name shadows Swift Charts. Remove ~60 lines
of UIKit stack layout code from MainViewController setupUI().

* Migrate BG display area from UIKit labels to SwiftUI

Replace BGText, DirectionText, DeltaText, MinAgoText, serverText,
LoopStatusLabel, and PredictionLabel with a SwiftUI BGDisplayView.
Add pull-to-refresh via .refreshable modifier. Move loop status and
prediction text updates to Observable values across DeviceStatus,
DeviceStatusLoop, DeviceStatusOpenAPS, and BGData. Remove
UIScrollView overlay and UIScrollViewDelegate conformance.

* Migrate main layout to SwiftUI with UIKit charts embedded

Replace UIStackView layout with MainHomeView SwiftUI view that composes
BGDisplayView, InfoTableView, LineChartWrapper (UIViewRepresentable for
DGCharts), and StatsDisplayView. MainViewController now hosts a single
UIHostingController instead of managing individual UIView containers.

Visibility of info table, small graph, and stats is now reactive via
Storage observables in SwiftUI, removing several Combine subscriptions.
BG text uses lineLimit + minimumScaleFactor instead of manual font sizing.

* Clean up migration artifacts and fix post-migration bugs

- Fix AVSpeechSynthesizer temporary in AppDelegate that would be
  deallocated before speech completes; use stored property instead
- Fix appMovedToBackground tab switching to use Observable instead of
  dead UIKit tabBarController reference
- Remove dead code: rebuildTabsIfNeeded(), updateNightscoutTabState(),
  traitCollectionDidChange notification relay, UIViewExtension.addBorder
- Remove unused imports (Charts, UIKit, Combine) from migrated files
- Remove unused synthesizer from LoopFollowApp
- Remove redundant .appearanceDidChange subscription from NightscoutVC
- Add missing super calls in viewWillAppear/viewDidAppear

* Replace view hierarchy walking with MainViewController.shared

The getMainViewController() methods in TreatmentsView, SettingsMenuView,
and BackgroundRefreshManager tried to find MainViewController by casting
rootViewController as UITabBarController, which always fails with the
SwiftUI lifecycle. Add a weak static shared reference set during
viewDidLoad and use it everywhere instead.

* Fix MainViewController.shared references for stats and treatments

Pass MainViewController.shared instead of nil when creating
AggregatedStatsContentView in MainTabView and MoreMenuView. Replace
view-hierarchy-walking getMainViewController() in TreatmentsViewModel
with MainViewController.shared.

* Fix info table font size to match storyboard

The storyboard used system 17pt for both title and detail labels.
The SwiftUI migration used .subheadline (~15pt) making text smaller.

* Fix Share Logs sheet rendering blank

Present UIActivityViewController via UIApplication.topMost instead of
wrapping it in a SwiftUI .sheet, which rendered an empty view.

* Fix back navigation from Settings sub-pages

SettingsMenuView declared its own NavigationStack(path:) while already
being pushed onto the outer NavigationStack from MainTabView, so sub-page
back buttons popped the outer stack and jumped past Settings to Menu.

Drop the nested NavigationStack and route Settings entries through the
ambient stack: a single SettingsRoute enum drives a navigationDestination
attached at the MoreMenuView root. The Settings entry itself becomes a
NavigationLink(value:) so it doesn't compete with a navigationDestination
(isPresented:) modifier, which was re-asserting Settings as the top of
stack whenever a sub-page was pushed.

* Harden post-storyboard migration

* MainViewController is now a strong static singleton bootstrapped from
  LoopFollowApp.init(). Lifecycle work in viewDidLoad (Combine sinks,
  observers, scheduleAllTasks, migrations) runs at launch regardless of
  whether the Home tab is rendered, and HomeContentView reuses the
  singleton instead of instantiating a fresh VC each time.

* MoreMenuView's eight .navigationDestination(isPresented:) modifiers are
  collapsed to a single MenuRoute enum routed through one
  .navigationDestination(for:), preventing the same destination-slot
  contention that previously caused Settings → Graph back navigation to
  jump past Settings.

* MainTabView observes Storage.shared.appearanceMode so theme changes
  propagate; the orphaned .appearanceDidChange notification name is
  removed.

* OPEN_APP_ACTION notification taps now dismiss any presented modal
  before switching to Home, matching prior SceneDelegate behavior.

* Drop the unused Core Data stack (NSPersistentCloudKitContainer,
  saveContext) from AppDelegate, the dead AppDelegate.window property,
  and the legacy UIRequiredDeviceCapabilities=armv7 /
  UIStatusBarTintParameters keys from Info.plist. Switch
  AlarmSound's keyWindow access to the connected-scenes API and
  generalize UIApplication.topMost likewise so it works on Mac
  Catalyst.

* Strip redundant inner NavigationView wrappers from settings sub-views
  pushed onto the outer NavigationStack: Graph, General, Advanced,
  Calendar, Contact, Dexcom, Nightscout, BackgroundRefresh,
  InfoDisplay, ImportExport. Drop unused onBack parameters from
  AlarmsContainerView and SettingsMenuView, the unused
  isPresentedAsModal flag from MainViewController, and the leftover
  debug print in ObservableValue.set.

* LineChartWrapper.updateUIView now flushes the chart on SwiftUI
  re-render. MainViewController.deinit removes all observers, not
  just the custom "refresh" one. MoreMenuView caches the app version
  in @State instead of constructing AppVersionManager on every body
  re-render. HomeModalView uses NavigationStack (not deprecated
  NavigationView).

* MoreMenuView: render tab-switch buttons in primary color

Buttons in a List inherit the accent tint, so the Features rows that switch tabs appeared blue while the NavigationLink rows that push appeared white. Use .buttonStyle(.plain) to suppress the tint and drop the now-redundant .foregroundStyle(.primary) calls.

* Revert MainViewController singleton bootstrap

Constructing MainViewController.shared from LoopFollowApp.init() — and
reusing the same VC across HomeContentView re-creations — caused tapping
the BG chart to crash with `-[__NSArrayM insertObject:atIndex:]: object
cannot be nil`. Bisected to the singleton+bootstrap piece of the
post-storyboard hardening; the rest of that commit (programmatic UI,
MoreMenuView routing, NavigationView strip-out) is retained.

Restore the prior behavior: shared is a weak static set in viewDidLoad,
HomeContentView constructs a fresh MainViewController each time, and the
LoopFollowApp.init() bootstrap is removed.

Known follow-up: lifecycle work in viewDidLoad (Combine sinks,
scheduleAllTasks, migrations) again only runs when the Home view is
first rendered, so a user who has moved Home off the tab bar gets
degraded behavior until they navigate to it.

* MoreMenuView: make tab-switch rows full-row tappable

Wrap Button labels in an HStack with a trailing Spacer and contentShape
so the entire row is tappable, matching the hit area of NavigationLink
rows. Extract the pattern into a small FullRowButton helper, used for
both tab-switch rows and Share Logs.

* Align units-selection conflict resolution with integration branch

Move the Diagnostics section out of Section("Speak BG") (was nested at
the wrong indent), match StatsDisplayModel field order, and add the
spacing line in updateStats.

* MoreMenuView: fix cross-row tap routing in Features section

Mixing Button and NavigationLink rows in the same List ForEach caused
taps on a NavigationLink row to fire a sibling Button row's action —
e.g. tapping Alarms with Stats in the tab bar would switch to the Stats
tab instead of pushing the alarms detail.

Make every row in the menu's List a uniform FullRowButton and drive
pushes from state via .navigationDestination(isPresented:). Add an
opt-in chevron to FullRowButton so navigating rows render the standard
disclosure indicator.

* MoreMenuView: keep Settings as a value-based NavigationLink

Mixing .navigationDestination(isPresented:) with
.navigationDestination(for:) on the same view shadowed the value-based
SettingsRoute registration once SettingsMenuView was on the stack, so
sub-rows like Units and Metrics couldn't push.

Settings sits alone in its section, so it doesn't need the uniform-Button
treatment used in Features and Logging — restore it to a NavigationLink
and route it through the existing .navigationDestination(for:) channel.

* Fix navigation between alarms and menu

* Drive Before-First-Unlock recovery from AppDelegate

Move BFU recovery (Storage.reloadAll) out of MainViewController and into
AppDelegate so it runs even when the home tab's UIHostingController has
not yet materialized — necessary under the SwiftUI App lifecycle, where a
BG-only launch (BGAppRefreshTask, BLE wake, prewarming) may complete and
the device may unlock without MainViewController ever being created.

AppDelegate observes protectedDataDidBecomeAvailable (authoritative signal)
and willEnterForeground (fallback), with a race-guard re-check immediately
after observer registration. Recovery is idempotent via needsBFUReload.

MainViewController now reacts to a new .bfuReloadCompleted notification by
showing the loading overlay and rescheduling tasks; if it is not alive when
the notification fires, its viewDidLoad will later see the already-reloaded
Storage values and schedule tasks correctly on first load.

* Keep MainViewController alive regardless of tab layout

Make MainViewController.shared a strong, long-lived singleton created
once via bootstrap() on first foreground, so the data pipeline, alarms
and background audio run even when Home is moved into the Menu rather
than a tab. Home views reuse the single instance instead of creating
new ones, so the singleton is never displaced.

Defer the one-shot BG graph zoom until the chart has a real frame and
re-render the graph on every appearance, so the curve stays visible
when Home is reached from the Menu or moved between tab bar and Menu.

Restore the one-time telemetry consent prompt that was lost when
SceneDelegate was removed, presenting it from MainTabView on first
appearance for undecided installs.

* Scale info data table text with Dynamic Type

Drive the info table font size and row height from @ScaledMetric so the
top-right info data grows and shrinks with the iPhone's text-size setting,
keeping the existing 17pt/21pt look at the default size. Cap the scaling at
accessibility1 so the compact top strip stays within its layout.

* Use two rows if needed

* Info table adjustments
Trio dev's APNS-based remote command support has merged to main, so the
Nightscout treatment-posting variant of remote commands is no longer
needed. Drop the .nightscout case from RemoteType, the Nightscout
remote view and controller, the unused NoRemoteView fallback, and all
.nightscout switch arms in the remote settings, import/export, and
device validation paths. Storage keys are reused by the TRC and
LoopAPNS variants and remain untouched.

Existing users with remoteType set to "Nightscout" automatically fall
back to .none on next launch: StorageValue's JSONDecoder fails on the
removed enum case and returns the default value.
Fixes #637.

The cone of uncertainty in updateOpenAPSPredictionDisplay() was capped
at the longest predBG array (.max()), which let the band visibly deform
at the tail as shorter arrays dropped out one by one.

Cap at the shortest array length instead so every cone point is computed
from the same set of contributing arrays. Matches Trio's ForecastSetup
(Trio/Sources/Modules/Home/HomeStateModel+Setup/ForecastSetup.swift),
which uses allForecastValues.map(\.count).min() and then iterates
0 ..< localMinCount.

Renamed maxLength to coneLength since the variable no longer represents
a max.
Add 'edited' to the lint workflow's pull_request trigger, guarded so the
SwiftFormat job only re-runs when the base branch actually changed.

Retargeting a PR (e.g. main -> dev) fires pull_request: edited, which the
default trigger types (opened, synchronize, reopened) ignore. When the new
base requires the SwiftFormat status check, GitHub leaves it 'Expected -
waiting for status to be reported' with no run behind it, blocking the PR
indefinitely. Re-running on base change ensures the required check actually
reports against the new base.
github-actions Bot and others added 4 commits June 17, 2026 19:47
The Nightscout remote command path was removed in #618, deleting the only
file under LoopFollow/Remote/Nightscout/. The now-empty PBXGroup was left
behind, still pointing at a folder with no contents. Remove it.
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ This PR targets the main branch. We do not accept PRs directly to main — please retarget your PR to the dev branch instead.

@bjorkert bjorkert self-requested a review June 21, 2026 16:01
@marionbarker marionbarker merged commit 9d2cea6 into main Jun 21, 2026
1 check passed
@marionbarker marionbarker deleted the release/v6.2.0 branch June 21, 2026 16:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants