281 lines
9.9 KiB
Swift
281 lines
9.9 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
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
CMColors.bg.ignoresSafeArea()
|
|
|
|
ScrollView {
|
|
VStack(spacing: CMSpacing.xl) {
|
|
// Clock header
|
|
ClockHeader(now: store.now)
|
|
|
|
// Next up card
|
|
if let next = store.nextFiringTimer {
|
|
NextUpCard(timer: next, now: store.now)
|
|
}
|
|
|
|
// Quick timer bar
|
|
QuickTimerBar {
|
|
showQuickTimer = true
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
.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)
|
|
}
|
|
}
|