From a86ed05271010c9ff37b9febed93509ce5952b2a Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 27 Feb 2026 22:13:32 -0800 Subject: [PATCH] feat(reschedule): add reschedule sheet UI, undo banner, suggestion banner, and timeline integration --- .../Views/Reschedule/RescheduleSheet.swift | 230 ++++++++++++++++++ .../Reschedule/RescheduleUndoBanner.swift | 145 +++++++++++ .../Views/Timeline/TimelineView.swift | 36 ++- 3 files changed, 408 insertions(+), 3 deletions(-) create mode 100644 ios/ChronoMind/Views/Reschedule/RescheduleSheet.swift create mode 100644 ios/ChronoMind/Views/Reschedule/RescheduleUndoBanner.swift diff --git a/ios/ChronoMind/Views/Reschedule/RescheduleSheet.swift b/ios/ChronoMind/Views/Reschedule/RescheduleSheet.swift new file mode 100644 index 0000000..98e8717 --- /dev/null +++ b/ios/ChronoMind/Views/Reschedule/RescheduleSheet.swift @@ -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) + } +} diff --git a/ios/ChronoMind/Views/Reschedule/RescheduleUndoBanner.swift b/ios/ChronoMind/Views/Reschedule/RescheduleUndoBanner.swift new file mode 100644 index 0000000..9c27c98 --- /dev/null +++ b/ios/ChronoMind/Views/Reschedule/RescheduleUndoBanner.swift @@ -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() + } + } + } +} diff --git a/ios/ChronoMind/Views/Timeline/TimelineView.swift b/ios/ChronoMind/Views/Timeline/TimelineView.swift index 0be1c87..ea7aa2e 100644 --- a/ios/ChronoMind/Views/Timeline/TimelineView.swift +++ b/ios/ChronoMind/Views/Timeline/TimelineView.swift @@ -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 }