From 39825e57712e199b05140e79837353d98a3f5ebe Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sat, 24 Oct 2026 12:39:19 -0500 Subject: [PATCH 1/3] Status screen layout for iPhone Duo's inner display Landscape mode (charts only, no HUD or status row) was keyed to the screen being wider than tall. The unfolded inner display is wider than tall but has regular height, so after a fold/unfold the HUD and status row disappeared. Key it to compact vertical size class instead. Chart row heights were sized from the longer screen dimension, which only makes sense in phone landscape; elsewhere use the table height so the charts fit the inner display. In inner landscape the status bar is vertical, so there is no top safe area and the first row met the display corners. The table is now inset by the corner-adapted safe area, updated when the safe area changes and scrolled back to the top if it was there. The warning banners instead run to the top edge (the display corners round them) with their content inset by that amount. dev's toolbar is a system toolbar, which already moves to the vertical bar on the trailing edge, so it needs no change. --- .../StatusTableViewController.swift | 66 ++++++++++++++++--- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 580d67eb8a..c59acb5aab 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -140,7 +140,7 @@ final class StatusTableViewController: LoopChartsTableViewController { tableView.estimatedRowHeight = 74 // Estimate an initial value - landscapeMode = UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height + landscapeMode = Self.isCompactHeight addScenarioStepGestureRecognizers() @@ -210,6 +210,11 @@ final class StatusTableViewController: LoopChartsTableViewController { } } + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + updateTopContentInset() + } + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { refreshContext.update(with: .size(size)) @@ -350,9 +355,23 @@ final class StatusTableViewController: LoopChartsTableViewController { } } - // Toggles the display mode based on the screen aspect ratio. Should not be updated outside of reloadData(). + // Charts only, for compact height (an iPhone in landscape). Should not be updated outside of reloadData(). private var landscapeMode = false + private static var isCompactHeight: Bool { + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + let scene = scenes.first { $0.windows.contains { $0.isKeyWindow } } + ?? scenes.first { $0.activationState == .foregroundActive } + ?? scenes.first + return scene?.traitCollection.verticalSizeClass == .compact + } + + // With a vertical status bar there is no top safe area inset, so keep the first row clear of the display corners. + private var cornerClearanceTopInset: CGFloat { + guard #available(iOS 26.0, *), let window = view.window else { return 0 } + return max(0, window.edgeInsets(for: .safeArea(cornerAdaptation: .vertical)).top - window.safeAreaInsets.top) + } + private var lastLoopError: Error? private var reloading = false @@ -363,6 +382,16 @@ final class StatusTableViewController: LoopChartsTableViewController { return !landscapeMode } + private func updateTopContentInset() { + let top = shouldShowBannerWarning ? 0 : cornerClearanceTopInset + guard tableView.contentInset.top != top else { return } + let wasAtTop = tableView.contentOffset.y <= -tableView.adjustedContentInset.top + tableView.contentInset.top = top + if wasAtTop { + tableView.contentOffset.y = -tableView.adjustedContentInset.top + } + } + private var shouldShowStatus: Bool { return !landscapeMode && statusRowMode.hasRow } @@ -792,6 +821,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } else { tableView.reloadRows(at: [IndexPath(row: 0, section: Section.alertWarning.rawValue)], with: .none) } + updateTopContentInset() } private func updateBannerAndHUDandStatusRows(statusRowMode: StatusRowMode, newSize: CGSize?, animated: Bool) { @@ -802,8 +832,8 @@ final class StatusTableViewController: LoopChartsTableViewController { self.statusRowMode = statusRowMode - if let newSize = newSize { - landscapeMode = newSize.width > newSize.height + if newSize != nil { + landscapeMode = traitCollection.verticalSizeClass == .compact } let hudIsVisible = self.shouldShowHUD @@ -923,6 +953,8 @@ final class StatusTableViewController: LoopChartsTableViewController { } private class AlertPermissionsDisabledWarningCell: UITableViewCell { + var fullBleedTopInset: CGFloat? + override func updateConfiguration(using state: UICellConfigurationState) { super.updateConfiguration(using: state) @@ -948,18 +980,22 @@ final class StatusTableViewController: LoopChartsTableViewController { backgroundConfiguration = backgroundConfig backgroundConfiguration?.backgroundInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 5, trailing: 10) backgroundConfiguration?.cornerRadius = 10 + if fullBleedTopInset != nil { + applyFullBleedBackground() + } let disclosureIndicator = UIImage(systemName: "chevron.right")?.withTintColor(.white) let imageView = UIImageView(image: disclosureIndicator) imageView.tintColor = .white accessoryView = imageView - contentView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 6, leading: 0, bottom: 13, trailing: 0) + contentView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: (fullBleedTopInset ?? 0) + 6, leading: 0, bottom: 13, trailing: 0) } } private class MuteAlertsWarningCell: UITableViewCell { var formattedAlertMuteEndTime: String = NSLocalizedString("Unknown", comment: "label for when the alert mute end time is unknown") + var fullBleedTopInset: CGFloat? fileprivate class GradientView: UIView { override static var layerClass: AnyClass { CAGradientLayer.self } @@ -992,6 +1028,9 @@ final class StatusTableViewController: LoopChartsTableViewController { backgroundConfiguration = backgroundConfig backgroundConfiguration?.backgroundInsets = NSDirectionalEdgeInsets(top: 0, leading: 5, bottom: 5, trailing: 5) backgroundConfiguration?.cornerRadius = 10 + if fullBleedTopInset != nil { + applyFullBleedBackground() + } let unmuteIndicator = UIImage(systemName: "stop.circle")?.withTintColor(.white) let imageView = UIImageView(image: unmuteIndicator) @@ -999,19 +1038,22 @@ final class StatusTableViewController: LoopChartsTableViewController { imageView.frame.size = CGSize(width: 30, height: 30) accessoryView = imageView - contentView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 6, leading: 0, bottom: 13, trailing: 0) + contentView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: (fullBleedTopInset ?? 0) + 6, leading: 0, bottom: 13, trailing: 0) } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { switch Section(rawValue: indexPath.section)! { case .alertWarning: + let fullBleedTopInset: CGFloat? = cornerClearanceTopInset > 0 ? cornerClearanceTopInset : nil if alertPermissionsChecker.showWarning { let cell = tableView.dequeueReusableCell(withIdentifier: AlertPermissionsDisabledWarningCell.className, for: indexPath) as! AlertPermissionsDisabledWarningCell + cell.fullBleedTopInset = fullBleedTopInset return cell } else { let cell = tableView.dequeueReusableCell(withIdentifier: MuteAlertsWarningCell.className, for: indexPath) as! MuteAlertsWarningCell cell.formattedAlertMuteEndTime = alertMuter.formattedEndTime + cell.fullBleedTopInset = fullBleedTopInset cell.selectionStyle = .none return cell } @@ -1219,8 +1261,8 @@ final class StatusTableViewController: LoopChartsTableViewController { case .charts: // Compute the height of the HUD, defaulting to 70 let hudHeight = ceil(hudView?.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height ?? 74) - var availableSize = max(tableView.bounds.width, tableView.bounds.height) - availableSize -= (tableView.safeAreaInsets.top + tableView.safeAreaInsets.bottom + hudHeight) + var availableSize = landscapeMode ? max(tableView.bounds.width, tableView.bounds.height) : tableView.bounds.height + availableSize -= (tableView.safeAreaInsets.top + tableView.safeAreaInsets.bottom + tableView.contentInset.top + hudHeight) switch ChartRow(rawValue: indexPath.row)! { case .glucose: @@ -2258,3 +2300,11 @@ extension StatusTableViewController: ServicesViewModelDelegate { show(settingsViewController, sender: self) } } + +private extension UITableViewCell { + // Without a top status bar, the banner runs to the top edge and the display corners round it. + func applyFullBleedBackground() { + backgroundConfiguration?.backgroundInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 5, trailing: 0) + backgroundConfiguration?.cornerRadius = 0 + } +} From 842272abac2313f8d015c7aa5e300ba77fac4904 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 29 Oct 2026 00:17:36 -0500 Subject: [PATCH 2/3] Match the HUD cell background to the HUD view The HUD view uses secondarySystemBackground but stops at the safe area, while the cell's default white background spans the full table width. On iPhone Duo's inner display the status bar is a trailing column, so the white cell background showed as a box beside the time and Wi-Fi status. --- Loop/Views/HUDViewTableViewCell.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Loop/Views/HUDViewTableViewCell.swift b/Loop/Views/HUDViewTableViewCell.swift index 7dc949cfe7..57efeed68f 100644 --- a/Loop/Views/HUDViewTableViewCell.swift +++ b/Loop/Views/HUDViewTableViewCell.swift @@ -12,4 +12,8 @@ class HUDViewTableViewCell: UITableViewCell { @IBOutlet var hudView: StatusBarHUDView! + override func awakeFromNib() { + super.awakeFromNib() + backgroundColor = .secondarySystemBackground + } } From 49814a2118c9aba3caa9fb49f9ffd225a87104b0 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 29 Oct 2026 00:44:23 -0500 Subject: [PATCH 3/3] Cap the HUD row width on wide displays On iPhone Duo's inner display the HUD stack view stretched across about 900 pt, and the CGM pill's corner sat against the display's much larger corner curve. Limit the row to 500 pt, centered; every iPhone in portrait is narrower than that, so phones are unchanged. --- LoopUI/Views/StatusBarHUDView.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/LoopUI/Views/StatusBarHUDView.swift b/LoopUI/Views/StatusBarHUDView.swift index 3bd851e2e2..927a5a983c 100644 --- a/LoopUI/Views/StatusBarHUDView.swift +++ b/LoopUI/Views/StatusBarHUDView.swift @@ -51,11 +51,15 @@ public class StatusBarHUDView: UIView, NibLoadable { containerView.translatesAutoresizingMaskIntoConstraints = false self.addSubview(containerView) - // Use AutoLayout to have the stack view fill its entire container. + // Fill the container, up to a phone-like width on wide displays such as iPhone Duo's inner display. + let fillWidth = containerView.widthAnchor.constraint(equalTo: widthAnchor) + fillWidth.priority = .defaultHigh NSLayoutConstraint.activate([ containerView.centerXAnchor.constraint(equalTo: centerXAnchor), containerView.centerYAnchor.constraint(equalTo: centerYAnchor), - containerView.widthAnchor.constraint(equalTo: widthAnchor), + fillWidth, + containerView.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor), + containerView.widthAnchor.constraint(lessThanOrEqualToConstant: 500), containerView.heightAnchor.constraint(equalTo: heightAnchor), ])