360 lines
14 KiB
Swift
360 lines
14 KiB
Swift
// ── Pomodoro View ──────────────────────────────────────────────
|
|
// Focus session with countdown ring, round tracking, and auto-transitions
|
|
|
|
import SwiftUI
|
|
|
|
struct PomodoroView: View {
|
|
@EnvironmentObject var store: TimerStore
|
|
@State private var showCreatePomodoro = false
|
|
|
|
private var activePomodoro: CMTimer? {
|
|
store.timers.first { $0.type == .pomodoro && [.active, .warning, .paused, .firing].contains($0.state) }
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
CMColors.bg.ignoresSafeArea()
|
|
|
|
if let timer = activePomodoro {
|
|
ActivePomodoroView(timer: timer)
|
|
} else {
|
|
IdlePomodoroView(onStart: { showCreatePomodoro = true })
|
|
}
|
|
}
|
|
.navigationTitle("Focus")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbarBackground(CMColors.surface, for: .navigationBar)
|
|
.toolbarColorScheme(.dark, for: .navigationBar)
|
|
.sheet(isPresented: $showCreatePomodoro) {
|
|
PomodoroSetupSheet()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Idle State
|
|
|
|
struct IdlePomodoroView: View {
|
|
let onStart: () -> Void
|
|
@EnvironmentObject var store: TimerStore
|
|
|
|
var body: some View {
|
|
VStack(spacing: CMSpacing.xxl) {
|
|
Spacer()
|
|
|
|
// Decorative ring
|
|
ZStack {
|
|
Circle()
|
|
.stroke(CMColors.border, lineWidth: 12)
|
|
.frame(width: 220, height: 220)
|
|
|
|
VStack(spacing: CMSpacing.sm) {
|
|
Image(systemName: "target")
|
|
.font(.system(size: 40))
|
|
.foregroundStyle(CMColors.accent)
|
|
|
|
Text("Ready to focus?")
|
|
.font(CMFonts.body(size: 18, weight: .semibold))
|
|
.foregroundStyle(CMColors.text)
|
|
}
|
|
}
|
|
|
|
Text("Start a Pomodoro session to stay focused\nwith structured work and break intervals")
|
|
.font(CMFonts.body(size: 14))
|
|
.foregroundStyle(CMColors.textMuted)
|
|
.multilineTextAlignment(.center)
|
|
|
|
// Quick start buttons
|
|
VStack(spacing: CMSpacing.md) {
|
|
Button {
|
|
HapticEngine.tap()
|
|
let _ = store.addPomodoro()
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "play.fill")
|
|
Text("Start 25/5 Session")
|
|
}
|
|
.font(CMFonts.body(size: 16, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, CMSpacing.lg)
|
|
.background(CMColors.accent)
|
|
.clipShape(RoundedRectangle(cornerRadius: CMRadius.md))
|
|
}
|
|
|
|
Button {
|
|
onStart()
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "slider.horizontal.3")
|
|
Text("Custom Session")
|
|
}
|
|
.font(CMFonts.body(size: 16, weight: .medium))
|
|
.foregroundStyle(CMColors.accent)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, CMSpacing.lg)
|
|
.background(CMColors.surface)
|
|
.clipShape(RoundedRectangle(cornerRadius: CMRadius.md))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: CMRadius.md)
|
|
.stroke(CMColors.accent.opacity(0.3), lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
.padding(.horizontal, CMSpacing.xxl)
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Active Pomodoro
|
|
|
|
struct ActivePomodoroView: View {
|
|
let timer: CMTimer
|
|
@EnvironmentObject var store: TimerStore
|
|
|
|
private var progress: Double {
|
|
guard let duration = timer.duration, duration > 0 else { return 0 }
|
|
let remaining = getRemainingSeconds(timer, now: store.now)
|
|
return 1.0 - (remaining / duration)
|
|
}
|
|
|
|
private var phaseLabel: String {
|
|
guard let state = timer.pomodoroState else { return "" }
|
|
if state.isLongBreak { return "Long Break" }
|
|
if state.isBreak { return "Break" }
|
|
return "Work"
|
|
}
|
|
|
|
private var phaseIcon: String {
|
|
guard let state = timer.pomodoroState else { return "target" }
|
|
if state.isBreak || state.isLongBreak { return "cup.and.saucer.fill" }
|
|
return "brain.head.profile"
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: CMSpacing.xl) {
|
|
Spacer()
|
|
|
|
// Phase indicator
|
|
HStack(spacing: CMSpacing.sm) {
|
|
Image(systemName: phaseIcon)
|
|
.foregroundStyle(CMColors.accent)
|
|
Text(phaseLabel)
|
|
.font(CMFonts.body(size: 16, weight: .semibold))
|
|
.foregroundStyle(CMColors.text)
|
|
}
|
|
|
|
// Countdown ring
|
|
CountdownRing(
|
|
progress: progress,
|
|
urgency: timer.urgency,
|
|
remainingSeconds: getRemainingSeconds(timer, now: store.now),
|
|
totalSeconds: timer.duration ?? 0
|
|
)
|
|
|
|
// Timer label
|
|
Text(timer.label)
|
|
.font(CMFonts.body(size: 18, weight: .medium))
|
|
.foregroundStyle(CMColors.textSecondary)
|
|
|
|
// Round indicator
|
|
if let state = timer.pomodoroState, let config = timer.pomodoroConfig {
|
|
PomodoroRoundIndicator(
|
|
currentRound: state.currentRound,
|
|
totalRounds: config.rounds,
|
|
completedRounds: state.completedRounds,
|
|
isBreak: state.isBreak || state.isLongBreak
|
|
)
|
|
}
|
|
|
|
// Time blindness aid
|
|
let remaining = getRemainingSeconds(timer, now: store.now)
|
|
if let ref = getTimeReference(seconds: remaining) {
|
|
Text(ref)
|
|
.font(CMFonts.body(size: 13))
|
|
.foregroundStyle(CMColors.textMuted)
|
|
.italic()
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Controls
|
|
HStack(spacing: CMSpacing.xxl) {
|
|
// Cancel
|
|
Button {
|
|
HapticEngine.tap()
|
|
store.dismiss(timer.id)
|
|
} label: {
|
|
VStack(spacing: CMSpacing.xs) {
|
|
Image(systemName: "xmark")
|
|
.font(.title3)
|
|
Text("Cancel")
|
|
.font(CMFonts.body(size: 12))
|
|
}
|
|
.foregroundStyle(CMColors.textMuted)
|
|
.frame(width: 70)
|
|
}
|
|
|
|
// Pause / Resume / Advance
|
|
if timer.state == .firing {
|
|
Button {
|
|
HapticEngine.tap()
|
|
store.advancePom(timer.id)
|
|
} label: {
|
|
VStack(spacing: CMSpacing.xs) {
|
|
Image(systemName: "forward.fill")
|
|
.font(.title)
|
|
Text("Next")
|
|
.font(CMFonts.body(size: 12))
|
|
}
|
|
.foregroundStyle(.white)
|
|
.frame(width: 80, height: 80)
|
|
.background(CMColors.accent)
|
|
.clipShape(Circle())
|
|
}
|
|
} else if timer.state == .paused {
|
|
Button {
|
|
HapticEngine.tap()
|
|
store.resume(timer.id)
|
|
} label: {
|
|
VStack(spacing: CMSpacing.xs) {
|
|
Image(systemName: "play.fill")
|
|
.font(.title)
|
|
Text("Resume")
|
|
.font(CMFonts.body(size: 12))
|
|
}
|
|
.foregroundStyle(.white)
|
|
.frame(width: 80, height: 80)
|
|
.background(CMColors.accent)
|
|
.clipShape(Circle())
|
|
}
|
|
} else {
|
|
Button {
|
|
HapticEngine.tap()
|
|
store.pause(timer.id)
|
|
} label: {
|
|
VStack(spacing: CMSpacing.xs) {
|
|
Image(systemName: "pause.fill")
|
|
.font(.title)
|
|
Text("Pause")
|
|
.font(CMFonts.body(size: 12))
|
|
}
|
|
.foregroundStyle(.white)
|
|
.frame(width: 80, height: 80)
|
|
.background(CMColors.surface)
|
|
.clipShape(Circle())
|
|
.overlay(Circle().stroke(CMColors.border, lineWidth: 2))
|
|
}
|
|
}
|
|
|
|
// Skip to break / work
|
|
Button {
|
|
HapticEngine.tap()
|
|
store.advancePom(timer.id)
|
|
} label: {
|
|
VStack(spacing: CMSpacing.xs) {
|
|
Image(systemName: "forward.end.fill")
|
|
.font(.title3)
|
|
Text("Skip")
|
|
.font(CMFonts.body(size: 12))
|
|
}
|
|
.foregroundStyle(CMColors.textMuted)
|
|
.frame(width: 70)
|
|
}
|
|
}
|
|
.padding(.bottom, CMSpacing.xxl)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Pomodoro Setup Sheet
|
|
|
|
struct PomodoroSetupSheet: View {
|
|
@EnvironmentObject var store: TimerStore
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@State private var label = "Focus Session"
|
|
@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) {
|
|
CMTextField(title: "Session Label", placeholder: "e.g., Deep work", text: $label)
|
|
|
|
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 * max(0, rounds - 1)) + longBreakMinutes
|
|
HStack {
|
|
Text("Total session time:")
|
|
.font(CMFonts.body(size: 14))
|
|
.foregroundStyle(CMColors.textSecondary)
|
|
Spacer()
|
|
Text(formatDurationCompact(TimeInterval(totalMinutes * 60)))
|
|
.font(CMFonts.mono(size: 16, weight: .semibold))
|
|
.foregroundStyle(CMColors.accent)
|
|
}
|
|
.padding(CMSpacing.md)
|
|
.background(CMColors.surface)
|
|
.clipShape(RoundedRectangle(cornerRadius: CMRadius.sm))
|
|
|
|
// Time reference
|
|
if let ref = getTimeReference(minutes: totalMinutes) {
|
|
HStack {
|
|
Image(systemName: "lightbulb.fill")
|
|
.foregroundStyle(CMColors.standard)
|
|
.font(.caption)
|
|
Text(ref)
|
|
.font(CMFonts.body(size: 13))
|
|
.foregroundStyle(CMColors.textMuted)
|
|
.italic()
|
|
}
|
|
}
|
|
}
|
|
.padding(CMSpacing.lg)
|
|
}
|
|
}
|
|
.navigationTitle("Pomodoro Setup")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") { dismiss() }
|
|
.foregroundStyle(CMColors.textSecondary)
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Start") {
|
|
let config = PomodoroConfig(
|
|
workMinutes: workMinutes,
|
|
breakMinutes: breakMinutes,
|
|
longBreakMinutes: longBreakMinutes,
|
|
rounds: rounds
|
|
)
|
|
let _ = store.addPomodoro(CreatePomodoroParams(
|
|
label: label,
|
|
config: config
|
|
))
|
|
dismiss()
|
|
}
|
|
.font(.body.weight(.semibold))
|
|
.foregroundStyle(CMColors.accent)
|
|
}
|
|
}
|
|
.toolbarBackground(CMColors.surface, for: .navigationBar)
|
|
.toolbarColorScheme(.dark, for: .navigationBar)
|
|
}
|
|
}
|
|
}
|