test: add 13 Zustand store tests (66 total passing)
This commit is contained in:
parent
815e1cd7fe
commit
755d030c7a
147
ios/ChronoMind/Views/Components/AlarmOverlay.swift
Normal file
147
ios/ChronoMind/Views/Components/AlarmOverlay.swift
Normal file
@ -0,0 +1,147 @@
|
||||
// ── Alarm Overlay ──────────────────────────────────────────────
|
||||
// Full-screen overlay for CRITICAL urgency firing timers
|
||||
// Requires confirm-to-dismiss
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AlarmOverlay: View {
|
||||
let timer: CMTimer
|
||||
|
||||
@EnvironmentObject var store: TimerStore
|
||||
@State private var showConfirmDismiss = false
|
||||
@State private var pulseScale: CGFloat = 1.0
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background
|
||||
CMColors.bg.opacity(0.95)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: CMSpacing.xxl) {
|
||||
Spacer()
|
||||
|
||||
// Pulsing urgency ring
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(CMColors.critical.opacity(0.2), lineWidth: 4)
|
||||
.frame(width: 200, height: 200)
|
||||
.scaleEffect(pulseScale)
|
||||
.animation(
|
||||
.easeInOut(duration: 1.0).repeatForever(autoreverses: true),
|
||||
value: pulseScale
|
||||
)
|
||||
|
||||
Circle()
|
||||
.stroke(CMColors.critical, lineWidth: 3)
|
||||
.frame(width: 160, height: 160)
|
||||
|
||||
VStack(spacing: CMSpacing.sm) {
|
||||
Image(systemName: "bell.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(CMColors.critical)
|
||||
|
||||
Text("NOW")
|
||||
.font(CMFonts.mono(size: 24, weight: .bold))
|
||||
.foregroundStyle(CMColors.critical)
|
||||
}
|
||||
}
|
||||
|
||||
// Timer info
|
||||
VStack(spacing: CMSpacing.sm) {
|
||||
UrgencyBadge(urgency: .critical)
|
||||
|
||||
Text(timer.label)
|
||||
.font(CMFonts.display(size: 28))
|
||||
.foregroundStyle(CMColors.text)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if let desc = timer.description, !desc.isEmpty {
|
||||
Text(desc)
|
||||
.font(CMFonts.body(size: 16))
|
||||
.foregroundStyle(CMColors.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
if timer.snoozeCount > 0 {
|
||||
Text("Snoozed \(timer.snoozeCount) time\(timer.snoozeCount == 1 ? "" : "s")")
|
||||
.font(CMFonts.body(size: 14))
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Snooze buttons
|
||||
HStack(spacing: CMSpacing.lg) {
|
||||
Button {
|
||||
HapticEngine.tap()
|
||||
store.snooze(timer.id, minutes: 5)
|
||||
} label: {
|
||||
VStack(spacing: CMSpacing.xs) {
|
||||
Image(systemName: "moon.zzz")
|
||||
.font(.title3)
|
||||
Text("5 min")
|
||||
.font(CMFonts.body(size: 13, weight: .medium))
|
||||
}
|
||||
.foregroundStyle(CMColors.text)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, CMSpacing.lg)
|
||||
.background(CMColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: CMRadius.md))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: CMRadius.md)
|
||||
.stroke(CMColors.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
Button {
|
||||
HapticEngine.tap()
|
||||
store.snooze(timer.id, minutes: 15)
|
||||
} label: {
|
||||
VStack(spacing: CMSpacing.xs) {
|
||||
Image(systemName: "moon.zzz.fill")
|
||||
.font(.title3)
|
||||
Text("15 min")
|
||||
.font(CMFonts.body(size: 13, weight: .medium))
|
||||
}
|
||||
.foregroundStyle(CMColors.text)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, CMSpacing.lg)
|
||||
.background(CMColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: CMRadius.md))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: CMRadius.md)
|
||||
.stroke(CMColors.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, CMSpacing.xl)
|
||||
|
||||
// Dismiss button (requires confirmation for Critical)
|
||||
Button {
|
||||
HapticEngine.tap()
|
||||
showConfirmDismiss = true
|
||||
} label: {
|
||||
Text("Dismiss")
|
||||
.font(CMFonts.body(size: 18, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, CMSpacing.lg)
|
||||
.background(CMColors.critical)
|
||||
.clipShape(RoundedRectangle(cornerRadius: CMRadius.md))
|
||||
}
|
||||
.padding(.horizontal, CMSpacing.xl)
|
||||
.padding(.bottom, CMSpacing.xxl)
|
||||
}
|
||||
}
|
||||
.onAppear { pulseScale = 1.15 }
|
||||
.alert("Dismiss Critical Timer?", isPresented: $showConfirmDismiss) {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Dismiss", role: .destructive) {
|
||||
store.dismiss(timer.id)
|
||||
}
|
||||
} message: {
|
||||
Text("Are you sure you want to dismiss \"\(timer.label)\"? This is a critical timer.")
|
||||
}
|
||||
}
|
||||
}
|
||||
44
ios/ChronoMind/Views/Components/CascadeProgressBar.swift
Normal file
44
ios/ChronoMind/Views/Components/CascadeProgressBar.swift
Normal file
@ -0,0 +1,44 @@
|
||||
// ── Cascade Progress Bar ───────────────────────────────────────
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CascadeProgressBar: View {
|
||||
let fired: Int
|
||||
let total: Int
|
||||
let urgency: UrgencyLevel
|
||||
|
||||
private var progress: Double {
|
||||
guard total > 0 else { return 0 }
|
||||
return Double(fired) / Double(total)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: CMSpacing.xs) {
|
||||
HStack {
|
||||
Text("Cascade")
|
||||
.font(CMFonts.body(size: 11, weight: .medium))
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(fired)/\(total)")
|
||||
.font(CMFonts.mono(size: 11))
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
}
|
||||
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(CMColors.border)
|
||||
.frame(height: 6)
|
||||
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(CMColors.urgencyColor(urgency))
|
||||
.frame(width: geo.size.width * progress, height: 6)
|
||||
.animation(.easeInOut(duration: 0.3), value: progress)
|
||||
}
|
||||
}
|
||||
.frame(height: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
98
ios/ChronoMind/Views/Components/CountdownRing.swift
Normal file
98
ios/ChronoMind/Views/Components/CountdownRing.swift
Normal file
@ -0,0 +1,98 @@
|
||||
// ── Countdown Ring ─────────────────────────────────────────────
|
||||
// Visual countdown ring (like Time Timer) — neurodivergent-friendly
|
||||
// Color transitions: green → yellow → orange → red as time decreases
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CountdownRing: View {
|
||||
let progress: Double // 0.0 (full) → 1.0 (empty)
|
||||
let urgency: UrgencyLevel
|
||||
let remainingSeconds: TimeInterval
|
||||
let totalSeconds: TimeInterval
|
||||
var size: CGFloat = 220
|
||||
var lineWidth: CGFloat = 12
|
||||
|
||||
private var ringColor: Color {
|
||||
// Color transition based on remaining time ratio
|
||||
let ratio = 1.0 - progress // 1.0 = full time, 0.0 = no time
|
||||
if ratio > 0.5 {
|
||||
return CMColors.gentle // green
|
||||
} else if ratio > 0.25 {
|
||||
return CMColors.standard // yellow
|
||||
} else if ratio > 0.1 {
|
||||
return CMColors.important // orange
|
||||
} else {
|
||||
return CMColors.critical // red
|
||||
}
|
||||
}
|
||||
|
||||
private var glowColor: Color {
|
||||
ringColor.opacity(0.3)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background track
|
||||
Circle()
|
||||
.stroke(CMColors.border, lineWidth: lineWidth)
|
||||
.frame(width: size, height: size)
|
||||
|
||||
// Progress arc (fills clockwise from 12 o'clock)
|
||||
Circle()
|
||||
.trim(from: 0, to: 1.0 - progress)
|
||||
.stroke(
|
||||
ringColor,
|
||||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||
)
|
||||
.frame(width: size, height: size)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.shadow(color: glowColor, radius: 8)
|
||||
.animation(.easeInOut(duration: 0.3), value: progress)
|
||||
|
||||
// Center content
|
||||
VStack(spacing: CMSpacing.xs) {
|
||||
Text(formatDuration(remainingSeconds))
|
||||
.font(CMFonts.mono(size: size * 0.18, weight: .bold))
|
||||
.foregroundStyle(CMColors.text)
|
||||
.contentTransition(.numericText())
|
||||
|
||||
if totalSeconds > 0 {
|
||||
Text(formatDurationCompact(totalSeconds))
|
||||
.font(CMFonts.body(size: size * 0.06))
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mini Countdown Ring (for cards)
|
||||
|
||||
struct MiniCountdownRing: View {
|
||||
let progress: Double
|
||||
let urgency: UrgencyLevel
|
||||
var size: CGFloat = 32
|
||||
var lineWidth: CGFloat = 3
|
||||
|
||||
private var ringColor: Color {
|
||||
CMColors.urgencyColor(urgency)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(CMColors.border, lineWidth: lineWidth)
|
||||
.frame(width: size, height: size)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: 1.0 - progress)
|
||||
.stroke(
|
||||
ringColor,
|
||||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||
)
|
||||
.frame(width: size, height: size)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut(duration: 0.3), value: progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
67
ios/ChronoMind/Views/Components/FiringCard.swift
Normal file
67
ios/ChronoMind/Views/Components/FiringCard.swift
Normal file
@ -0,0 +1,67 @@
|
||||
// ── Firing Card ────────────────────────────────────────────────
|
||||
// Inline card for non-critical firing timers
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct FiringCard: View {
|
||||
let timer: CMTimer
|
||||
|
||||
@EnvironmentObject var store: TimerStore
|
||||
@State private var pulse = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: CMSpacing.md) {
|
||||
HStack {
|
||||
UrgencyBadge(urgency: timer.urgency)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("NOW")
|
||||
.font(CMFonts.body(size: 12, weight: .bold))
|
||||
.foregroundStyle(CMColors.urgencyColor(timer.urgency))
|
||||
.opacity(pulse ? 1.0 : 0.5)
|
||||
.animation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true), value: pulse)
|
||||
}
|
||||
|
||||
Text(timer.label)
|
||||
.font(CMFonts.body(size: 20, weight: .bold))
|
||||
.foregroundStyle(CMColors.text)
|
||||
|
||||
HStack(spacing: CMSpacing.md) {
|
||||
Button {
|
||||
HapticEngine.tap()
|
||||
store.snooze(timer.id, minutes: 5)
|
||||
} label: {
|
||||
Label("Snooze 5m", systemImage: "moon.zzz")
|
||||
.font(CMFonts.body(size: 14, weight: .medium))
|
||||
.foregroundStyle(CMColors.text)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, CMSpacing.md)
|
||||
.background(CMColors.surfaceHover)
|
||||
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
|
||||
}
|
||||
|
||||
Button {
|
||||
HapticEngine.tap()
|
||||
store.dismiss(timer.id)
|
||||
} label: {
|
||||
Text("Dismiss")
|
||||
.font(CMFonts.body(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, CMSpacing.md)
|
||||
.background(CMColors.urgencyColor(timer.urgency))
|
||||
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(CMSpacing.lg)
|
||||
.background(CMColors.urgencyBg(timer.urgency))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: CMRadius.lg)
|
||||
.stroke(CMColors.urgencyBorder(timer.urgency), lineWidth: 2)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: CMRadius.lg))
|
||||
.onAppear { pulse = true }
|
||||
}
|
||||
}
|
||||
255
ios/ChronoMind/Views/Components/TimerCard.swift
Normal file
255
ios/ChronoMind/Views/Components/TimerCard.swift
Normal file
@ -0,0 +1,255 @@
|
||||
// ── 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
ios/ChronoMind/Views/Components/TimerListSection.swift
Normal file
23
ios/ChronoMind/Views/Components/TimerListSection.swift
Normal file
@ -0,0 +1,23 @@
|
||||
// ── Timer List Section ─────────────────────────────────────────
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TimerListSection: View {
|
||||
let title: String
|
||||
let timers: [CMTimer]
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: CMSpacing.md) {
|
||||
Text(title.uppercased())
|
||||
.font(CMFonts.body(size: 11, weight: .bold))
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
.tracking(1.5)
|
||||
.padding(.leading, CMSpacing.xs)
|
||||
|
||||
ForEach(timers) { timer in
|
||||
TimerCard(timer: timer, now: now)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
ios/ChronoMind/Views/Components/UrgencyBadge.swift
Normal file
20
ios/ChronoMind/Views/Components/UrgencyBadge.swift
Normal file
@ -0,0 +1,20 @@
|
||||
// ── Urgency Badge ──────────────────────────────────────────────
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct UrgencyBadge: View {
|
||||
let urgency: UrgencyLevel
|
||||
|
||||
var body: some View {
|
||||
Text(getUrgencyConfig(urgency).label.uppercased())
|
||||
.font(CMFonts.body(size: 10, weight: .bold))
|
||||
.foregroundStyle(CMColors.urgencyColor(urgency))
|
||||
.padding(.horizontal, CMSpacing.sm)
|
||||
.padding(.vertical, CMSpacing.xxs)
|
||||
.background(CMColors.urgencyBg(urgency))
|
||||
.clipShape(Capsule())
|
||||
.overlay(
|
||||
Capsule().stroke(CMColors.urgencyBorder(urgency), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
451
ios/ChronoMind/Views/CreateTimer/CreateTimerView.swift
Normal file
451
ios/ChronoMind/Views/CreateTimer/CreateTimerView.swift
Normal file
@ -0,0 +1,451 @@
|
||||
// ── Create Timer View ──────────────────────────────────────────
|
||||
// Modal for creating alarm, countdown, or pomodoro timers
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CreateTimerView: View {
|
||||
@EnvironmentObject var store: TimerStore
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var timerType: CMTimerType = .countdown
|
||||
@State private var label = ""
|
||||
@State private var urgency: UrgencyLevel = .standard
|
||||
@State private var cascadePreset: CascadePreset = .standard
|
||||
@State private var category = ""
|
||||
|
||||
// Countdown fields
|
||||
@State private var hours = 0
|
||||
@State private var minutes = 25
|
||||
@State private var seconds = 0
|
||||
|
||||
// Alarm fields
|
||||
@State private var alarmDate = Date().addingTimeInterval(3600) // 1h from now
|
||||
|
||||
// Pomodoro fields
|
||||
@State private var workMinutes = 25
|
||||
@State private var breakMinutes = 5
|
||||
@State private var longBreakMinutes = 15
|
||||
@State private var rounds = 4
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
CMColors.bg.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: CMSpacing.xl) {
|
||||
// Timer type selector
|
||||
timerTypePicker
|
||||
|
||||
// Label
|
||||
CMTextField(
|
||||
title: "Label",
|
||||
placeholder: placeholderForType,
|
||||
text: $label
|
||||
)
|
||||
|
||||
// Type-specific fields
|
||||
switch timerType {
|
||||
case .countdown:
|
||||
countdownPicker
|
||||
case .alarm:
|
||||
alarmPicker
|
||||
case .pomodoro:
|
||||
pomodoroPicker
|
||||
case .event:
|
||||
alarmPicker // same UI for now
|
||||
}
|
||||
|
||||
// Urgency selector
|
||||
urgencySelector
|
||||
|
||||
// Cascade preset
|
||||
cascadeSelector
|
||||
|
||||
// Time blindness reference
|
||||
if let ref = timeReference {
|
||||
HStack {
|
||||
Image(systemName: "lightbulb.fill")
|
||||
.foregroundStyle(CMColors.standard)
|
||||
.font(.caption)
|
||||
Text(ref)
|
||||
.font(CMFonts.body(size: 13))
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
.italic()
|
||||
}
|
||||
.padding(CMSpacing.md)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(CMColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
|
||||
}
|
||||
}
|
||||
.padding(CMSpacing.lg)
|
||||
}
|
||||
}
|
||||
.navigationTitle("New Timer")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
.foregroundStyle(CMColors.textSecondary)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Create") {
|
||||
createTimer()
|
||||
dismiss()
|
||||
}
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(CMColors.accent)
|
||||
.disabled(!isValid)
|
||||
}
|
||||
}
|
||||
.toolbarBackground(CMColors.surface, for: .navigationBar)
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timer Type Picker
|
||||
|
||||
private var timerTypePicker: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach([CMTimerType.countdown, .alarm, .pomodoro], id: \.self) { type in
|
||||
Button {
|
||||
HapticEngine.selection()
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
timerType = type
|
||||
}
|
||||
} label: {
|
||||
Text(type.label)
|
||||
.font(CMFonts.body(size: 14, weight: timerType == type ? .semibold : .regular))
|
||||
.foregroundStyle(timerType == type ? CMColors.text : CMColors.textMuted)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, CMSpacing.md)
|
||||
.background(timerType == type ? CMColors.accent.opacity(0.15) : Color.clear)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(CMColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: CMRadius.sm)
|
||||
.stroke(CMColors.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Countdown Picker
|
||||
|
||||
private var countdownPicker: some View {
|
||||
VStack(alignment: .leading, spacing: CMSpacing.sm) {
|
||||
Text("Duration")
|
||||
.font(CMFonts.body(size: 13, weight: .medium))
|
||||
.foregroundStyle(CMColors.textSecondary)
|
||||
|
||||
HStack(spacing: CMSpacing.md) {
|
||||
durationWheel(title: "Hours", value: $hours, range: 0...23)
|
||||
durationWheel(title: "Min", value: $minutes, range: 0...59)
|
||||
durationWheel(title: "Sec", value: $seconds, range: 0...59)
|
||||
}
|
||||
.frame(height: 150)
|
||||
}
|
||||
}
|
||||
|
||||
private func durationWheel(title: String, value: Binding<Int>, range: ClosedRange<Int>) -> some View {
|
||||
VStack(spacing: CMSpacing.xs) {
|
||||
Text(title)
|
||||
.font(CMFonts.body(size: 11, weight: .medium))
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
|
||||
Picker(title, selection: value) {
|
||||
ForEach(range, id: \.self) { n in
|
||||
Text("\(n)")
|
||||
.font(CMFonts.mono(size: 20))
|
||||
.foregroundStyle(CMColors.text)
|
||||
.tag(n)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.wheel)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
// MARK: - Alarm Picker
|
||||
|
||||
private var alarmPicker: some View {
|
||||
VStack(alignment: .leading, spacing: CMSpacing.sm) {
|
||||
Text("Date & Time")
|
||||
.font(CMFonts.body(size: 13, weight: .medium))
|
||||
.foregroundStyle(CMColors.textSecondary)
|
||||
|
||||
DatePicker(
|
||||
"Target Time",
|
||||
selection: $alarmDate,
|
||||
in: Date()...,
|
||||
displayedComponents: [.date, .hourAndMinute]
|
||||
)
|
||||
.datePickerStyle(.graphical)
|
||||
.tint(CMColors.accent)
|
||||
.colorScheme(.dark)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pomodoro Picker
|
||||
|
||||
private var pomodoroPicker: some View {
|
||||
VStack(alignment: .leading, spacing: CMSpacing.md) {
|
||||
Text("Pomodoro Settings")
|
||||
.font(CMFonts.body(size: 13, weight: .medium))
|
||||
.foregroundStyle(CMColors.textSecondary)
|
||||
|
||||
CMStepper(title: "Work", value: $workMinutes, range: 5...90, suffix: "min")
|
||||
CMStepper(title: "Break", value: $breakMinutes, range: 1...30, suffix: "min")
|
||||
CMStepper(title: "Long Break", value: $longBreakMinutes, range: 5...60, suffix: "min")
|
||||
CMStepper(title: "Rounds", value: $rounds, range: 1...12, suffix: "")
|
||||
|
||||
// Total time
|
||||
let totalMinutes = (workMinutes * rounds) + (breakMinutes * (rounds - 1)) + longBreakMinutes
|
||||
Text("Total: \(formatDurationCompact(TimeInterval(totalMinutes * 60)))")
|
||||
.font(CMFonts.body(size: 13))
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Urgency Selector
|
||||
|
||||
private var urgencySelector: some View {
|
||||
VStack(alignment: .leading, spacing: CMSpacing.sm) {
|
||||
Text("Urgency")
|
||||
.font(CMFonts.body(size: 13, weight: .medium))
|
||||
.foregroundStyle(CMColors.textSecondary)
|
||||
|
||||
HStack(spacing: CMSpacing.sm) {
|
||||
ForEach(UrgencyLevel.allCases) { level in
|
||||
Button {
|
||||
HapticEngine.selection()
|
||||
urgency = level
|
||||
} label: {
|
||||
VStack(spacing: CMSpacing.xxs) {
|
||||
Circle()
|
||||
.fill(CMColors.urgencyColor(level))
|
||||
.frame(width: urgency == level ? 24 : 16, height: urgency == level ? 24 : 16)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.white, lineWidth: urgency == level ? 2 : 0)
|
||||
)
|
||||
|
||||
Text(getUrgencyConfig(level).label)
|
||||
.font(CMFonts.body(size: 10, weight: urgency == level ? .bold : .regular))
|
||||
.foregroundStyle(urgency == level ? CMColors.text : CMColors.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.15), value: urgency)
|
||||
}
|
||||
}
|
||||
.padding(CMSpacing.md)
|
||||
.background(CMColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cascade Selector
|
||||
|
||||
private var cascadeSelector: some View {
|
||||
VStack(alignment: .leading, spacing: CMSpacing.sm) {
|
||||
Text("Pre-Warning Cascade")
|
||||
.font(CMFonts.body(size: 13, weight: .medium))
|
||||
.foregroundStyle(CMColors.textSecondary)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: CMSpacing.sm) {
|
||||
ForEach(CascadePreset.allCases.filter { $0 != .custom }) { preset in
|
||||
Button {
|
||||
HapticEngine.selection()
|
||||
cascadePreset = preset
|
||||
} label: {
|
||||
VStack(spacing: CMSpacing.xxs) {
|
||||
Text(preset.label)
|
||||
.font(CMFonts.body(size: 13, weight: cascadePreset == preset ? .semibold : .regular))
|
||||
.foregroundStyle(cascadePreset == preset ? CMColors.text : CMColors.textMuted)
|
||||
|
||||
let intervals = preset.defaultIntervals
|
||||
if intervals.isEmpty {
|
||||
Text("—")
|
||||
.font(CMFonts.body(size: 10))
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
} else {
|
||||
Text(intervals.map { formatMinutesBefore($0) }.joined(separator: ", "))
|
||||
.font(CMFonts.body(size: 9))
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, CMSpacing.md)
|
||||
.padding(.vertical, CMSpacing.sm)
|
||||
.background(cascadePreset == preset ? CMColors.accent.opacity(0.15) : CMColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: CMRadius.sm)
|
||||
.stroke(cascadePreset == preset ? CMColors.accent.opacity(0.4) : CMColors.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private var placeholderForType: String {
|
||||
switch timerType {
|
||||
case .countdown: return "e.g., Pasta timer"
|
||||
case .alarm: return "e.g., Team standup"
|
||||
case .pomodoro: return "e.g., Deep work session"
|
||||
case .event: return "e.g., Vacation countdown"
|
||||
}
|
||||
}
|
||||
|
||||
private var timeReference: String? {
|
||||
switch timerType {
|
||||
case .countdown:
|
||||
let totalSeconds = (hours * 3600) + (minutes * 60) + seconds
|
||||
return getTimeReference(seconds: TimeInterval(totalSeconds))
|
||||
case .pomodoro:
|
||||
return getTimeReference(minutes: workMinutes)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private var isValid: Bool {
|
||||
switch timerType {
|
||||
case .countdown:
|
||||
return (hours + minutes + seconds) > 0
|
||||
case .alarm, .event:
|
||||
return alarmDate > Date()
|
||||
case .pomodoro:
|
||||
return workMinutes > 0
|
||||
}
|
||||
}
|
||||
|
||||
private func createTimer() {
|
||||
let timerLabel = label.isEmpty ? placeholderForType.replacingOccurrences(of: "e.g., ", with: "") : label
|
||||
let cascadeConfig = CascadeConfig(preset: cascadePreset, intervals: [])
|
||||
|
||||
switch timerType {
|
||||
case .countdown:
|
||||
let totalSeconds = TimeInterval((hours * 3600) + (minutes * 60) + seconds)
|
||||
let _ = store.addCountdown(CreateCountdownParams(
|
||||
label: timerLabel,
|
||||
durationSeconds: totalSeconds,
|
||||
urgency: urgency,
|
||||
cascade: cascadeConfig,
|
||||
category: category.isEmpty ? nil : category
|
||||
))
|
||||
|
||||
case .alarm, .event:
|
||||
let _ = store.addAlarm(CreateAlarmParams(
|
||||
label: timerLabel,
|
||||
targetTime: alarmDate,
|
||||
urgency: urgency,
|
||||
cascade: cascadeConfig,
|
||||
category: category.isEmpty ? nil : category
|
||||
))
|
||||
|
||||
case .pomodoro:
|
||||
let config = PomodoroConfig(
|
||||
workMinutes: workMinutes,
|
||||
breakMinutes: breakMinutes,
|
||||
longBreakMinutes: longBreakMinutes,
|
||||
rounds: rounds
|
||||
)
|
||||
let _ = store.addPomodoro(CreatePomodoroParams(
|
||||
label: timerLabel,
|
||||
config: config,
|
||||
urgency: urgency
|
||||
))
|
||||
}
|
||||
|
||||
HapticEngine.tap()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CM Text Field
|
||||
|
||||
struct CMTextField: View {
|
||||
let title: String
|
||||
let placeholder: String
|
||||
@Binding var text: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: CMSpacing.sm) {
|
||||
Text(title)
|
||||
.font(CMFonts.body(size: 13, weight: .medium))
|
||||
.foregroundStyle(CMColors.textSecondary)
|
||||
|
||||
TextField(placeholder, text: $text)
|
||||
.font(CMFonts.body(size: 16))
|
||||
.foregroundStyle(CMColors.text)
|
||||
.padding(CMSpacing.md)
|
||||
.background(CMColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: CMRadius.sm)
|
||||
.stroke(CMColors.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CM Stepper
|
||||
|
||||
struct CMStepper: View {
|
||||
let title: String
|
||||
@Binding var value: Int
|
||||
let range: ClosedRange<Int>
|
||||
let suffix: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(CMFonts.body(size: 14))
|
||||
.foregroundStyle(CMColors.textSecondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: CMSpacing.md) {
|
||||
Button {
|
||||
if value > range.lowerBound {
|
||||
value -= 1
|
||||
HapticEngine.selection()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "minus.circle.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(value > range.lowerBound ? CMColors.textSecondary : CMColors.textMuted)
|
||||
}
|
||||
.disabled(value <= range.lowerBound)
|
||||
|
||||
Text("\(value)\(suffix.isEmpty ? "" : " \(suffix)")")
|
||||
.font(CMFonts.mono(size: 16, weight: .semibold))
|
||||
.foregroundStyle(CMColors.text)
|
||||
.frame(minWidth: 60)
|
||||
|
||||
Button {
|
||||
if value < range.upperBound {
|
||||
value += 1
|
||||
HapticEngine.selection()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(value < range.upperBound ? CMColors.accent : CMColors.textMuted)
|
||||
}
|
||||
.disabled(value >= range.upperBound)
|
||||
}
|
||||
}
|
||||
.padding(CMSpacing.md)
|
||||
.background(CMColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
|
||||
}
|
||||
}
|
||||
92
ios/ChronoMind/Views/CreateTimer/QuickTimerSheet.swift
Normal file
92
ios/ChronoMind/Views/CreateTimer/QuickTimerSheet.swift
Normal file
@ -0,0 +1,92 @@
|
||||
// ── Quick Timer Sheet ──────────────────────────────────────────
|
||||
// Bottom sheet with one-tap preset timers
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct QuickTimerSheet: View {
|
||||
@EnvironmentObject var store: TimerStore
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private let presets: [(String, Int, String, UrgencyLevel)] = [
|
||||
("1 min", 1, "timer", .gentle),
|
||||
("3 min", 3, "timer", .gentle),
|
||||
("5 min", 5, "timer", .gentle),
|
||||
("10 min", 10, "timer", .standard),
|
||||
("15 min", 15, "timer", .standard),
|
||||
("20 min", 20, "timer", .standard),
|
||||
("25 min", 25, "Pomodoro", .standard),
|
||||
("30 min", 30, "timer", .standard),
|
||||
("45 min", 45, "timer", .important),
|
||||
("1 hour", 60, "timer", .important),
|
||||
("90 min", 90, "timer", .important),
|
||||
("2 hours", 120, "timer", .important),
|
||||
]
|
||||
|
||||
let columns = [
|
||||
GridItem(.flexible(), spacing: CMSpacing.md),
|
||||
GridItem(.flexible(), spacing: CMSpacing.md),
|
||||
GridItem(.flexible(), spacing: CMSpacing.md),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
CMColors.bg.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: CMSpacing.md) {
|
||||
ForEach(presets, id: \.0) { label, minutes, name, urgency in
|
||||
Button {
|
||||
HapticEngine.tap()
|
||||
let _ = store.addCountdown(CreateCountdownParams(
|
||||
label: "\(label) \(name)",
|
||||
durationSeconds: TimeInterval(minutes * 60),
|
||||
urgency: urgency,
|
||||
cascade: CascadeConfig(
|
||||
preset: minutes >= 30 ? .light : .minimal,
|
||||
intervals: []
|
||||
)
|
||||
))
|
||||
dismiss()
|
||||
} label: {
|
||||
VStack(spacing: CMSpacing.xs) {
|
||||
Text(label)
|
||||
.font(CMFonts.mono(size: 18, weight: .semibold))
|
||||
.foregroundStyle(CMColors.text)
|
||||
|
||||
// Time reference
|
||||
if let ref = getTimeReference(minutes: minutes) {
|
||||
Text(ref.replacingOccurrences(of: "About as long as ", with: ""))
|
||||
.font(CMFonts.body(size: 10))
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, CMSpacing.lg)
|
||||
.background(CMColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: CMRadius.md))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: CMRadius.md)
|
||||
.stroke(CMColors.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(CMSpacing.lg)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Quick Timer")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
.foregroundStyle(CMColors.textSecondary)
|
||||
}
|
||||
}
|
||||
.toolbarBackground(CMColors.surface, for: .navigationBar)
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
}
|
||||
238
web/src/lib/store.test.ts
Normal file
238
web/src/lib/store.test.ts
Normal file
@ -0,0 +1,238 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock browser APIs that the store depends on
|
||||
vi.mock('./sounds', () => ({
|
||||
playAlarmSound: vi.fn(),
|
||||
playWarningChime: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./notifications', () => ({
|
||||
sendFireNotification: vi.fn(),
|
||||
sendWarningNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock zustand persist middleware as a pass-through (no actual storage in tests)
|
||||
vi.mock('zustand/middleware', async () => {
|
||||
const actual = await vi.importActual<typeof import('zustand/middleware')>('zustand/middleware');
|
||||
return {
|
||||
...actual,
|
||||
persist: (fn: unknown) => fn,
|
||||
createJSONStorage: actual.createJSONStorage,
|
||||
};
|
||||
});
|
||||
|
||||
import { useTimerStore } from './store';
|
||||
|
||||
describe('TimerStore', () => {
|
||||
beforeEach(() => {
|
||||
useTimerStore.setState({ timers: [], now: Date.now() });
|
||||
});
|
||||
|
||||
describe('addAlarm', () => {
|
||||
it('creates an alarm timer and adds it to the store', () => {
|
||||
const { addAlarm } = useTimerStore.getState();
|
||||
const timer = addAlarm({
|
||||
label: 'Test Alarm',
|
||||
targetTime: Date.now() + 60_000,
|
||||
urgency: 'standard',
|
||||
cascade: { preset: 'light', intervals: [] },
|
||||
});
|
||||
|
||||
expect(timer.type).toBe('alarm');
|
||||
expect(timer.label).toBe('Test Alarm');
|
||||
expect(timer.state).toBe('active');
|
||||
|
||||
const { timers } = useTimerStore.getState();
|
||||
expect(timers).toHaveLength(1);
|
||||
expect(timers[0].id).toBe(timer.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addCountdown', () => {
|
||||
it('creates a countdown timer', () => {
|
||||
const { addCountdown } = useTimerStore.getState();
|
||||
const timer = addCountdown({
|
||||
label: '5 min timer',
|
||||
durationMs: 5 * 60_000,
|
||||
urgency: 'gentle',
|
||||
cascade: { preset: 'minimal', intervals: [] },
|
||||
});
|
||||
|
||||
expect(timer.type).toBe('countdown');
|
||||
expect(timer.duration).toBe(5 * 60_000);
|
||||
expect(timer.state).toBe('active');
|
||||
|
||||
const { timers } = useTimerStore.getState();
|
||||
expect(timers).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addPomodoro', () => {
|
||||
it('creates a pomodoro timer with defaults', () => {
|
||||
const { addPomodoro } = useTimerStore.getState();
|
||||
const timer = addPomodoro();
|
||||
|
||||
expect(timer.type).toBe('pomodoro');
|
||||
expect(timer.pomodoroConfig).toBeDefined();
|
||||
expect(timer.pomodoroConfig!.workMinutes).toBe(25);
|
||||
expect(timer.pomodoroConfig!.rounds).toBe(4);
|
||||
expect(timer.state).toBe('active');
|
||||
});
|
||||
|
||||
it('creates a pomodoro timer with custom config', () => {
|
||||
const { addPomodoro } = useTimerStore.getState();
|
||||
const timer = addPomodoro({ label: 'Deep Work', config: { workMinutes: 50, breakMinutes: 10 } });
|
||||
|
||||
expect(timer.label).toBe('Deep Work');
|
||||
expect(timer.pomodoroConfig!.workMinutes).toBe(50);
|
||||
expect(timer.pomodoroConfig!.breakMinutes).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeTimer', () => {
|
||||
it('removes a timer by id', () => {
|
||||
const { addCountdown, removeTimer } = useTimerStore.getState();
|
||||
const timer = addCountdown({
|
||||
label: 'To remove',
|
||||
durationMs: 60_000,
|
||||
urgency: 'standard',
|
||||
cascade: { preset: 'none', intervals: [] },
|
||||
});
|
||||
|
||||
removeTimer(timer.id);
|
||||
const { timers } = useTimerStore.getState();
|
||||
expect(timers).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('state transitions', () => {
|
||||
it('pauses and resumes a countdown', () => {
|
||||
const { addCountdown, pause, resume } = useTimerStore.getState();
|
||||
const timer = addCountdown({
|
||||
label: 'Pausable',
|
||||
durationMs: 60_000,
|
||||
urgency: 'standard',
|
||||
cascade: { preset: 'none', intervals: [] },
|
||||
});
|
||||
|
||||
pause(timer.id);
|
||||
expect(useTimerStore.getState().timers[0].state).toBe('paused');
|
||||
|
||||
resume(timer.id);
|
||||
expect(useTimerStore.getState().timers[0].state).toBe('active');
|
||||
});
|
||||
|
||||
it('dismisses a timer', () => {
|
||||
const { addCountdown, dismiss } = useTimerStore.getState();
|
||||
const timer = addCountdown({
|
||||
label: 'Dismissable',
|
||||
durationMs: 60_000,
|
||||
urgency: 'standard',
|
||||
cascade: { preset: 'none', intervals: [] },
|
||||
});
|
||||
|
||||
dismiss(timer.id);
|
||||
expect(useTimerStore.getState().timers[0].state).toBe('dismissed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tick', () => {
|
||||
it('fires a timer when its target time is reached', () => {
|
||||
const { addCountdown, tick } = useTimerStore.getState();
|
||||
const timer = addCountdown({
|
||||
label: 'Fire me',
|
||||
durationMs: 1_000, // 1 second
|
||||
urgency: 'standard',
|
||||
cascade: { preset: 'none', intervals: [] },
|
||||
});
|
||||
|
||||
// Tick past the target time
|
||||
const futureTime = timer.targetTime + 100;
|
||||
tick(futureTime);
|
||||
|
||||
const updated = useTimerStore.getState().timers[0];
|
||||
expect(updated.state).toBe('firing');
|
||||
});
|
||||
|
||||
it('does not fire a timer before its target time', () => {
|
||||
const { addCountdown, tick } = useTimerStore.getState();
|
||||
const timer = addCountdown({
|
||||
label: 'Not yet',
|
||||
durationMs: 60_000,
|
||||
urgency: 'standard',
|
||||
cascade: { preset: 'none', intervals: [] },
|
||||
});
|
||||
|
||||
// Tick before target time
|
||||
tick(timer.targetTime - 30_000);
|
||||
|
||||
const updated = useTimerStore.getState().timers[0];
|
||||
expect(updated.state).toBe('active');
|
||||
});
|
||||
|
||||
it('updates now on every tick', () => {
|
||||
const { tick } = useTimerStore.getState();
|
||||
const time = Date.now() + 5000;
|
||||
tick(time);
|
||||
expect(useTimerStore.getState().now).toBe(time);
|
||||
});
|
||||
});
|
||||
|
||||
describe('helpers', () => {
|
||||
it('getTimer returns correct timer', () => {
|
||||
const { addCountdown, getTimer } = useTimerStore.getState();
|
||||
const timer = addCountdown({
|
||||
label: 'Find me',
|
||||
durationMs: 60_000,
|
||||
urgency: 'standard',
|
||||
cascade: { preset: 'none', intervals: [] },
|
||||
});
|
||||
|
||||
const found = getTimer(timer.id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.label).toBe('Find me');
|
||||
});
|
||||
|
||||
it('getActiveTimers returns only active timers', () => {
|
||||
const { addCountdown, dismiss, getActiveTimers } = useTimerStore.getState();
|
||||
addCountdown({
|
||||
label: 'Active',
|
||||
durationMs: 60_000,
|
||||
urgency: 'standard',
|
||||
cascade: { preset: 'none', intervals: [] },
|
||||
});
|
||||
const t2 = addCountdown({
|
||||
label: 'To dismiss',
|
||||
durationMs: 60_000,
|
||||
urgency: 'standard',
|
||||
cascade: { preset: 'none', intervals: [] },
|
||||
});
|
||||
|
||||
dismiss(t2.id);
|
||||
|
||||
const active = getActiveTimers();
|
||||
expect(active).toHaveLength(1);
|
||||
expect(active[0].label).toBe('Active');
|
||||
});
|
||||
|
||||
it('getNextFiringTimer returns timer with soonest target', () => {
|
||||
const { addCountdown, getNextFiringTimer } = useTimerStore.getState();
|
||||
addCountdown({
|
||||
label: 'Later',
|
||||
durationMs: 120_000,
|
||||
urgency: 'standard',
|
||||
cascade: { preset: 'none', intervals: [] },
|
||||
});
|
||||
addCountdown({
|
||||
label: 'Sooner',
|
||||
durationMs: 30_000,
|
||||
urgency: 'standard',
|
||||
cascade: { preset: 'none', intervals: [] },
|
||||
});
|
||||
|
||||
const next = getNextFiringTimer();
|
||||
expect(next).toBeDefined();
|
||||
expect(next!.label).toBe('Sooner');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user