256 lines
9.3 KiB
Swift
256 lines
9.3 KiB
Swift
// ── Timer Card ─────────────────────────────────────────────────
|
|
// Individual timer display for timeline list
|
|
|
|
import SwiftUI
|
|
|
|
struct TimerCard: View {
|
|
let timer: CMTimer
|
|
let now: Date
|
|
|
|
@EnvironmentObject var store: TimerStore
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: CMSpacing.sm) {
|
|
// Header: urgency + label + time
|
|
HStack(alignment: .top) {
|
|
// Urgency indicator dot
|
|
Circle()
|
|
.fill(CMColors.urgencyColor(timer.urgency))
|
|
.frame(width: 8, height: 8)
|
|
.padding(.top, 6)
|
|
|
|
VStack(alignment: .leading, spacing: CMSpacing.xxs) {
|
|
Text(timer.label)
|
|
.font(CMFonts.body(size: 16, weight: .semibold))
|
|
.foregroundStyle(CMColors.text)
|
|
.strikethrough(timer.state == .dismissed || timer.state == .completed)
|
|
|
|
if let desc = timer.description, !desc.isEmpty {
|
|
Text(desc)
|
|
.font(CMFonts.body(size: 13))
|
|
.foregroundStyle(CMColors.textSecondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// State / countdown
|
|
VStack(alignment: .trailing, spacing: CMSpacing.xxs) {
|
|
timerStateView
|
|
|
|
Text(formatTime(timer.targetTime))
|
|
.font(CMFonts.body(size: 12))
|
|
.foregroundStyle(CMColors.textMuted)
|
|
}
|
|
}
|
|
|
|
// Cascade progress (if applicable)
|
|
if !timer.warnings.isEmpty && isTimerActive(timer) {
|
|
let firedCount = timer.warnings.filter(\.fired).count
|
|
CascadeProgressBar(
|
|
fired: firedCount,
|
|
total: timer.warnings.count,
|
|
urgency: timer.urgency
|
|
)
|
|
}
|
|
|
|
// Pomodoro round indicator
|
|
if timer.type == .pomodoro, let state = timer.pomodoroState, let config = timer.pomodoroConfig {
|
|
PomodoroRoundIndicator(
|
|
currentRound: state.currentRound,
|
|
totalRounds: config.rounds,
|
|
completedRounds: state.completedRounds,
|
|
isBreak: state.isBreak || state.isLongBreak
|
|
)
|
|
}
|
|
|
|
// Action buttons for firing timers
|
|
if timer.state == .firing {
|
|
HStack(spacing: CMSpacing.sm) {
|
|
Button {
|
|
HapticEngine.tap()
|
|
store.snooze(timer.id, minutes: 5)
|
|
} label: {
|
|
Label("5m", systemImage: "moon.zzz")
|
|
.font(CMFonts.body(size: 13, weight: .medium))
|
|
.foregroundStyle(CMColors.text)
|
|
.padding(.horizontal, CMSpacing.md)
|
|
.padding(.vertical, CMSpacing.sm)
|
|
.background(CMColors.surfaceHover)
|
|
.clipShape(Capsule())
|
|
}
|
|
|
|
Button {
|
|
HapticEngine.tap()
|
|
store.snooze(timer.id, minutes: 15)
|
|
} label: {
|
|
Label("15m", systemImage: "moon.zzz")
|
|
.font(CMFonts.body(size: 13, weight: .medium))
|
|
.foregroundStyle(CMColors.text)
|
|
.padding(.horizontal, CMSpacing.md)
|
|
.padding(.vertical, CMSpacing.sm)
|
|
.background(CMColors.surfaceHover)
|
|
.clipShape(Capsule())
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
HapticEngine.tap()
|
|
store.dismiss(timer.id)
|
|
} label: {
|
|
Text("Dismiss")
|
|
.font(CMFonts.body(size: 13, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, CMSpacing.lg)
|
|
.padding(.vertical, CMSpacing.sm)
|
|
.background(CMColors.error)
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
}
|
|
|
|
// Action buttons for active/paused timers (countdown/pomodoro)
|
|
if [.active, .warning, .paused].contains(timer.state) && timer.type != .alarm {
|
|
HStack(spacing: CMSpacing.sm) {
|
|
if timer.state == .paused {
|
|
Button {
|
|
HapticEngine.tap()
|
|
store.resume(timer.id)
|
|
} label: {
|
|
Label("Resume", systemImage: "play.fill")
|
|
.font(CMFonts.body(size: 13, weight: .medium))
|
|
.foregroundStyle(CMColors.accent)
|
|
}
|
|
} else {
|
|
Button {
|
|
HapticEngine.tap()
|
|
store.pause(timer.id)
|
|
} label: {
|
|
Label("Pause", systemImage: "pause.fill")
|
|
.font(CMFonts.body(size: 13, weight: .medium))
|
|
.foregroundStyle(CMColors.textSecondary)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
HapticEngine.tap()
|
|
store.dismiss(timer.id)
|
|
} label: {
|
|
Image(systemName: "xmark")
|
|
.font(.caption.weight(.bold))
|
|
.foregroundStyle(CMColors.textMuted)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(CMSpacing.lg)
|
|
.background(cardBackground)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: CMRadius.md)
|
|
.stroke(cardBorder, lineWidth: 1)
|
|
)
|
|
.clipShape(RoundedRectangle(cornerRadius: CMRadius.md))
|
|
}
|
|
|
|
// MARK: - Timer State View
|
|
|
|
@ViewBuilder
|
|
private var timerStateView: some View {
|
|
switch timer.state {
|
|
case .active, .warning:
|
|
let remaining = getRemainingSeconds(timer, now: now)
|
|
Text(formatDuration(remaining))
|
|
.font(CMFonts.mono(size: 16, weight: .semibold))
|
|
.foregroundStyle(CMColors.urgencyColor(timer.urgency))
|
|
|
|
case .paused:
|
|
let remaining = getRemainingSeconds(timer, now: now)
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "pause.fill")
|
|
.font(.caption2)
|
|
Text(formatDuration(remaining))
|
|
.font(CMFonts.mono(size: 16, weight: .semibold))
|
|
}
|
|
.foregroundStyle(CMColors.textMuted)
|
|
|
|
case .firing:
|
|
Text("FIRING")
|
|
.font(CMFonts.body(size: 12, weight: .bold))
|
|
.foregroundStyle(CMColors.urgencyColor(timer.urgency))
|
|
.padding(.horizontal, CMSpacing.sm)
|
|
.padding(.vertical, CMSpacing.xxs)
|
|
.background(CMColors.urgencyBg(timer.urgency))
|
|
.clipShape(Capsule())
|
|
|
|
case .snoozed:
|
|
if let until = timer.snoozedUntil {
|
|
Text("Snoozed → \(formatTime(until))")
|
|
.font(CMFonts.body(size: 12))
|
|
.foregroundStyle(CMColors.textMuted)
|
|
}
|
|
|
|
case .completed:
|
|
Label("Done", systemImage: "checkmark.circle.fill")
|
|
.font(CMFonts.body(size: 12, weight: .medium))
|
|
.foregroundStyle(CMColors.success)
|
|
|
|
case .dismissed:
|
|
Text("Dismissed")
|
|
.font(CMFonts.body(size: 12))
|
|
.foregroundStyle(CMColors.textMuted)
|
|
|
|
case .idle:
|
|
EmptyView()
|
|
}
|
|
}
|
|
|
|
// MARK: - Card Styling
|
|
|
|
private var cardBackground: Color {
|
|
switch timer.state {
|
|
case .firing:
|
|
return CMColors.urgencyBg(timer.urgency)
|
|
default:
|
|
return CMColors.surface
|
|
}
|
|
}
|
|
|
|
private var cardBorder: Color {
|
|
switch timer.state {
|
|
case .firing:
|
|
return CMColors.urgencyBorder(timer.urgency)
|
|
default:
|
|
return CMColors.border
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Pomodoro Round Indicator
|
|
|
|
struct PomodoroRoundIndicator: View {
|
|
let currentRound: Int
|
|
let totalRounds: Int
|
|
let completedRounds: Int
|
|
let isBreak: Bool
|
|
|
|
var body: some View {
|
|
HStack(spacing: CMSpacing.sm) {
|
|
Text(isBreak ? "Break" : "Round \(currentRound) of \(totalRounds)")
|
|
.font(CMFonts.body(size: 12, weight: .medium))
|
|
.foregroundStyle(CMColors.textSecondary)
|
|
|
|
HStack(spacing: CMSpacing.xs) {
|
|
ForEach(1...totalRounds, id: \.self) { round in
|
|
Circle()
|
|
.fill(round <= completedRounds ? CMColors.accent : CMColors.border)
|
|
.frame(width: 6, height: 6)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|