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
|
||||
@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 }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user