feat(reschedule): add reschedule sheet UI, undo banner, suggestion banner, and timeline integration

This commit is contained in:
saravanakumardb1 2026-02-27 22:13:32 -08:00
parent 931746a119
commit a86ed05271
3 changed files with 408 additions and 3 deletions

View File

@ -0,0 +1,230 @@
// Reschedule Sheet
// NL-style reschedule options: "I slept in", "Push everything", "Skip next"
import SwiftUI
struct RescheduleSheet: View {
@EnvironmentObject var store: TimerStore
@Environment(\.dismiss) private var dismiss
@State private var customMinutes: Double = 30
var body: some View {
NavigationStack {
ZStack {
CMColors.bg.ignoresSafeArea()
ScrollView {
VStack(spacing: CMSpacing.xl) {
// Smart suggestions (if any)
if !store.rescheduleSuggestions.isEmpty {
smartSuggestionsSection
}
// Quick actions
quickActionsSection
// Custom shift
customShiftSection
// Skip next
skipSection
}
.padding(.horizontal, CMSpacing.lg)
.padding(.bottom, CMSpacing.xxl)
}
}
.navigationTitle("Reschedule")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(CMColors.surface, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
.foregroundStyle(CMColors.textSecondary)
}
}
}
}
// MARK: - Smart Suggestions
private var smartSuggestionsSection: some View {
VStack(alignment: .leading, spacing: CMSpacing.md) {
HStack(spacing: CMSpacing.sm) {
Image(systemName: "sparkles")
.foregroundStyle(CMColors.accent)
Text("SUGGESTED")
.font(CMFonts.body(size: 11, weight: .bold))
.foregroundStyle(CMColors.textMuted)
.tracking(1.5)
}
ForEach(store.rescheduleSuggestions) { suggestion in
RescheduleOptionButton(
title: suggestion.title,
subtitle: suggestion.subtitle,
icon: suggestion.icon,
color: CMColors.accent
) {
applyAndDismiss(suggestion.action)
}
}
}
}
// MARK: - Quick Actions
private var quickActionsSection: some View {
VStack(alignment: .leading, spacing: CMSpacing.md) {
Text("QUICK ACTIONS")
.font(CMFonts.body(size: 11, weight: .bold))
.foregroundStyle(CMColors.textMuted)
.tracking(1.5)
ForEach(RescheduleSuggestion.quickActions) { action in
RescheduleOptionButton(
title: action.title,
subtitle: action.subtitle,
icon: action.icon,
color: CMColors.text
) {
applyAndDismiss(action.action)
}
}
}
}
// MARK: - Custom Shift
private var customShiftSection: some View {
VStack(alignment: .leading, spacing: CMSpacing.md) {
Text("CUSTOM")
.font(CMFonts.body(size: 11, weight: .bold))
.foregroundStyle(CMColors.textMuted)
.tracking(1.5)
VStack(spacing: CMSpacing.md) {
HStack {
Text("Shift by")
.font(CMFonts.body(size: 14))
.foregroundStyle(CMColors.textSecondary)
Spacer()
Text("\(Int(customMinutes)) min")
.font(CMFonts.mono(size: 18, weight: .bold))
.foregroundStyle(CMColors.accent)
}
Slider(value: $customMinutes, in: 5...120, step: 5)
.tint(CMColors.accent)
HStack(spacing: CMSpacing.md) {
Button {
applyAndDismiss(.pushAll(interval: TimeInterval(customMinutes * 60)))
} label: {
HStack {
Image(systemName: "arrow.right")
Text("Push Later")
}
.font(CMFonts.body(size: 14, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, CMSpacing.md)
.background(CMColors.accent)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
}
Button {
applyAndDismiss(.pushAll(interval: TimeInterval(-customMinutes * 60)))
} label: {
HStack {
Image(systemName: "arrow.left")
Text("Pull Earlier")
}
.font(CMFonts.body(size: 14, weight: .semibold))
.foregroundStyle(CMColors.text)
.frame(maxWidth: .infinity)
.padding(.vertical, CMSpacing.md)
.background(CMColors.surface)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
.overlay(
RoundedRectangle(cornerRadius: CMRadius.sm)
.stroke(CMColors.border, lineWidth: 1)
)
}
}
}
.padding(CMSpacing.lg)
.background(CMColors.surface)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.md))
}
}
// MARK: - Skip
private var skipSection: some View {
VStack(alignment: .leading, spacing: CMSpacing.md) {
if let next = store.nextFiringTimer {
RescheduleOptionButton(
title: "Skip \"\(next.label)\"",
subtitle: "Dismiss your next timer and adjust remaining",
icon: "forward.fill",
color: CMColors.important
) {
applyAndDismiss(.skip(timerId: next.id))
}
}
}
}
// MARK: - Helpers
private func applyAndDismiss(_ action: RescheduleAction) {
HapticEngine.tap()
store.applyReschedule(action)
dismiss()
}
}
// MARK: - Option Button
struct RescheduleOptionButton: View {
let title: String
let subtitle: String
let icon: String
let color: Color
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: CMSpacing.md) {
Image(systemName: icon)
.font(.title3)
.foregroundStyle(color)
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: CMSpacing.xxs) {
Text(title)
.font(CMFonts.body(size: 15, weight: .semibold))
.foregroundStyle(CMColors.text)
Text(subtitle)
.font(CMFonts.body(size: 12))
.foregroundStyle(CMColors.textMuted)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(CMColors.textMuted)
}
.padding(CMSpacing.md)
.background(CMColors.surface)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
.overlay(
RoundedRectangle(cornerRadius: CMRadius.sm)
.stroke(CMColors.border, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}

View File

@ -0,0 +1,145 @@
// Reschedule Undo Banner
// Toast-style banner that appears after reschedule with undo button
import SwiftUI
struct RescheduleUndoBanner: View {
@EnvironmentObject var store: TimerStore
@State private var isVisible = false
var body: some View {
if let result = store.lastReschedule {
HStack(spacing: CMSpacing.md) {
Image(systemName: "arrow.uturn.backward.circle.fill")
.font(.title3)
.foregroundStyle(CMColors.accent)
VStack(alignment: .leading, spacing: CMSpacing.xxs) {
Text(result.description)
.font(CMFonts.body(size: 13, weight: .medium))
.foregroundStyle(CMColors.text)
Text("\(result.affectedTimerIds.count) timer\(result.affectedTimerIds.count == 1 ? "" : "s") affected")
.font(CMFonts.body(size: 11))
.foregroundStyle(CMColors.textMuted)
}
Spacer()
Button {
HapticEngine.tap()
withAnimation(.easeInOut(duration: 0.3)) {
store.undoReschedule()
}
} label: {
Text("Undo")
.font(CMFonts.body(size: 14, weight: .bold))
.foregroundStyle(CMColors.accent)
.padding(.horizontal, CMSpacing.md)
.padding(.vertical, CMSpacing.sm)
.background(CMColors.accent.opacity(0.15))
.clipShape(Capsule())
}
Button {
withAnimation(.easeOut(duration: 0.2)) {
store.lastReschedule = nil
}
} label: {
Image(systemName: "xmark")
.font(.caption.weight(.bold))
.foregroundStyle(CMColors.textMuted)
}
}
.padding(CMSpacing.md)
.background(CMColors.surface)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.md))
.overlay(
RoundedRectangle(cornerRadius: CMRadius.md)
.stroke(CMColors.accent.opacity(0.3), lineWidth: 1)
)
.shadow(color: CMShadow.md, radius: 8, y: 4)
.padding(.horizontal, CMSpacing.lg)
.transition(.move(edge: .top).combined(with: .opacity))
.onAppear {
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
isVisible = true
}
}
}
}
}
// MARK: - Smart Suggestion Banner
struct RescheduleSuggestionBanner: View {
@EnvironmentObject var store: TimerStore
@State private var showSheet = false
var body: some View {
if !store.rescheduleSuggestions.isEmpty {
VStack(spacing: CMSpacing.sm) {
HStack(spacing: CMSpacing.sm) {
Image(systemName: "sparkles")
.foregroundStyle(CMColors.accent)
Text("Running late? Reschedule your timers")
.font(CMFonts.body(size: 13, weight: .medium))
.foregroundStyle(CMColors.text)
Spacer()
Button {
store.clearRescheduleSuggestions()
} label: {
Image(systemName: "xmark")
.font(.caption.weight(.bold))
.foregroundStyle(CMColors.textMuted)
}
}
HStack(spacing: CMSpacing.sm) {
// Show first suggestion as quick action
if let first = store.rescheduleSuggestions.first {
Button {
HapticEngine.tap()
store.applyReschedule(first.action)
store.clearRescheduleSuggestions()
} label: {
Text(first.title)
.font(CMFonts.body(size: 13, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, CMSpacing.md)
.padding(.vertical, CMSpacing.sm)
.background(CMColors.accent)
.clipShape(Capsule())
}
}
Button {
showSheet = true
} label: {
Text("More options")
.font(CMFonts.body(size: 13, weight: .medium))
.foregroundStyle(CMColors.accent)
.padding(.horizontal, CMSpacing.md)
.padding(.vertical, CMSpacing.sm)
.background(CMColors.accent.opacity(0.1))
.clipShape(Capsule())
}
}
}
.padding(CMSpacing.md)
.background(CMColors.surface)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.md))
.overlay(
RoundedRectangle(cornerRadius: CMRadius.md)
.stroke(CMColors.accent.opacity(0.3), lineWidth: 1)
)
.padding(.horizontal, CMSpacing.lg)
.transition(.move(edge: .top).combined(with: .opacity))
.sheet(isPresented: $showSheet) {
RescheduleSheet()
}
}
}
}

View File

@ -7,6 +7,7 @@ struct TimelineView: View {
@EnvironmentObject var store: TimerStore
@State private var showCreateTimer = false
@State private var showQuickTimer = false
@State private var showReschedule = false
var body: some View {
NavigationStack {
@ -18,14 +19,40 @@ struct TimelineView: View {
// Clock header
ClockHeader(now: store.now)
// Undo banner (after reschedule)
RescheduleUndoBanner()
// Smart suggestion banner (late dismissal)
RescheduleSuggestionBanner()
// Next up card
if let next = store.nextFiringTimer {
NextUpCard(timer: next, now: store.now)
}
// Quick timer bar
QuickTimerBar {
showQuickTimer = true
// Quick timer bar + reschedule
HStack(spacing: CMSpacing.sm) {
QuickTimerBar {
showQuickTimer = true
}
if !store.activeTimers.isEmpty {
Button {
HapticEngine.tap()
showReschedule = true
} label: {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.body.weight(.semibold))
.foregroundStyle(CMColors.important)
.padding(.horizontal, CMSpacing.md)
.padding(.vertical, CMSpacing.sm)
.background(CMColors.surface)
.clipShape(Capsule())
.overlay(
Capsule().stroke(CMColors.important.opacity(0.4), lineWidth: 1)
)
}
}
}
// Active timers
@ -95,6 +122,9 @@ struct TimelineView: View {
.sheet(isPresented: $showQuickTimer) {
QuickTimerSheet()
}
.sheet(isPresented: $showReschedule) {
RescheduleSheet()
}
.overlay {
// Full-screen overlay for CRITICAL firing timers
let criticalFiring = store.timers.first { $0.state == .firing && $0.urgency == .critical }