From 88603bd4c4ff4a69a597f1c6a242ea9592ff2f00 Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Wed, 18 Mar 2026 23:43:30 -0700 Subject: [PATCH] feat: phase blocks as horizontal scroller behind disclosure Replace vertical stack of phase blocks with a compact horizontal ScrollView behind a "Set Points (N)" disclosure toggle. Each phase card shows icon, name, time, and temp +/- controls in a compact 100pt-wide card with color-coded border. Clear Schedule button also moves behind the disclosure. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sleepypod/Views/Schedule/PhaseBlockView.swift | 82 +++++++++++++++++ Sleepypod/Views/Schedule/ScheduleScreen.swift | 89 ++++++++++--------- 2 files changed, 131 insertions(+), 40 deletions(-) diff --git a/Sleepypod/Views/Schedule/PhaseBlockView.swift b/Sleepypod/Views/Schedule/PhaseBlockView.swift index 5e97468..3b11f2b 100644 --- a/Sleepypod/Views/Schedule/PhaseBlockView.swift +++ b/Sleepypod/Views/Schedule/PhaseBlockView.swift @@ -1,5 +1,87 @@ import SwiftUI +// MARK: - Compact Phase Card (horizontal scroll) + +struct PhaseBlockCompactView: View { + @Environment(ScheduleManager.self) private var scheduleManager + @Environment(SettingsManager.self) private var settingsManager + let phase: SchedulePhase + + private var tempColor: Color { TempColor.forOffset(phase.offset) } + + var body: some View { + VStack(spacing: 8) { + // Icon + time + Image(systemName: phase.icon) + .font(.system(size: 14)) + .foregroundColor(tempColor) + + Text(phase.name) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.white) + .lineLimit(1) + + Text(formatTime(phase.time)) + .font(.system(size: 9)) + .foregroundColor(Theme.textSecondary) + + // Temp + controls + HStack(spacing: 6) { + Button { + Haptics.light() + Task { await scheduleManager.updatePhaseTemperature(time: phase.time, delta: -1) } + } label: { + Image(systemName: "minus") + .font(.system(size: 10, weight: .bold)) + .foregroundColor(Theme.textSecondary) + .frame(width: 22, height: 22) + .background(Theme.cardElevated) + .clipShape(Circle()) + } + .buttonStyle(.plain) + + Text(TemperatureConversion.displayTemp(phase.temperatureF, format: settingsManager.temperatureFormat)) + .font(.system(size: 14, weight: .bold, design: .rounded)) + .foregroundColor(tempColor) + .frame(width: 40) + .contentTransition(.numericText()) + + Button { + Haptics.light() + Task { await scheduleManager.updatePhaseTemperature(time: phase.time, delta: 1) } + } label: { + Image(systemName: "plus") + .font(.system(size: 10, weight: .bold)) + .foregroundColor(Theme.textSecondary) + .frame(width: 22, height: 22) + .background(Theme.cardElevated) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + } + .frame(width: 100) + .padding(.vertical, 10) + .padding(.horizontal, 6) + .background(Theme.card) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(tempColor.opacity(0.2), lineWidth: 1) + ) + } + + private func formatTime(_ time: String) -> String { + let parts = time.split(separator: ":") + guard parts.count == 2, let hour = Int(parts[0]), let minute = Int(parts[1]) else { return time } + let period = hour >= 12 ? "PM" : "AM" + let displayHour = hour == 0 ? 12 : (hour > 12 ? hour - 12 : hour) + return "\(displayHour):\(String(format: "%02d", minute)) \(period)" + } +} + +// MARK: - Full Phase Card (legacy, kept for reference) + struct PhaseBlockView: View { @Environment(ScheduleManager.self) private var scheduleManager @Environment(SettingsManager.self) private var settingsManager diff --git a/Sleepypod/Views/Schedule/ScheduleScreen.swift b/Sleepypod/Views/Schedule/ScheduleScreen.swift index 89b2bc6..e7e9b0c 100644 --- a/Sleepypod/Views/Schedule/ScheduleScreen.swift +++ b/Sleepypod/Views/Schedule/ScheduleScreen.swift @@ -22,55 +22,64 @@ struct ScheduleScreen: View { // Schedule toggle scheduleToggle - // Manual set points (advanced) - Button { - Haptics.light() - withAnimation(.easeInOut(duration: 0.2)) { showAdvanced.toggle() } - } label: { - HStack { - Text("Manual Set Points") - .font(.caption.weight(.medium)) - .foregroundColor(Theme.textMuted) - Spacer() - Image(systemName: "chevron.right") - .font(.caption2) - .foregroundColor(Theme.textMuted) - .rotationEffect(.degrees(showAdvanced ? 90 : 0)) - } - } - .buttonStyle(.plain) + // Phase detail — horizontal scroller behind disclosure + if scheduleManager.schedules != nil && !scheduleManager.phases.isEmpty { + VStack(spacing: 10) { + Button { + Haptics.light() + withAnimation(.easeInOut(duration: 0.2)) { showAdvanced.toggle() } + } label: { + HStack { + Text("Set Points") + .font(.caption.weight(.medium)) + .foregroundColor(Theme.textSecondary) + Text("(\(scheduleManager.phases.count))") + .font(.caption2) + .foregroundColor(Theme.textMuted) + Spacer() + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundColor(Theme.textMuted) + .rotationEffect(.degrees(showAdvanced ? 90 : 0)) + } + } + .buttonStyle(.plain) + + if showAdvanced { + // Horizontal scrolling phase cards + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(scheduleManager.phases) { phase in + PhaseBlockCompactView(phase: phase) + } + } + .padding(.horizontal, 2) + } + } - // Phase blocks - if scheduleManager.schedules != nil { - VStack(spacing: 12) { - ForEach(scheduleManager.phases) { phase in - PhaseBlockView(phase: phase) + // Clear schedule + if showAdvanced { + Button { + Haptics.medium() + showClearConfirm = true + } label: { + HStack(spacing: 6) { + Image(systemName: "trash") + Text("Clear Schedule") + } + .font(.caption.weight(.medium)) + .foregroundColor(Theme.error) + } + .buttonStyle(.plain) } } } else if scheduleManager.isLoading { LoadingView(message: "Loading schedule…") - } else { + } else if scheduleManager.schedules == nil { Text("No schedule data") .foregroundColor(Theme.textSecondary) .padding(40) } - - // Clear schedule - if scheduleManager.schedules != nil && !scheduleManager.phases.isEmpty { - Button { - Haptics.medium() - showClearConfirm = true - } label: { - HStack(spacing: 6) { - Image(systemName: "trash") - Text("Clear Schedule") - } - .font(.caption.weight(.medium)) - .foregroundColor(Theme.error) - } - .buttonStyle(.plain) - .padding(.top, 8) - } } .padding(.horizontal, 16) .padding(.bottom, 20)