diff --git a/PulseLoop/Views/SleepView.swift b/PulseLoop/Views/SleepView.swift index 369c301..a80189b 100644 --- a/PulseLoop/Views/SleepView.swift +++ b/PulseLoop/Views/SleepView.swift @@ -10,6 +10,14 @@ struct SleepView: View { @State private var coachStore = CoachSettingsStore.shared @State private var selectedSleepPage = 0 @State private var scrolledSleepPage: Int? + /// 0 = today's reference night; N = N days earlier. Drives the Day-view date stepper. + @State private var dayOffset = 0 + @State private var showDayPicker = false + /// Edge the incoming day's content pushes in from (leading = stepping back, trailing = forward). + @State private var dayNavEdge: Edge = .trailing + /// Observed so the tab re-fetches when a background sync writes new sleep data (matches + /// Today/Vitals). Without it, `sleepRange` is a plain call the body never re-runs on sync. + @State private var dataChange = PulseDataChange.shared init() { let raw = UserDefaults.standard.string(forKey: "startSleepRange") @@ -27,7 +35,12 @@ struct SleepView: View { } var body: some View { - let summary = SleepService.sleepRange(range, context: modelContext) + // Day view is anchored on a selectable night: shift `now` back `dayOffset` days and let + // `sleepRange`/`dayReferenceNight` apply the usual 4 AM "last night" flip. Week/Month/Year + // keep their own anchor (dayOffset is ignored there). + let _ = dataChange.token // subscribe: re-run the body (and re-fetch below) on each synced batch + let effectiveNow = Calendar.current.date(byAdding: .day, value: -dayOffset, to: Date()) ?? Date() + let summary = SleepService.sleepRange(range, context: modelContext, now: range == .day ? effectiveNow : Date()) let goalMin = MetricsRepository.goals(context: modelContext)?.sleepMinutes let activitySteps = MetricsRepository.latestActivity(context: modelContext)?.steps @@ -36,7 +49,10 @@ struct SleepView: View { SleepRangeSelectorView(selection: $range) if range == .day { - dayView(summary: summary, activitySteps: activitySteps) + dayNavHeader(shownDay: SleepService.dayReferenceNight(now: effectiveNow)) + dayView(summary: summary, activitySteps: activitySteps, isToday: dayOffset == 0) + .id(dayOffset) + .transition(reduceMotion ? .opacity : .push(from: dayNavEdge)) } else { aggregateView(summary: summary, goalMin: goalMin) } @@ -47,10 +63,22 @@ struct SleepView: View { .background(PulseColors.background) .refreshable { await coordinator.pullToRefresh() } .pulseScrollEdges() - .task(id: range) { + .onChange(of: range) { _, newRange in + // Returning to Day from an aggregate range always lands on today, not a stale offset. + if newRange == .day { resetToToday() } + } + .sensoryFeedback(.selection, trigger: dayOffset) + .onChange(of: dayOffset) { _, newOffset in announceDay(newOffset) } + .sheet(isPresented: $showDayPicker) { dayPickerSheet } + .task(id: "\(range.rawValue)-\(dayOffset)") { guard coachEnabled else { return } - if range == .day { await summaryService.refreshSleepDayIfNeeded() } - else { await summaryService.refreshSleepRangeIfNeeded(range) } + if range == .day { + // Only the live "last night" gets an LLM summary refresh; browsing history never + // regenerates/clobbers today's summary. + if dayOffset == 0 { await summaryService.refreshSleepDayIfNeeded() } + } else { + await summaryService.refreshSleepRangeIfNeeded(range) + } } } @@ -74,7 +102,7 @@ struct SleepView: View { // MARK: Day @ViewBuilder - private func dayView(summary: SleepRangeSummary, activitySteps: Int?) -> some View { + private func dayView(summary: SleepRangeSummary, activitySteps: Int?, isToday: Bool) -> some View { let sessions = SleepInsights.validSessions(summary.sessions).sorted { $0.session.startAt < $1.session.startAt } if sessions.isEmpty { let noData = SleepInsights.noDataState(.day) @@ -99,7 +127,9 @@ struct SleepView: View { } else { sleepCarousel(sessions: sessions) } - summaryCard(daySummary, fallback: dayFallback) + // The LLM day summary describes last night only; on a past day fall back to the + // scripted coach (deterministically computed from that day's own primary session). + summaryCard(isToday ? daySummary : nil, fallback: dayFallback) } } @@ -157,6 +187,23 @@ struct SleepView: View { selectedSleepPage = 0 scrolledSleepPage = 0 } + // VoiceOver can't reach off-screen carousel pages via horizontal swipe, so expose the + // carousel as a single adjustable element: swipe up/down steps sessions and scrolls to them. + .accessibilityElement(children: .contain) + .accessibilityLabel("Sleep session") + .accessibilityValue(sessionCarouselA11yValue(sessions: sessions)) + .accessibilityAdjustableAction { direction in + let current = min(max(0, selectedSleepPage), sessions.count - 1) + let next: Int + switch direction { + case .increment: next = min(sessions.count - 1, current + 1) + case .decrement: next = max(0, current - 1) + @unknown default: next = current + } + guard next != selectedSleepPage else { return } + selectedSleepPage = next + withAnimation(reduceMotion ? nil : PulseMotion.spring) { scrolledSleepPage = next } + } // Page indicator dots. HStack(spacing: 8) { @@ -169,6 +216,191 @@ struct SleepView: View { } .frame(maxWidth: .infinity) .padding(.top, 4) + .accessibilityHidden(true) + } + + /// VoiceOver value for the adjustable carousel: "2 of 3, 1:20 PM to 2:05 PM". + private func sessionCarouselA11yValue(sessions: [SleepSummary]) -> String { + guard !sessions.isEmpty else { return "" } + let idx = min(max(0, selectedSleepPage), sessions.count - 1) + let s = sessions[idx] + return "\(idx + 1) of \(sessions.count), \(SleepFormat.clockTime(s.session.startAt)) to \(SleepFormat.clockTime(s.session.endAt))" + } + + // MARK: Day navigation + + /// The date stepper above the Day content: ‹ older · date · newer ›. Horizontal swipe is owned + /// by the session carousel, so days move only via these taps / the date picker. + @ViewBuilder + private func dayNavHeader(shownDay: Date) -> some View { + let labels = dayLabels(shownDay) + HStack(spacing: 8) { + chevronButton("chevron.left", enabled: dayOffset < maxDayOffset, label: "Previous day", + disabledHint: "Earliest recorded sleep") { stepDay(older: true) } + Spacer(minLength: 4) + Button { showDayPicker = true } label: { + VStack(spacing: 1) { + HStack(spacing: 4) { + Text(labels.primary) + .font(PulseFont.headline) + .foregroundStyle(PulseColors.textPrimary) + .contentTransition(.opacity) + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(PulseColors.textMuted) + } + // Always render the subtitle line (a space when empty) so the hero card never + // shifts vertically as you cross the Today/Yesterday/weekday label boundaries. + Text(labels.secondary.isEmpty ? " " : labels.secondary) + .font(PulseFont.caption) + .foregroundStyle(PulseColors.textSecondary) + .textCase(.uppercase) + .kerning(0.5) + .contentTransition(.opacity) + } + .frame(minHeight: 44) + .fixedSize(horizontal: false, vertical: true) + .contentShape(Rectangle()) + .animation(reduceMotion ? nil : PulseMotion.spring, value: dayOffset) + } + .buttonStyle(.plain) + .accessibilityElement(children: .ignore) + .accessibilityAddTraits([.isButton, .isHeader]) + .accessibilityLabel(voiceLabel(shownDay)) + .accessibilityHint("Opens a date picker to choose a day") + Spacer(minLength: 4) + chevronButton("chevron.right", enabled: dayOffset > 0, label: "Next day", + disabledHint: "You're viewing the most recent day") { stepDay(older: false) } + } + .frame(maxWidth: .infinity) + } + + /// A ≥44pt tappable chevron; glass disc only while enabled, muted glyph when at a bound. + @ViewBuilder + private func chevronButton(_ system: String, enabled: Bool, label: String, + disabledHint: String? = nil, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: system) + .font(PulseFont.headline.weight(.semibold)) + .foregroundStyle(enabled ? PulseColors.textPrimary : PulseColors.textMuted) + .frame(width: 44, height: 44) + .background { + if enabled { + Circle().fill(Color.clear).frame(width: 36, height: 36).pulseGlass(Circle(), interactive: true) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.pulseTap) + .disabled(!enabled) + .accessibilityLabel(label) + .accessibilityHint(enabled ? "" : (disabledHint ?? "")) + } + + /// Graphical date picker bounded to [oldest browsable day ... today]. + private var dayPickerSheet: some View { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + let selection = Binding( + get: { calendar.date(byAdding: .day, value: -dayOffset, to: today) ?? today }, + set: { setDay(to: $0) } + ) + return NavigationStack { + DatePicker("Day", selection: selection, in: floorDay...today, displayedComponents: .date) + .datePickerStyle(.graphical) + .padding() + .navigationTitle("Choose a day") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { showDayPicker = false } + } + } + } + .presentationDetents([.medium, .large]) + } + + /// Earliest recorded night (sessions are sorted ascending by date), start-of-day. + private var earliestDataDay: Date? { + SleepRepository.sessions(context: modelContext).first.map { Calendar.current.startOfDay(for: $0.date) } + } + /// How many days back the stepper may travel: up to one week before the earliest recorded + /// night, clamped to a 1-year floor. 0 (locked to today) when there's no data at all. + private var maxDayOffset: Int { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + guard let earliest = earliestDataDay else { return 0 } + let earliestMinus7 = calendar.date(byAdding: .day, value: -7, to: earliest) ?? earliest + let yearAgo = calendar.date(byAdding: .day, value: -365, to: today) ?? today + let floor = max(earliestMinus7, yearAgo) // the more-recent of the two bounds + return max(0, calendar.dateComponents([.day], from: floor, to: today).day ?? 0) + } + private var floorDay: Date { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + return calendar.date(byAdding: .day, value: -maxDayOffset, to: today) ?? today + } + + private func resetToToday() { + dayOffset = 0 + selectedSleepPage = 0 + scrolledSleepPage = nil + } + private func stepDay(older: Bool) { + let target = older ? dayOffset + 1 : dayOffset - 1 + let clamped = min(maxDayOffset, max(0, target)) + guard clamped != dayOffset else { return } + dayNavEdge = older ? .leading : .trailing + withAnimation(reduceMotion ? nil : PulseMotion.spring) { dayOffset = clamped } + selectedSleepPage = 0 + scrolledSleepPage = nil + } + private func setDay(to date: Date) { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + let offset = calendar.dateComponents([.day], from: calendar.startOfDay(for: date), to: today).day ?? 0 + let clamped = min(maxDayOffset, max(0, offset)) + dayNavEdge = clamped > dayOffset ? .leading : .trailing // further back = enters from leading + withAnimation(reduceMotion ? nil : PulseMotion.spring) { dayOffset = clamped } + selectedSleepPage = 0 + scrolledSleepPage = nil + showDayPicker = false + } + + /// Primary (relative) + secondary (absolute) date strings for the header label. + private func dayLabels(_ shownDay: Date) -> (primary: String, secondary: String) { + let calendar = Calendar.current + let absolute = shownDay.formatted(.dateTime.weekday(.abbreviated).month(.abbreviated).day()) + if calendar.isDateInToday(shownDay) { return ("Today", absolute) } + if calendar.isDateInYesterday(shownDay) { return ("Yesterday", absolute) } + let daysAgo = calendar.dateComponents([.day], from: shownDay, to: calendar.startOfDay(for: Date())).day ?? 0 + if daysAgo < 7 { + return (shownDay.formatted(.dateTime.weekday(.wide)), shownDay.formatted(.dateTime.month(.abbreviated).day())) + } + let sameYear = calendar.component(.year, from: shownDay) == calendar.component(.year, from: Date()) + let primary = sameYear ? absolute : shownDay.formatted(.dateTime.month(.abbreviated).day().year()) + return (primary, "") + } + /// Full spoken date for VoiceOver (never abbreviated glyphs). + private func voiceLabel(_ shownDay: Date) -> String { + let calendar = Calendar.current + let full = shownDay.formatted(.dateTime.weekday(.wide).month(.wide).day()) + if calendar.isDateInToday(shownDay) { return "Today, \(full)" } + if calendar.isDateInYesterday(shownDay) { return "Yesterday, \(full)" } + let sameYear = calendar.component(.year, from: shownDay) == calendar.component(.year, from: Date()) + return sameYear ? full : shownDay.formatted(.dateTime.weekday(.wide).month(.wide).day().year()) + } + + /// Speak the newly-selected day + its session count — the carousel repopulates silently and + /// focus stays on the chevron, so VoiceOver users otherwise get no confirmation of the change. + private func announceDay(_ offset: Int) { + let now = Calendar.current.date(byAdding: .day, value: -offset, to: Date()) ?? Date() + let sessionCount = SleepInsights.validSessions(SleepService.sleepRange(.day, context: modelContext, now: now).sessions).count + let day = SleepService.dayReferenceNight(now: now) + let sessionsPhrase = sessionCount == 0 + ? "No sleep recorded" + : "\(sessionCount) sleep session\(sessionCount == 1 ? "" : "s")" + AccessibilityNotification.Announcement("\(voiceLabel(day)). \(sessionsPhrase).").post() } // MARK: Aggregate