Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Bedtime/Bedtime/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
8 changes: 8 additions & 0 deletions Bedtime/Bedtime/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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")
Expand Down
54 changes: 53 additions & 1 deletion Bedtime/Bedtime/Views/SleepBankCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,8 +31,18 @@ struct SleepBankCard: View {
/// When provided, the goal is tappable and edits this value directly.
var sleepGoalHours: Binding<Double>? = 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(
Expand Down Expand Up @@ -125,7 +149,15 @@ struct SleepBankCard: View {
.accessibilityLabel("Sleep goal")
.accessibilityValue(formattedGoal)
.accessibilityHint("Adjusts your nightly sleep goal")
.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)
Expand All @@ -135,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")
Expand Down Expand Up @@ -169,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
Expand Down Expand Up @@ -204,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)
}
}
Expand Down