feat(reschedule): add reschedule sheet UI, undo banner, suggestion banner, and timeline integration
This commit is contained in:
parent
931746a119
commit
a86ed05271
230
ios/ChronoMind/Views/Reschedule/RescheduleSheet.swift
Normal file
230
ios/ChronoMind/Views/Reschedule/RescheduleSheet.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
145
ios/ChronoMind/Views/Reschedule/RescheduleUndoBanner.swift
Normal file
145
ios/ChronoMind/Views/Reschedule/RescheduleUndoBanner.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ struct TimelineView: View {
|
|||||||
@EnvironmentObject var store: TimerStore
|
@EnvironmentObject var store: TimerStore
|
||||||
@State private var showCreateTimer = false
|
@State private var showCreateTimer = false
|
||||||
@State private var showQuickTimer = false
|
@State private var showQuickTimer = false
|
||||||
|
@State private var showReschedule = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@ -18,14 +19,40 @@ struct TimelineView: View {
|
|||||||
// Clock header
|
// Clock header
|
||||||
ClockHeader(now: store.now)
|
ClockHeader(now: store.now)
|
||||||
|
|
||||||
|
// Undo banner (after reschedule)
|
||||||
|
RescheduleUndoBanner()
|
||||||
|
|
||||||
|
// Smart suggestion banner (late dismissal)
|
||||||
|
RescheduleSuggestionBanner()
|
||||||
|
|
||||||
// Next up card
|
// Next up card
|
||||||
if let next = store.nextFiringTimer {
|
if let next = store.nextFiringTimer {
|
||||||
NextUpCard(timer: next, now: store.now)
|
NextUpCard(timer: next, now: store.now)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quick timer bar
|
// Quick timer bar + reschedule
|
||||||
QuickTimerBar {
|
HStack(spacing: CMSpacing.sm) {
|
||||||
showQuickTimer = true
|
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
|
// Active timers
|
||||||
@ -95,6 +122,9 @@ struct TimelineView: View {
|
|||||||
.sheet(isPresented: $showQuickTimer) {
|
.sheet(isPresented: $showQuickTimer) {
|
||||||
QuickTimerSheet()
|
QuickTimerSheet()
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showReschedule) {
|
||||||
|
RescheduleSheet()
|
||||||
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
// Full-screen overlay for CRITICAL firing timers
|
// Full-screen overlay for CRITICAL firing timers
|
||||||
let criticalFiring = store.timers.first { $0.state == .firing && $0.urgency == .critical }
|
let criticalFiring = store.timers.first { $0.state == .firing && $0.urgency == .critical }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user