68fed087-9049-446f-bfc5-3f8.../Sources/ui/PomodoroView.swift

370 lines
13 KiB
Swift
Raw Permalink Normal View History

//
// PomodoroView.swift
// plugin v0.1
//
// Big timer + start/pause/reset + today's count + focus/break adjusters.
//
import SwiftUI
struct PomodoroView: View {
@ObservedObject var store: WorkerStore
var body: some View {
feat: v0.2.0 — sleep-aware timers + auto-loop pomodoro Core change: both timers stop lying when the Mac sleeps or the user walks away. Old code measured wallclock from a stored "start" timestamp, so 4 hours of lid-closed showed up as 4 hours of "sitting" and a 25-minute focus could quietly stretch across multiple hours because Timer.scheduledTimer pauses during sleep. What changed: - Sit timer now reads CGEventSource HID idle to count only seconds with real input activity. New Sources/engine/SystemIdle.swift iterates the user-input event types and takes the min. Idle ≥ 5 min resets the accumulator (counts as a real break). Tick-gap > 30 s flags a sleep/wake and clears too — covers the case where the user unlocks and idle resets to ~0 instantly. - Pomodoro switched from "decrement counter every second" to a wallclock end-time. Persisted to UserDefaults so quit/relaunch recovers correctly: still-running phases keep counting; phases whose end-time has already passed get caught up (one focus credited if missed) and the timer returns to idle. - Auto-loop with classic 4-pack rhythm: focus → short break → focus → … on the 4th focus a long break (default 15 min) replaces the short break, then the cycle resets. Default ON; toggle pill in the settings row turns it off when the user wants manual restarts. - New cycle-progress dot row (●●○○) next to the phase badge so the user can see where they are in the 4-pack at a glance. - SitView tip text rewritten to reflect the new semantics: "只统计 你真正在键盘前的时间。离开 5 分钟以上自动归零,午休/会议不会被 算进去。" User-facing wins: - Closing the lid for lunch no longer fakes 90 minutes of sitting. - A 25-min focus that survives a sleep cycle ends accurately. - Start a focus once, the rhythm runs all morning.
2026-04-30 03:23:09 +00:00
VStack(spacing: 14) {
phaseHeader
timerRing
controls
Divider()
.background(WorkerTheme.overlay08)
.padding(.horizontal, 24)
statsRow
feat: v0.2.0 — sleep-aware timers + auto-loop pomodoro Core change: both timers stop lying when the Mac sleeps or the user walks away. Old code measured wallclock from a stored "start" timestamp, so 4 hours of lid-closed showed up as 4 hours of "sitting" and a 25-minute focus could quietly stretch across multiple hours because Timer.scheduledTimer pauses during sleep. What changed: - Sit timer now reads CGEventSource HID idle to count only seconds with real input activity. New Sources/engine/SystemIdle.swift iterates the user-input event types and takes the min. Idle ≥ 5 min resets the accumulator (counts as a real break). Tick-gap > 30 s flags a sleep/wake and clears too — covers the case where the user unlocks and idle resets to ~0 instantly. - Pomodoro switched from "decrement counter every second" to a wallclock end-time. Persisted to UserDefaults so quit/relaunch recovers correctly: still-running phases keep counting; phases whose end-time has already passed get caught up (one focus credited if missed) and the timer returns to idle. - Auto-loop with classic 4-pack rhythm: focus → short break → focus → … on the 4th focus a long break (default 15 min) replaces the short break, then the cycle resets. Default ON; toggle pill in the settings row turns it off when the user wants manual restarts. - New cycle-progress dot row (●●○○) next to the phase badge so the user can see where they are in the 4-pack at a glance. - SitView tip text rewritten to reflect the new semantics: "只统计 你真正在键盘前的时间。离开 5 分钟以上自动归零,午休/会议不会被 算进去。" User-facing wins: - Closing the lid for lunch no longer fakes 90 minutes of sitting. - A 25-min focus that survives a sleep cycle ends accurately. - Start a focus once, the rhythm runs all morning.
2026-04-30 03:23:09 +00:00
settingsRowPrimary
settingsRowSecondary
Spacer(minLength: 0)
}
.padding(.top, 8)
}
feat: v0.2.0 — sleep-aware timers + auto-loop pomodoro Core change: both timers stop lying when the Mac sleeps or the user walks away. Old code measured wallclock from a stored "start" timestamp, so 4 hours of lid-closed showed up as 4 hours of "sitting" and a 25-minute focus could quietly stretch across multiple hours because Timer.scheduledTimer pauses during sleep. What changed: - Sit timer now reads CGEventSource HID idle to count only seconds with real input activity. New Sources/engine/SystemIdle.swift iterates the user-input event types and takes the min. Idle ≥ 5 min resets the accumulator (counts as a real break). Tick-gap > 30 s flags a sleep/wake and clears too — covers the case where the user unlocks and idle resets to ~0 instantly. - Pomodoro switched from "decrement counter every second" to a wallclock end-time. Persisted to UserDefaults so quit/relaunch recovers correctly: still-running phases keep counting; phases whose end-time has already passed get caught up (one focus credited if missed) and the timer returns to idle. - Auto-loop with classic 4-pack rhythm: focus → short break → focus → … on the 4th focus a long break (default 15 min) replaces the short break, then the cycle resets. Default ON; toggle pill in the settings row turns it off when the user wants manual restarts. - New cycle-progress dot row (●●○○) next to the phase badge so the user can see where they are in the 4-pack at a glance. - SitView tip text rewritten to reflect the new semantics: "只统计 你真正在键盘前的时间。离开 5 分钟以上自动归零,午休/会议不会被 算进去。" User-facing wins: - Closing the lid for lunch no longer fakes 90 minutes of sitting. - A 25-min focus that survives a sleep cycle ends accurately. - Start a focus once, the rhythm runs all morning.
2026-04-30 03:23:09 +00:00
// MARK: - Phase header (badge + cycle dots)
private var phaseHeader: some View {
HStack(spacing: 10) {
phaseBadge
cycleDots
}
}
private var phaseBadge: some View {
HStack(spacing: 8) {
Circle()
.fill(phaseColor)
.frame(width: 8, height: 8)
.shadow(color: phaseColor.opacity(0.7), radius: 4)
Text(store.pomodoroPhase.label)
.font(.system(size: 12.5, weight: .semibold))
.foregroundColor(WorkerTheme.fg85)
}
.padding(.horizontal, 12)
.frame(height: 26)
.background(Capsule().fill(WorkerTheme.overlay06))
}
feat: v0.2.0 — sleep-aware timers + auto-loop pomodoro Core change: both timers stop lying when the Mac sleeps or the user walks away. Old code measured wallclock from a stored "start" timestamp, so 4 hours of lid-closed showed up as 4 hours of "sitting" and a 25-minute focus could quietly stretch across multiple hours because Timer.scheduledTimer pauses during sleep. What changed: - Sit timer now reads CGEventSource HID idle to count only seconds with real input activity. New Sources/engine/SystemIdle.swift iterates the user-input event types and takes the min. Idle ≥ 5 min resets the accumulator (counts as a real break). Tick-gap > 30 s flags a sleep/wake and clears too — covers the case where the user unlocks and idle resets to ~0 instantly. - Pomodoro switched from "decrement counter every second" to a wallclock end-time. Persisted to UserDefaults so quit/relaunch recovers correctly: still-running phases keep counting; phases whose end-time has already passed get caught up (one focus credited if missed) and the timer returns to idle. - Auto-loop with classic 4-pack rhythm: focus → short break → focus → … on the 4th focus a long break (default 15 min) replaces the short break, then the cycle resets. Default ON; toggle pill in the settings row turns it off when the user wants manual restarts. - New cycle-progress dot row (●●○○) next to the phase badge so the user can see where they are in the 4-pack at a glance. - SitView tip text rewritten to reflect the new semantics: "只统计 你真正在键盘前的时间。离开 5 分钟以上自动归零,午休/会议不会被 算进去。" User-facing wins: - Closing the lid for lunch no longer fakes 90 minutes of sitting. - A 25-min focus that survives a sleep cycle ends accurately. - Start a focus once, the rhythm runs all morning.
2026-04-30 03:23:09 +00:00
/// Four dots showing position in the 4-focus cycle. Filled = focus
/// completed; the last empty dot is the long-break gate. After a
/// long break the row resets to all empty (fresh cycle).
private var cycleDots: some View {
HStack(spacing: 5) {
ForEach(0..<4, id: \.self) { i in
Circle()
.fill(i < store.pomodoroCycleProgress ? WorkerTheme.tomato : WorkerTheme.overlay12)
.frame(width: 6, height: 6)
.overlay(
Circle()
.stroke(WorkerTheme.overlay18, lineWidth: 0.5)
)
}
}
.help("4 个番茄一组,第 4 个后进入长休息")
}
private var phaseColor: Color {
switch store.pomodoroPhase {
case .focus: return WorkerTheme.tomato
case .rest: return WorkerTheme.lime
case .paused: return WorkerTheme.fg55
case .idle: return WorkerTheme.fg40
}
}
// MARK: - Big circular ring
private var timerRing: some View {
ZStack {
// Track
Circle()
.stroke(WorkerTheme.overlay08, lineWidth: 8)
// Progress
Circle()
.trim(from: 0, to: progressFraction)
.stroke(
phaseColor,
style: StrokeStyle(lineWidth: 8, lineCap: .round)
)
.rotationEffect(.degrees(-90))
.animation(.linear(duration: 0.3), value: store.pomodoroRemaining)
VStack(spacing: 4) {
Text(WorkerFormat.mmss(store.pomodoroRemaining))
.font(.system(size: 44, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundColor(WorkerTheme.fgPrimary)
Text(subText)
.font(.system(size: 11))
.foregroundColor(WorkerTheme.fg55)
}
}
.frame(width: 200, height: 200)
.padding(.vertical, 4)
}
private var progressFraction: CGFloat {
// P0 fix (2026-05-19 review): delegate to store so paused-in-break
// gets the right denominator. Inline switch couldn't see the
// private pausedPhase.
let total = store.pomodoroPhaseTotalSec
guard total > 0 else { return 0 }
let remaining = max(0, store.pomodoroRemaining)
return CGFloat(total - remaining) / CGFloat(total)
}
private var subText: String {
switch store.pomodoroPhase {
case .focus: return "专注 \(store.pomodoroFocusMin) 分钟"
case .rest: return "休息 \(store.pomodoroBreakMin) 分钟"
case .paused: return "点击继续"
case .idle: return "准备开始"
}
}
// MARK: - Controls
private var controls: some View {
HStack(spacing: 10) {
// Primary start/pause button
Button(action: primaryAction) {
HStack(spacing: 6) {
Image(systemName: primaryIcon)
.font(.system(size: 11, weight: .semibold))
Text(primaryLabel)
.font(.system(size: 12.5, weight: .semibold))
}
.foregroundColor(Color(red: 0x0B/255, green: 0x0B/255, blue: 0x0B/255))
.padding(.horizontal, 18)
.frame(height: 32)
.background(Capsule().fill(WorkerTheme.lime))
}
.buttonStyle(.plain)
// Reset
Button(action: { store.pomodoroReset() }) {
HStack(spacing: 6) {
Image(systemName: "arrow.counterclockwise")
.font(.system(size: 11, weight: .semibold))
Text("重置")
.font(.system(size: 12.5, weight: .semibold))
}
.foregroundColor(WorkerTheme.fg85)
.padding(.horizontal, 16)
.frame(height: 32)
.background(
Capsule()
.fill(WorkerTheme.overlay06)
.overlay(Capsule().stroke(WorkerTheme.overlay12, lineWidth: 0.5))
)
}
.buttonStyle(.plain)
}
}
private var primaryIcon: String {
switch store.pomodoroPhase {
case .focus, .rest: return "pause.fill"
default: return "play.fill"
}
}
private var primaryLabel: String {
switch store.pomodoroPhase {
case .focus, .rest: return "暂停"
case .paused: return "继续"
case .idle: return "开始"
}
}
private func primaryAction() {
switch store.pomodoroPhase {
case .focus, .rest:
store.pomodoroPause()
case .paused, .idle:
store.pomodoroStart()
}
}
// MARK: - Stats
private var statsRow: some View {
HStack(spacing: 8) {
statCard(
title: "今日番茄",
value: "\(store.pomodoroTodayCount)",
accent: WorkerTheme.tomato
)
statCard(
title: "专注时长",
value: "\(store.pomodoroTodayCount * store.pomodoroFocusMin)",
accent: WorkerTheme.lime
)
// Quality score: dimmed (em-dash) when n=0, otherwise the
// running average across today's focus phases.
// 8 / 10 = "1500s focus with 300s idle" solid focus
// 5 / 10 = "half the time I was elsewhere"
// <3 = "you were not really pomodoro-ing"
statCard(
title: "今日均分",
value: store.pomodoroQualityTodayN == 0
? ""
: String(format: "%.1f", store.pomodoroQualityTodayAvg),
accent: store.pomodoroQualityTodayN == 0
? WorkerTheme.fg40
: qualityAccent(store.pomodoroQualityTodayAvg)
)
}
.padding(.horizontal, 12)
}
/// Score colour: high = lime, mid = tomato, low = alertRed.
/// Visual sanity-check on glance green is good.
private func qualityAccent(_ score: Double) -> Color {
if score >= 8 { return WorkerTheme.lime }
if score >= 5 { return WorkerTheme.tomato }
return WorkerTheme.alertRed
}
private func statCard(title: String, value: String, accent: Color) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.system(size: 10.5))
.foregroundColor(WorkerTheme.fg55)
Text(value)
.font(.system(size: 18, weight: .semibold, design: .rounded))
.foregroundColor(accent)
.monospacedDigit()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(WorkerTheme.overlay04)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(WorkerTheme.overlay08, lineWidth: 0.5)
)
)
}
// MARK: - Settings
feat: v0.2.0 — sleep-aware timers + auto-loop pomodoro Core change: both timers stop lying when the Mac sleeps or the user walks away. Old code measured wallclock from a stored "start" timestamp, so 4 hours of lid-closed showed up as 4 hours of "sitting" and a 25-minute focus could quietly stretch across multiple hours because Timer.scheduledTimer pauses during sleep. What changed: - Sit timer now reads CGEventSource HID idle to count only seconds with real input activity. New Sources/engine/SystemIdle.swift iterates the user-input event types and takes the min. Idle ≥ 5 min resets the accumulator (counts as a real break). Tick-gap > 30 s flags a sleep/wake and clears too — covers the case where the user unlocks and idle resets to ~0 instantly. - Pomodoro switched from "decrement counter every second" to a wallclock end-time. Persisted to UserDefaults so quit/relaunch recovers correctly: still-running phases keep counting; phases whose end-time has already passed get caught up (one focus credited if missed) and the timer returns to idle. - Auto-loop with classic 4-pack rhythm: focus → short break → focus → … on the 4th focus a long break (default 15 min) replaces the short break, then the cycle resets. Default ON; toggle pill in the settings row turns it off when the user wants manual restarts. - New cycle-progress dot row (●●○○) next to the phase badge so the user can see where they are in the 4-pack at a glance. - SitView tip text rewritten to reflect the new semantics: "只统计 你真正在键盘前的时间。离开 5 分钟以上自动归零,午休/会议不会被 算进去。" User-facing wins: - Closing the lid for lunch no longer fakes 90 minutes of sitting. - A 25-min focus that survives a sleep cycle ends accurately. - Start a focus once, the rhythm runs all morning.
2026-04-30 03:23:09 +00:00
private var settingsRowPrimary: some View {
HStack(spacing: 10) {
stepperPill(
title: "专注",
value: store.pomodoroFocusMin,
onMinus: { store.pomodoroSetFocus(store.pomodoroFocusMin - 5) },
onPlus: { store.pomodoroSetFocus(store.pomodoroFocusMin + 5) }
)
stepperPill(
feat: v0.2.0 — sleep-aware timers + auto-loop pomodoro Core change: both timers stop lying when the Mac sleeps or the user walks away. Old code measured wallclock from a stored "start" timestamp, so 4 hours of lid-closed showed up as 4 hours of "sitting" and a 25-minute focus could quietly stretch across multiple hours because Timer.scheduledTimer pauses during sleep. What changed: - Sit timer now reads CGEventSource HID idle to count only seconds with real input activity. New Sources/engine/SystemIdle.swift iterates the user-input event types and takes the min. Idle ≥ 5 min resets the accumulator (counts as a real break). Tick-gap > 30 s flags a sleep/wake and clears too — covers the case where the user unlocks and idle resets to ~0 instantly. - Pomodoro switched from "decrement counter every second" to a wallclock end-time. Persisted to UserDefaults so quit/relaunch recovers correctly: still-running phases keep counting; phases whose end-time has already passed get caught up (one focus credited if missed) and the timer returns to idle. - Auto-loop with classic 4-pack rhythm: focus → short break → focus → … on the 4th focus a long break (default 15 min) replaces the short break, then the cycle resets. Default ON; toggle pill in the settings row turns it off when the user wants manual restarts. - New cycle-progress dot row (●●○○) next to the phase badge so the user can see where they are in the 4-pack at a glance. - SitView tip text rewritten to reflect the new semantics: "只统计 你真正在键盘前的时间。离开 5 分钟以上自动归零,午休/会议不会被 算进去。" User-facing wins: - Closing the lid for lunch no longer fakes 90 minutes of sitting. - A 25-min focus that survives a sleep cycle ends accurately. - Start a focus once, the rhythm runs all morning.
2026-04-30 03:23:09 +00:00
title: "短休",
value: store.pomodoroBreakMin,
onMinus: { store.pomodoroSetBreak(store.pomodoroBreakMin - 1) },
onPlus: { store.pomodoroSetBreak(store.pomodoroBreakMin + 1) }
)
}
.padding(.horizontal, 16)
}
feat: v0.2.0 — sleep-aware timers + auto-loop pomodoro Core change: both timers stop lying when the Mac sleeps or the user walks away. Old code measured wallclock from a stored "start" timestamp, so 4 hours of lid-closed showed up as 4 hours of "sitting" and a 25-minute focus could quietly stretch across multiple hours because Timer.scheduledTimer pauses during sleep. What changed: - Sit timer now reads CGEventSource HID idle to count only seconds with real input activity. New Sources/engine/SystemIdle.swift iterates the user-input event types and takes the min. Idle ≥ 5 min resets the accumulator (counts as a real break). Tick-gap > 30 s flags a sleep/wake and clears too — covers the case where the user unlocks and idle resets to ~0 instantly. - Pomodoro switched from "decrement counter every second" to a wallclock end-time. Persisted to UserDefaults so quit/relaunch recovers correctly: still-running phases keep counting; phases whose end-time has already passed get caught up (one focus credited if missed) and the timer returns to idle. - Auto-loop with classic 4-pack rhythm: focus → short break → focus → … on the 4th focus a long break (default 15 min) replaces the short break, then the cycle resets. Default ON; toggle pill in the settings row turns it off when the user wants manual restarts. - New cycle-progress dot row (●●○○) next to the phase badge so the user can see where they are in the 4-pack at a glance. - SitView tip text rewritten to reflect the new semantics: "只统计 你真正在键盘前的时间。离开 5 分钟以上自动归零,午休/会议不会被 算进去。" User-facing wins: - Closing the lid for lunch no longer fakes 90 minutes of sitting. - A 25-min focus that survives a sleep cycle ends accurately. - Start a focus once, the rhythm runs all morning.
2026-04-30 03:23:09 +00:00
private var settingsRowSecondary: some View {
HStack(spacing: 10) {
stepperPill(
title: "长休",
value: store.pomodoroLongBreakMin,
onMinus: { store.pomodoroSetLongBreak(store.pomodoroLongBreakMin - 5) },
onPlus: { store.pomodoroSetLongBreak(store.pomodoroLongBreakMin + 5) }
)
autoLoopPill
}
.padding(.horizontal, 16)
}
/// Toggle pill for "auto-loop." Tap to flip; visual state mirrors
/// the running tomato (lime when on). Default is on unchecking
/// makes the timer stop after each break for a manual restart.
private var autoLoopPill: some View {
Button(action: { store.pomodoroSetAutoLoop(!store.pomodoroAutoLoop) }) {
HStack(spacing: 6) {
Image(systemName: store.pomodoroAutoLoop ? "infinity" : "playpause")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(store.pomodoroAutoLoop ? WorkerTheme.lime : WorkerTheme.fg55)
Text(store.pomodoroAutoLoop ? "自动循环" : "单次")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(store.pomodoroAutoLoop ? WorkerTheme.fgPrimary : WorkerTheme.fg70)
}
.frame(maxWidth: .infinity)
.frame(height: 30)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(WorkerTheme.overlay04)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(store.pomodoroAutoLoop ? WorkerTheme.lime.opacity(0.4) : WorkerTheme.overlay08, lineWidth: 0.5)
)
)
}
.buttonStyle(.plain)
.help(store.pomodoroAutoLoop ? "番茄/休息自动衔接" : "每次休息结束后停下,等你手动开始")
}
private func stepperPill(title: String, value: Int, onMinus: @escaping () -> Void, onPlus: @escaping () -> Void) -> some View {
HStack(spacing: 0) {
Text(title)
.font(.system(size: 11))
.foregroundColor(WorkerTheme.fg55)
.padding(.leading, 10)
.padding(.trailing, 6)
Button(action: onMinus) {
Image(systemName: "minus")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(WorkerTheme.fg70)
.frame(width: 22, height: 22)
.background(Circle().fill(WorkerTheme.overlay06))
}
.buttonStyle(.plain)
Text("\(value)")
.font(.system(size: 12.5, weight: .semibold))
.foregroundColor(WorkerTheme.fgPrimary)
.monospacedDigit()
.frame(minWidth: 40)
Button(action: onPlus) {
Image(systemName: "plus")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(WorkerTheme.fg70)
.frame(width: 22, height: 22)
.background(Circle().fill(WorkerTheme.overlay06))
}
.buttonStyle(.plain)
.padding(.trailing, 6)
}
.frame(maxWidth: .infinity)
.frame(height: 30)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(WorkerTheme.overlay04)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(WorkerTheme.overlay08, lineWidth: 0.5)
)
)
}
}