From 87971dc8904a302cd655553720a7917b54959c1e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 05:47:00 +0000 Subject: [PATCH 1/5] Fall back to opening the sleep goal popover downward near the top of the screen Co-authored-by: Greg --- Bedtime/Bedtime/Views/SleepBankCard.swift | 42 ++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/Bedtime/Bedtime/Views/SleepBankCard.swift b/Bedtime/Bedtime/Views/SleepBankCard.swift index 034daa1..0bf149f 100644 --- a/Bedtime/Bedtime/Views/SleepBankCard.swift +++ b/Bedtime/Bedtime/Views/SleepBankCard.swift @@ -6,6 +6,7 @@ // import SwiftUI +import UIKit struct SleepBankCard: View { /// Balance over the selected range, which the Average / Status / Goal columns report. @@ -19,6 +20,12 @@ struct SleepBankCard: View { @Environment(\.durationDisplayStyle) private var durationStyle @State private var isEditingGoal = false + @State private var goalPopoverArrowEdge: Edge = .bottom + @State private var goalButtonFrame: CGRect = .zero + + /// Rough on-screen height of the goal popover's wheel picker plus its + /// popover chrome, used to decide whether there's room to open upward. + private static let goalPopoverEstimatedHeight: CGFloat = 180 private var formattedGoal: String { TimeFormatter.formatHours( @@ -117,6 +124,7 @@ struct SleepBankCard: View { private var goalColumn: some View { if let sleepGoalHours { Button { + goalPopoverArrowEdge = preferredGoalPopoverArrowEdge() isEditingGoal = true } label: { goalLabel(showsEditAffordance: true) @@ -125,7 +133,14 @@ struct SleepBankCard: View { .accessibilityLabel("Sleep goal") .accessibilityValue(formattedGoal) .accessibilityHint("Adjusts your nightly sleep goal") - .popover(isPresented: $isEditingGoal, arrowEdge: .bottom) { + .background( + GeometryReader { proxy in + Color.clear + .preference(key: GoalButtonFramePreferenceKey.self, value: proxy.frame(in: .global)) + } + ) + .onPreferenceChange(GoalButtonFramePreferenceKey.self) { goalButtonFrame = $0 } + .popover(isPresented: $isEditingGoal, arrowEdge: goalPopoverArrowEdge) { SleepGoalEditor(goalHours: sleepGoalHours) .frame(maxWidth: 150) .presentationCompactAdaptation(.popover) @@ -135,6 +150,22 @@ struct SleepBankCard: View { } } + /// Opens the popover downward (`.top` arrow edge) when there isn't enough room above the + /// goal button to fit it—e.g. when the card is scrolled near the top of the screen—and + /// upward (`.bottom` arrow edge, the preferred default) otherwise. + private func preferredGoalPopoverArrowEdge() -> Edge { + guard goalButtonFrame != .zero else { return .bottom } + + let screenHeight = UIScreen.main.bounds.height + let spaceAbove = goalButtonFrame.minY + let spaceBelow = screenHeight - goalButtonFrame.maxY + + if spaceAbove < Self.goalPopoverEstimatedHeight && spaceBelow > spaceAbove { + return .top + } + return .bottom + } + private func goalLabel(showsEditAffordance: Bool) -> some View { VStack(alignment: .trailing, spacing: 2) { Text("Goal") @@ -159,6 +190,15 @@ struct SleepBankCard: View { } } +/// Reports the goal button's frame (in global coordinates) so `SleepBankCard` can pick which +/// side to open its goal popover on based on available space. +private struct GoalButtonFramePreferenceKey: PreferenceKey { + static var defaultValue: CGRect = .zero + static func reduce(value: inout CGRect, nextValue: () -> CGRect) { + value = nextValue() + } +} + #if DEBUG /// Recomputes the bank from a fixed set of nights whenever the card changes the selected From 323a57fd18a46e07763c5bca68909572e13ecb44 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 06:00:41 +0000 Subject: [PATCH 2/5] Anchor goal popover to bottom-right corner and auto-fallback edge on iOS 18+ Co-authored-by: Greg --- Bedtime/Bedtime/Views/SleepBankCard.swift | 72 +++++++++++------------ 1 file changed, 34 insertions(+), 38 deletions(-) diff --git a/Bedtime/Bedtime/Views/SleepBankCard.swift b/Bedtime/Bedtime/Views/SleepBankCard.swift index 0bf149f..6657bc9 100644 --- a/Bedtime/Bedtime/Views/SleepBankCard.swift +++ b/Bedtime/Bedtime/Views/SleepBankCard.swift @@ -6,7 +6,6 @@ // import SwiftUI -import UIKit struct SleepBankCard: View { /// Balance over the selected range, which the Average / Status / Goal columns report. @@ -20,12 +19,6 @@ struct SleepBankCard: View { @Environment(\.durationDisplayStyle) private var durationStyle @State private var isEditingGoal = false - @State private var goalPopoverArrowEdge: Edge = .bottom - @State private var goalButtonFrame: CGRect = .zero - - /// Rough on-screen height of the goal popover's wheel picker plus its - /// popover chrome, used to decide whether there's room to open upward. - private static let goalPopoverEstimatedHeight: CGFloat = 180 private var formattedGoal: String { TimeFormatter.formatHours( @@ -124,7 +117,6 @@ struct SleepBankCard: View { private var goalColumn: some View { if let sleepGoalHours { Button { - goalPopoverArrowEdge = preferredGoalPopoverArrowEdge() isEditingGoal = true } label: { goalLabel(showsEditAffordance: true) @@ -133,14 +125,15 @@ struct SleepBankCard: View { .accessibilityLabel("Sleep goal") .accessibilityValue(formattedGoal) .accessibilityHint("Adjusts your nightly sleep goal") - .background( - GeometryReader { proxy in - Color.clear - .preference(key: GoalButtonFramePreferenceKey.self, value: proxy.frame(in: .global)) - } - ) - .onPreferenceChange(GoalButtonFramePreferenceKey.self) { goalButtonFrame = $0 } - .popover(isPresented: $isEditingGoal, arrowEdge: goalPopoverArrowEdge) { + // Anchored to the button's bottom-right corner (rather than the default + // center of its bottom edge) so the arrow lines up with the visible "Goal" + // value instead of pointing from the middle of the wide, trailing-aligned + // column it sits in. + .adaptivePopover( + isPresented: $isEditingGoal, + attachmentAnchor: .point(.bottomTrailing), + preferredArrowEdge: .bottom + ) { SleepGoalEditor(goalHours: sleepGoalHours) .frame(maxWidth: 150) .presentationCompactAdaptation(.popover) @@ -150,22 +143,6 @@ struct SleepBankCard: View { } } - /// Opens the popover downward (`.top` arrow edge) when there isn't enough room above the - /// goal button to fit it—e.g. when the card is scrolled near the top of the screen—and - /// upward (`.bottom` arrow edge, the preferred default) otherwise. - private func preferredGoalPopoverArrowEdge() -> Edge { - guard goalButtonFrame != .zero else { return .bottom } - - let screenHeight = UIScreen.main.bounds.height - let spaceAbove = goalButtonFrame.minY - let spaceBelow = screenHeight - goalButtonFrame.maxY - - if spaceAbove < Self.goalPopoverEstimatedHeight && spaceBelow > spaceAbove { - return .top - } - return .bottom - } - private func goalLabel(showsEditAffordance: Bool) -> some View { VStack(alignment: .trailing, spacing: 2) { Text("Goal") @@ -190,12 +167,31 @@ struct SleepBankCard: View { } } -/// Reports the goal button's frame (in global coordinates) so `SleepBankCard` can pick which -/// side to open its goal popover on based on available space. -private struct GoalButtonFramePreferenceKey: PreferenceKey { - static var defaultValue: CGRect = .zero - static func reduce(value: inout CGRect, nextValue: () -> CGRect) { - value = nextValue() +private extension View { + /// A `.popover` that prefers `preferredArrowEdge`/`attachmentAnchor` but, on iOS 18+, + /// lets the system pick whichever edge actually has room and reposition automatically + /// as the anchor scrolls—so it can fall back to a different side rather than clipping + /// when there isn't enough space on the preferred edge (e.g. near the top of the screen). + /// + /// iOS 17 treats `arrowEdge` as a hint and already repositions when it doesn't fit, but + /// iOS 18 enforces it strictly, which is why the two need separate handling here. + @ViewBuilder + func adaptivePopover( + isPresented: Binding, + attachmentAnchor: PopoverAttachmentAnchor, + preferredArrowEdge: Edge, + @ViewBuilder content: @escaping () -> Content + ) -> some View { + if #available(iOS 18, *) { + popover(isPresented: isPresented, attachmentAnchor: attachmentAnchor, content: content) + } else { + popover( + isPresented: isPresented, + attachmentAnchor: attachmentAnchor, + arrowEdge: preferredArrowEdge, + content: content + ) + } } } From bdf18397e8c7eb88aa34a7b4bdb2227a8461446b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 06:04:12 +0000 Subject: [PATCH 3/5] Always open goal popover above, anchored to its trailing corner Co-authored-by: Greg --- Bedtime/Bedtime/Views/SleepBankCard.swift | 40 ++++------------------- 1 file changed, 6 insertions(+), 34 deletions(-) diff --git a/Bedtime/Bedtime/Views/SleepBankCard.swift b/Bedtime/Bedtime/Views/SleepBankCard.swift index 6657bc9..a67497e 100644 --- a/Bedtime/Bedtime/Views/SleepBankCard.swift +++ b/Bedtime/Bedtime/Views/SleepBankCard.swift @@ -125,14 +125,14 @@ struct SleepBankCard: View { .accessibilityLabel("Sleep goal") .accessibilityValue(formattedGoal) .accessibilityHint("Adjusts your nightly sleep goal") - // Anchored to the button's bottom-right corner (rather than the default - // center of its bottom edge) so the arrow lines up with the visible "Goal" - // value instead of pointing from the middle of the wide, trailing-aligned - // column it sits in. - .adaptivePopover( + // Always opens above (arrowEdge .bottom) so it never covers the chart below, + // and is anchored to the button's bottom-right corner (rather than the default + // center of its bottom edge) so it extends up and to the left from there, + // covering the Average/Balance columns before it would ever reach the chart. + .popover( isPresented: $isEditingGoal, attachmentAnchor: .point(.bottomTrailing), - preferredArrowEdge: .bottom + arrowEdge: .bottom ) { SleepGoalEditor(goalHours: sleepGoalHours) .frame(maxWidth: 150) @@ -167,34 +167,6 @@ struct SleepBankCard: View { } } -private extension View { - /// A `.popover` that prefers `preferredArrowEdge`/`attachmentAnchor` but, on iOS 18+, - /// lets the system pick whichever edge actually has room and reposition automatically - /// as the anchor scrolls—so it can fall back to a different side rather than clipping - /// when there isn't enough space on the preferred edge (e.g. near the top of the screen). - /// - /// iOS 17 treats `arrowEdge` as a hint and already repositions when it doesn't fit, but - /// iOS 18 enforces it strictly, which is why the two need separate handling here. - @ViewBuilder - func adaptivePopover( - isPresented: Binding, - attachmentAnchor: PopoverAttachmentAnchor, - preferredArrowEdge: Edge, - @ViewBuilder content: @escaping () -> Content - ) -> some View { - if #available(iOS 18, *) { - popover(isPresented: isPresented, attachmentAnchor: attachmentAnchor, content: content) - } else { - popover( - isPresented: isPresented, - attachmentAnchor: attachmentAnchor, - arrowEdge: preferredArrowEdge, - content: content - ) - } - } -} - #if DEBUG /// Recomputes the bank from a fixed set of nights whenever the card changes the selected From 8f4456eaf5d91960f293eab7763322e9653ce476 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 06:13:59 +0000 Subject: [PATCH 4/5] Revert to default popover anchor; corner anchor looked worse Co-authored-by: Greg --- Bedtime/Bedtime/Views/SleepBankCard.swift | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/Bedtime/Bedtime/Views/SleepBankCard.swift b/Bedtime/Bedtime/Views/SleepBankCard.swift index a67497e..38b5be5 100644 --- a/Bedtime/Bedtime/Views/SleepBankCard.swift +++ b/Bedtime/Bedtime/Views/SleepBankCard.swift @@ -125,15 +125,8 @@ struct SleepBankCard: View { .accessibilityLabel("Sleep goal") .accessibilityValue(formattedGoal) .accessibilityHint("Adjusts your nightly sleep goal") - // Always opens above (arrowEdge .bottom) so it never covers the chart below, - // and is anchored to the button's bottom-right corner (rather than the default - // center of its bottom edge) so it extends up and to the left from there, - // covering the Average/Balance columns before it would ever reach the chart. - .popover( - isPresented: $isEditingGoal, - attachmentAnchor: .point(.bottomTrailing), - arrowEdge: .bottom - ) { + // Always opens above (arrowEdge .bottom) so it never covers the chart below. + .popover(isPresented: $isEditingGoal, arrowEdge: .bottom) { SleepGoalEditor(goalHours: sleepGoalHours) .frame(maxWidth: 150) .presentationCompactAdaptation(.popover) From e0e6052ee43fab7bbf2a6917cc107278f96682c8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 06:36:11 +0000 Subject: [PATCH 5/5] Track goal button position with a named scroll coordinate space The previous GeometryReader+.global+PreferenceKey approach didn't reliably update during interactive scrolling, so the computed arrow edge was based on a stale position (effectively wherever the button was when the view last re-rendered, not its actual current on-screen position). Using a named coordinate space anchored to the main ScrollView's own (non-scrolling) viewport, read via onGeometryChange, tracks the button's position within that viewport correctly and continuously as the user scrolls. Co-authored-by: Greg --- Bedtime/Bedtime/Constants.swift | 6 +++ Bedtime/Bedtime/ContentView.swift | 8 ++++ Bedtime/Bedtime/Views/SleepBankCard.swift | 55 ++++++++++++++++++++++- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/Bedtime/Bedtime/Constants.swift b/Bedtime/Bedtime/Constants.swift index 358a686..529362e 100644 --- a/Bedtime/Bedtime/Constants.swift +++ b/Bedtime/Bedtime/Constants.swift @@ -61,4 +61,10 @@ class Constants { static func sleepDurationColor(hours: Double, goal: Double, graceColor: Color) -> Color { sleepGoalColor(difference: hours - goal, graceColor: graceColor) } + + /// Named coordinate space for the app's main scroll view. Descendants can measure their + /// position relative to it (`.named(mainScrollCoordinateSpaceName)`) to reliably track how + /// far they are from the top/bottom of the visible viewport as the user scrolls—unlike + /// `.global` frames, which don't update consistently during interactive scrolling. + static let mainScrollCoordinateSpaceName = "mainScrollView" } diff --git a/Bedtime/Bedtime/ContentView.swift b/Bedtime/Bedtime/ContentView.swift index c3044d4..4f756d6 100644 --- a/Bedtime/Bedtime/ContentView.swift +++ b/Bedtime/Bedtime/ContentView.swift @@ -18,6 +18,7 @@ struct ContentView: View { @State private var showingSettings = false @State private var showingError = false @State private var error: Error? + @State private var scrollViewportSize: CGSize = .zero init() { let sourcePrefs = SourcePreferences() @@ -187,6 +188,13 @@ struct ContentView: View { .frame(maxWidth: 600) .frame(maxWidth: .infinity) } + .coordinateSpace(name: Constants.mainScrollCoordinateSpaceName) + .onGeometryChange(for: CGSize.self) { proxy in + proxy.size + } action: { _, newValue in + scrollViewportSize = newValue + } + .environment(\.scrollViewportSize, scrollViewportSize) .environment(\.durationDisplayStyle, userPreferences.durationDisplayStyle) .background(Color.backgroundBehindCards) .navigationTitle("Bedger") diff --git a/Bedtime/Bedtime/Views/SleepBankCard.swift b/Bedtime/Bedtime/Views/SleepBankCard.swift index 38b5be5..212c236 100644 --- a/Bedtime/Bedtime/Views/SleepBankCard.swift +++ b/Bedtime/Bedtime/Views/SleepBankCard.swift @@ -7,6 +7,20 @@ import SwiftUI +private struct ScrollViewportSizeKey: EnvironmentKey { + static let defaultValue: CGSize = .zero +} + +extension EnvironmentValues { + /// Size of the main scroll view's viewport, published by `ContentView` via + /// `onGeometryChange`. Lets a card reason about how much room is above/below it on + /// screen without relying on `.global` frames, which don't track reliably during scroll. + var scrollViewportSize: CGSize { + get { self[ScrollViewportSizeKey.self] } + set { self[ScrollViewportSizeKey.self] = newValue } + } +} + struct SleepBankCard: View { /// Balance over the selected range, which the Average / Status / Goal columns report. let sleepBank: SleepBank @@ -17,8 +31,18 @@ struct SleepBankCard: View { /// When provided, the goal is tappable and edits this value directly. var sleepGoalHours: Binding? = nil @Environment(\.durationDisplayStyle) private var durationStyle + @Environment(\.scrollViewportSize) private var scrollViewportSize @State private var isEditingGoal = false + /// The goal button's frame relative to the main scroll view's viewport, kept up to date + /// by `onGeometryChange` (including while the user is actively scrolling). + @State private var goalButtonFrame: CGRect = .zero + + /// Rough on-screen height of the goal popover's wheel picker plus its popover chrome, + /// used to decide whether there's enough room above the button to open upward. The wheel + /// picker itself is a fixed system height (~190pt even after the editor's negative + /// padding), so this pads that out a bit for the popover's own arrow/margins. + private static let goalPopoverEstimatedHeight: CGFloat = 210 private var formattedGoal: String { TimeFormatter.formatHours( @@ -125,8 +149,15 @@ struct SleepBankCard: View { .accessibilityLabel("Sleep goal") .accessibilityValue(formattedGoal) .accessibilityHint("Adjusts your nightly sleep goal") - // Always opens above (arrowEdge .bottom) so it never covers the chart below. - .popover(isPresented: $isEditingGoal, arrowEdge: .bottom) { + .onGeometryChange(for: CGRect.self) { proxy in + proxy.frame(in: .named(Constants.mainScrollCoordinateSpaceName)) + } action: { _, newValue in + goalButtonFrame = newValue + } + // Prefers opening above (arrowEdge .bottom) so it never covers the chart below, + // falling back to opening below only when there truly isn't room above—e.g. when + // this card is scrolled near the very top of the screen. + .popover(isPresented: $isEditingGoal, arrowEdge: goalPopoverArrowEdge) { SleepGoalEditor(goalHours: sleepGoalHours) .frame(maxWidth: 150) .presentationCompactAdaptation(.popover) @@ -136,6 +167,18 @@ struct SleepBankCard: View { } } + private var goalPopoverArrowEdge: Edge { + guard scrollViewportSize != .zero, goalButtonFrame != .zero else { return .bottom } + + let spaceAbove = goalButtonFrame.minY + let spaceBelow = scrollViewportSize.height - goalButtonFrame.maxY + + if spaceAbove < Self.goalPopoverEstimatedHeight && spaceBelow > spaceAbove { + return .top + } + return .bottom + } + private func goalLabel(showsEditAffordance: Bool) -> some View { VStack(alignment: .trailing, spacing: 2) { Text("Goal") @@ -170,6 +213,7 @@ private struct SleepBankCardPreview: View { @State private var days = 7 @State private var goalHours = 8.0 + @State private var scrollViewportSize: CGSize = .zero private var allNights: [NightSummary] { let calendar = Calendar.current @@ -205,6 +249,13 @@ private struct SleepBankCardPreview: View { ) .padding() } + .coordinateSpace(name: Constants.mainScrollCoordinateSpaceName) + .onGeometryChange(for: CGSize.self) { proxy in + proxy.size + } action: { _, newValue in + scrollViewportSize = newValue + } + .environment(\.scrollViewportSize, scrollViewportSize) .background(Color.backgroundBehindCards) } }