Remove storyboard, migrate to SwiftUI layout#608
Conversation
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.
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.
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().
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.
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.
- 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
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.
Pass MainViewController.shared instead of nil when creating AggregatedStatsContentView in MainTabView and MoreMenuView. Replace view-hierarchy-walking getMainViewController() in TreatmentsViewModel with MainViewController.shared.
The storyboard used system 17pt for both title and detail labels. The SwiftUI migration used .subheadline (~15pt) making text smaller.
Present UIActivityViewController via UIApplication.topMost instead of wrapping it in a SwiftUI .sheet, which rendered an empty view.
# Conflicts: # LoopFollow/Application/SceneDelegate.swift
# Conflicts: # LoopFollow.xcodeproj/project.pbxproj # LoopFollow/Remote/RemoteViewController.swift
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.
Port of debug-log-default-on-with-share-notice (PR #627) onto the integration branch where MoreMenuViewController.swift no longer exists (removed by PR #608). The Share Logs flow lives in MoreMenuView.swift now, so the notice prompt and ShareNotice file writer are wired there instead. Migration step renamed from 8 to 9 because step 8 is already taken by the timeInRangeMode migration in this branch.
* 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).
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.
# Conflicts: # LoopFollow/Settings/GraphSettingsView.swift
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.
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.
# Conflicts: # LoopFollow/Application/Base.lproj/Main.storyboard # LoopFollow/Application/SceneDelegate.swift # LoopFollow/Controllers/MainViewController+updateStats.swift # LoopFollow/Nightscout/NightscoutSettingsView.swift # LoopFollow/Settings/DexcomSettingsView.swift # LoopFollow/Settings/GeneralSettingsView.swift # LoopFollow/Settings/GraphSettingsView.swift # LoopFollow/Settings/SettingsMenuView.swift # LoopFollow/ViewControllers/MainViewController.swift
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.
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.
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.
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.
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.
# Conflicts: # LoopFollow/ViewControllers/MainViewController.swift
- 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.
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.
TestLast time I tested was 29 April 2026 and at that time I had issues building and issues returning to prior version after building the remove-storyboard code. The only way to build dev version again was to delete the app completely. I sent the information to @bjorkert via DM. He says the problems have been resolved, so testing again. Test Summary✅ building remove-storyboard over dev works with no issues
ConfigurationTwo test phones:
Test Round Tripbuild PR 608Each phone was working as expected.
build dev
build PR 608
Final Round Triprepeat the build using dev - be sure to clean build folder. return to PR 608 code, don't bother with clean build folder Build from Test FlightInstall a TestFlight build of LoopFollow 6.10.0 over a remove-storyboard version.
|
Summary
Removes Main.storyboard and SceneDelegate, replacing the entire UI lifecycle with SwiftUI. The home screen layout (BG display, info table, charts, stats) is now composed in a single SwiftUI view (
MainHomeView) with the DGChartsLineChartViewinstances embedded viaUIViewRepresentable.What changed:
LoopFollowApp(SwiftUI App) andMainTabViewUITableViewto SwiftUIListbacked byInfoManageras anObservableObjectPieChartViewviaUIViewRepresentablesince the Charts pod shadows Swift Charts).refreshableUIStackViewhierarchy toMainHomeViewSwiftUI composition — visibility of info table, small graph, and stats is now reactive via Storage observablesgetMainViewController()hierarchy-walking methods replaced withMainViewController.sharedweak referencetabBarControllerreferences, redundant appearance notification relay, unusedUIViewExtensionNet result: −1430 lines, all UIKit view controllers except
MainViewController(charts) andNightscoutViewController(web view) are gone.