learning_ai_clock/ios/ChronoMind/Views/Timeline/TimelineView.swift

311 lines
11 KiB
Swift

// Timeline View
// Main screen vertical timeline showing all active/upcoming timers
import SwiftUI
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 {
ZStack {
CMColors.bg.ignoresSafeArea()
ScrollView {
VStack(spacing: CMSpacing.xl) {
// 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 + 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
if store.activeTimers.isEmpty {
EmptyTimelineView()
} else {
TimerListSection(
title: "Active",
timers: store.activeTimers.sorted { $0.targetTime < $1.targetTime },
now: store.now
)
}
// Firing timers overlay check
let firingTimers = store.timers.filter { $0.state == .firing }
if !firingTimers.isEmpty {
// Show inline firing card for non-critical
ForEach(firingTimers.filter { $0.urgency != .critical }) { timer in
FiringCard(timer: timer)
}
}
// Completed/dismissed (recent)
let recentDone = store.timers
.filter { [.completed, .dismissed].contains($0.state) }
.sorted { ($0.completedAt ?? $0.dismissedAt ?? .distantPast) > ($1.completedAt ?? $1.dismissedAt ?? .distantPast) }
.prefix(5)
if !recentDone.isEmpty {
TimerListSection(
title: "Recent",
timers: Array(recentDone),
now: store.now
)
}
}
.padding(.horizontal, CMSpacing.lg)
.padding(.bottom, 100)
}
// FAB
VStack {
Spacer()
HStack {
Spacer()
Button {
HapticEngine.tap()
showCreateTimer = true
} label: {
Image(systemName: "plus")
.font(.title2.weight(.semibold))
.foregroundStyle(.white)
.frame(width: 56, height: 56)
.background(CMColors.accent)
.clipShape(Circle())
.shadow(color: CMShadow.glow, radius: 12)
}
.padding(.trailing, CMSpacing.xl)
.padding(.bottom, CMSpacing.lg)
}
}
}
.navigationBarHidden(true)
.sheet(isPresented: $showCreateTimer) {
CreateTimerView()
}
.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 }
if let timer = criticalFiring {
AlarmOverlay(timer: timer)
}
}
}
}
}
// MARK: - Clock Header
struct ClockHeader: View {
let now: Date
var body: some View {
VStack(spacing: CMSpacing.xs) {
Text(formatTime(now))
.font(CMFonts.mono(size: 48, weight: .bold))
.foregroundStyle(CMColors.text)
.shadow(color: CMColors.accentGlow, radius: 20)
Text(formatDate(now))
.font(CMFonts.body(size: 16, weight: .medium))
.foregroundStyle(CMColors.textSecondary)
}
.frame(maxWidth: .infinity)
.padding(.top, CMSpacing.xl)
.padding(.bottom, CMSpacing.md)
}
}
// MARK: - Next Up Card
struct NextUpCard: View {
let timer: CMTimer
let now: Date
var body: some View {
VStack(alignment: .leading, spacing: CMSpacing.sm) {
HStack {
Text("NEXT UP")
.font(CMFonts.body(size: 11, weight: .bold))
.foregroundStyle(CMColors.textMuted)
.tracking(1.5)
Spacer()
UrgencyBadge(urgency: timer.urgency)
}
Text(timer.label)
.font(CMFonts.body(size: 18, weight: .semibold))
.foregroundStyle(CMColors.text)
HStack {
Image(systemName: "clock")
.font(.caption)
.foregroundStyle(CMColors.textMuted)
Text(formatRelativeTime(timer.targetTime, now: now))
.font(CMFonts.mono(size: 14, weight: .medium))
.foregroundStyle(CMColors.urgencyColor(timer.urgency))
Spacer()
Text(formatTime(timer.targetTime))
.font(CMFonts.body(size: 14))
.foregroundStyle(CMColors.textSecondary)
}
// Cascade progress
if !timer.warnings.isEmpty {
let firedCount = timer.warnings.filter(\.fired).count
let total = timer.warnings.count
CascadeProgressBar(
fired: firedCount,
total: total,
urgency: timer.urgency
)
}
// Time blindness aid
let remaining = getRemainingSeconds(timer, now: now)
if let ref = getTimeReference(seconds: remaining) {
Text(ref)
.font(CMFonts.body(size: 12))
.foregroundStyle(CMColors.textMuted)
.italic()
}
}
.padding(CMSpacing.lg)
.background(CMColors.surface)
.overlay(
RoundedRectangle(cornerRadius: CMRadius.lg)
.stroke(CMColors.urgencyBorder(timer.urgency), lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.lg))
}
}
// MARK: - Quick Timer Bar
struct QuickTimerBar: View {
let onCustom: () -> Void
@EnvironmentObject var store: TimerStore
private let presets: [(String, Int)] = [
("5m", 5),
("15m", 15),
("25m", 25),
("45m", 45),
("1h", 60),
]
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: CMSpacing.sm) {
ForEach(presets, id: \.0) { label, minutes in
Button {
HapticEngine.tap()
let _ = store.addCountdown(CreateCountdownParams(
label: "\(label) Timer",
durationSeconds: TimeInterval(minutes * 60),
cascade: CascadeConfig(preset: .light, intervals: [])
))
} label: {
Text(label)
.font(CMFonts.body(size: 14, weight: .semibold))
.foregroundStyle(CMColors.text)
.padding(.horizontal, CMSpacing.lg)
.padding(.vertical, CMSpacing.sm)
.background(CMColors.surface)
.clipShape(Capsule())
.overlay(
Capsule().stroke(CMColors.border, lineWidth: 1)
)
}
}
Button {
HapticEngine.tap()
onCustom()
} label: {
Image(systemName: "slider.horizontal.3")
.font(.body.weight(.semibold))
.foregroundStyle(CMColors.accent)
.padding(.horizontal, CMSpacing.lg)
.padding(.vertical, CMSpacing.sm)
.background(CMColors.surface)
.clipShape(Capsule())
.overlay(
Capsule().stroke(CMColors.accent.opacity(0.4), lineWidth: 1)
)
}
}
}
}
}
// MARK: - Empty State
struct EmptyTimelineView: View {
var body: some View {
VStack(spacing: CMSpacing.lg) {
Image(systemName: "timer")
.font(.system(size: 48))
.foregroundStyle(CMColors.textMuted)
Text("No active timers")
.font(CMFonts.body(size: 18, weight: .semibold))
.foregroundStyle(CMColors.textSecondary)
Text("Create your first timer with the + button\nor tap a quick preset above")
.font(CMFonts.body(size: 14))
.foregroundStyle(CMColors.textMuted)
.multilineTextAlignment(.center)
}
.padding(.vertical, CMSpacing.xxxl)
}
}