mirror of
https://github.com/carey314/mio-plugin-worker.git
synced 2026-08-10 07:04:32 +00:00
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.
This commit is contained in:
parent
959580c55e
commit
8d8a2e21a1
@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<string>0.2.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>2</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>WorkerPlugin.WorkerPlugin</string>
|
||||
<!--
|
||||
|
||||
@ -13,7 +13,7 @@ final class WorkerPlugin: NSObject, MioPlugin {
|
||||
var id: String { "worker" }
|
||||
var name: String { "摸鱼侠" }
|
||||
var icon: String { "fish.fill" }
|
||||
var version: String { "0.1.0" }
|
||||
var version: String { "0.2.0" }
|
||||
|
||||
func activate() {
|
||||
WorkerDebugLog.write("plugin activate")
|
||||
|
||||
36
Sources/engine/SystemIdle.swift
Normal file
36
Sources/engine/SystemIdle.swift
Normal file
@ -0,0 +1,36 @@
|
||||
//
|
||||
// SystemIdle.swift
|
||||
// 摸鱼侠 plugin
|
||||
//
|
||||
// Returns "seconds since the user last touched any input device". This
|
||||
// is the ground truth for "is a human at the keys right now." Unlike
|
||||
// Date()-since-startTimestamp, it survives sleep/lid-close/lock cleanly:
|
||||
// while no input arrives, the counter grows; the moment the user taps
|
||||
// a key it drops to ~0. The sit-timer uses it to distinguish "sat at
|
||||
// the desk for 45 minutes" from "left for lunch with the lid open."
|
||||
//
|
||||
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
enum SystemIdle {
|
||||
/// Seconds since the last user input event of any kind.
|
||||
/// CGEventType is an enum and Swift doesn't expose the C-level
|
||||
/// kCGAnyInputEventType wildcard, so we iterate the events that
|
||||
/// matter for "is the user active" and take the minimum.
|
||||
static var seconds: TimeInterval {
|
||||
let types: [CGEventType] = [
|
||||
.mouseMoved,
|
||||
.leftMouseDown, .rightMouseDown, .otherMouseDown,
|
||||
.leftMouseDragged, .rightMouseDragged,
|
||||
.keyDown,
|
||||
.scrollWheel
|
||||
]
|
||||
var minVal = Double.greatestFiniteMagnitude
|
||||
for t in types {
|
||||
let v = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: t)
|
||||
if v < minVal { minVal = v }
|
||||
}
|
||||
return minVal == .greatestFiniteMagnitude ? 0 : minVal
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,16 @@
|
||||
//
|
||||
// WorkerStore.swift
|
||||
// 摸鱼侠 plugin v0.1
|
||||
// 摸鱼侠 plugin
|
||||
//
|
||||
// Single source of truth for all five tabs. Holds:
|
||||
// - pomodoro state machine (idle / focus / break / paused)
|
||||
// - sit-timer countup (start timestamp)
|
||||
// - pomodoro state machine (idle / focus / break / paused) driven by
|
||||
// a wallclock end-time so sleep / lock / app-quit don't lie about
|
||||
// remaining time
|
||||
// - sit-timer = accumulator of "seconds the user was actually
|
||||
// active." Idle > breakResetThreshold → counter resets (you got up
|
||||
// from the desk). The old startTimestamp model would count lunch
|
||||
// as sitting; this one only counts real input.
|
||||
// - pomodoro auto-loop with classic 4-focus → long-break cycle
|
||||
// - water tally (cups today)
|
||||
// - clockout target time (HH:mm)
|
||||
// - weekend countdown (computed)
|
||||
@ -23,9 +29,17 @@ private enum K {
|
||||
static let pomodoroHistory = "pomodoro.history" // [String: Int] (yyyy-MM-dd → count)
|
||||
static let pomodoroFocusMin = "pomodoro.focusMin" // Int, default 25
|
||||
static let pomodoroBreakMin = "pomodoro.breakMin" // Int, default 5
|
||||
static let sitStart = "sit.start" // Double (timeIntervalSince1970), 0 = idle
|
||||
static let pomodoroLongBreakMin = "pomodoro.longBreakMin" // Int, default 15
|
||||
static let pomodoroPhaseRaw = "pomodoro.phaseRaw" // String, current phase
|
||||
static let pomodoroEndsAt = "pomodoro.endsAt" // Double (timeIntervalSince1970), 0 = nil
|
||||
static let pomodoroAutoLoop = "pomodoro.autoLoop" // Bool, default true
|
||||
static let pomodoroCycleProg = "pomodoro.cycleProgress" // Int, 0..4 — focuses done in current 4-pack
|
||||
static let pomodoroPausedPhase = "pomodoro.pausedPhase" // String, phase to resume
|
||||
static let pomodoroPausedRemain = "pomodoro.pausedRemain" // Int seconds
|
||||
static let sitEnabled = "sit.enabled" // Bool, monitoring on/off
|
||||
static let sitTriggerMin = "sit.triggerMin" // Int, default 45
|
||||
static let sitLastNotified = "sit.lastNotified" // Double — last fired notification ts, to dedupe
|
||||
static let sitAccumActive = "sit.accumActive" // Int seconds, persisted active time
|
||||
static let waterHistory = "water.history" // [String: Int] (date → cups)
|
||||
static let waterGoal = "water.goal" // Int, default 8
|
||||
static let clockoutHHmm = "clockout.hhmm" // String "18:00"
|
||||
@ -33,12 +47,25 @@ private enum K {
|
||||
|
||||
private let suiteName = "com.mioisland.plugin.worker"
|
||||
|
||||
/// User went idle for this many seconds → we count the sitting session as broken.
|
||||
/// 5 minutes is the bathroom-break / coffee-refill threshold: shorter idleness
|
||||
/// (looking out the window, on a phone call) shouldn't reset the streak.
|
||||
private let sitBreakResetThresholdSec: Double = 5 * 60
|
||||
|
||||
/// Persist sit counter every N ticks to avoid hammering UserDefaults each second.
|
||||
private let sitPersistEveryTicks: Int = 30
|
||||
|
||||
/// If two consecutive tick fires are this far apart in wallclock, the
|
||||
/// system was probably asleep (Timer pauses during sleep). Treat the
|
||||
/// gap as "user was away" and reset the sit accumulator.
|
||||
private let tickGapWakeThresholdSec: Double = 30
|
||||
|
||||
// MARK: - Pomodoro phase
|
||||
|
||||
enum PomodoroPhase: String {
|
||||
case idle // not running
|
||||
case focus // 25 min focus going
|
||||
case rest // 5 min break going
|
||||
case focus // focus session going
|
||||
case rest // break going
|
||||
case paused // paused mid-phase
|
||||
|
||||
var label: String {
|
||||
@ -63,18 +90,47 @@ final class WorkerStore: ObservableObject {
|
||||
|
||||
/// Pomodoro
|
||||
@Published var pomodoroPhase: PomodoroPhase = .idle
|
||||
@Published var pomodoroRemaining: Int = 25 * 60 // seconds
|
||||
@Published var pomodoroRemaining: Int = 25 * 60 // seconds, derived from pomodoroPhaseEndsAt each tick
|
||||
@Published var pomodoroFocusMin: Int = 25
|
||||
@Published var pomodoroBreakMin: Int = 5
|
||||
@Published var pomodoroLongBreakMin: Int = 15
|
||||
@Published var pomodoroTodayCount: Int = 0
|
||||
@Published var pomodoroAutoLoop: Bool = true
|
||||
/// 0..4 — number of focuses completed in the current 4-pack. After
|
||||
/// the 4th focus, the upcoming break is a long one; after the long
|
||||
/// break ends, this resets to 0. Dot indicator in PomodoroView reads
|
||||
/// this to draw the "🍅🍅⚪️⚪️" cycle position.
|
||||
@Published var pomodoroCycleProgress: Int = 0
|
||||
|
||||
/// Wallclock target. tick computes remaining = endsAt - now. Storing
|
||||
/// this (instead of decrementing a counter every second) means the
|
||||
/// timer is correct after sleep, lock, app quit, anything.
|
||||
private var pomodoroPhaseEndsAt: Date? = nil
|
||||
|
||||
/// Pre-pause snapshot so resume can restore the underlying phase.
|
||||
private var pausedPhase: PomodoroPhase = .focus
|
||||
private var pausedRemaining: Int = 25 * 60
|
||||
|
||||
/// Sit
|
||||
@Published var sitStart: Date? = nil // nil = not seated yet
|
||||
/// Kept as Date? for UI compat — nil = monitoring off, non-nil = on.
|
||||
/// The actual elapsed value is in sitElapsedSec, computed from the
|
||||
/// accumulator below — NOT from this Date.
|
||||
@Published var sitStart: Date? = nil
|
||||
@Published var sitTriggerMin: Int = 45
|
||||
@Published var sitElapsedSec: Int = 0 // computed each tick
|
||||
@Published var sitElapsedSec: Int = 0
|
||||
|
||||
/// Accumulator — incremented each tick where the user looked active
|
||||
/// (idle < ~1 min) and reset when they're away long enough to count
|
||||
/// as a real break. This is what gets shown to the user.
|
||||
private var sitAccumActiveSec: Int = 0
|
||||
private var sitTickCount: Int = 0
|
||||
/// Wallclock timestamp of the last tick fire. A gap larger than
|
||||
/// `tickGapWakeThreshold` means the system slept (Timer doesn't run
|
||||
/// while the Mac is asleep). On wake, idle-time API resets to ~0
|
||||
/// immediately because the unlock counts as input — so we can't
|
||||
/// rely on it alone to detect "user came back from a long break."
|
||||
/// The wallclock gap is the missing signal.
|
||||
private var lastTickAt: Date? = nil
|
||||
|
||||
/// Water
|
||||
@Published var waterCupsToday: Int = 0
|
||||
@ -127,42 +183,89 @@ final class WorkerStore: ObservableObject {
|
||||
WorkerDebugLog.write("store stop")
|
||||
tick?.invalidate()
|
||||
tick = nil
|
||||
// Flush any unsaved sit progress before going dark.
|
||||
defaults.set(sitAccumActiveSec, forKey: K.sitAccumActive)
|
||||
}
|
||||
|
||||
// MARK: - Tick (1Hz)
|
||||
|
||||
private func tickFire() {
|
||||
// 1. Pomodoro countdown
|
||||
if pomodoroPhase == .focus || pomodoroPhase == .rest {
|
||||
if pomodoroRemaining > 0 {
|
||||
pomodoroRemaining -= 1
|
||||
}
|
||||
if pomodoroRemaining <= 0 {
|
||||
// Detect system-sleep gaps. Timer.scheduledTimer pauses while
|
||||
// the Mac is asleep, so two consecutive fires more than
|
||||
// `tickGapWakeThresholdSec` apart means the user almost
|
||||
// certainly stepped away (lid closed for lunch, etc).
|
||||
let now = Date()
|
||||
let sleepGap: TimeInterval = lastTickAt.map { now.timeIntervalSince($0) } ?? 0
|
||||
let didSleepWake = sleepGap > tickGapWakeThresholdSec
|
||||
lastTickAt = now
|
||||
|
||||
// 1. Pomodoro — wallclock countdown.
|
||||
if let endsAt = pomodoroPhaseEndsAt,
|
||||
pomodoroPhase == .focus || pomodoroPhase == .rest {
|
||||
let remaining = max(0, Int(endsAt.timeIntervalSinceNow))
|
||||
pomodoroRemaining = remaining
|
||||
if remaining <= 0 {
|
||||
pomodoroPhaseEnded()
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Sit countup + threshold notification
|
||||
if let start = sitStart {
|
||||
let elapsed = max(0, Int(Date().timeIntervalSince(start)))
|
||||
sitElapsedSec = elapsed
|
||||
// Fire once per crossing of the trigger (and once per
|
||||
// additional trigger period after that).
|
||||
// 2. Sit — input-idle accumulator.
|
||||
if sitStart != nil {
|
||||
// System wake from sleep counts as "stepped away" — clear the
|
||||
// counter so 4 hours of lid-closed doesn't show as 4 hours
|
||||
// of sitting.
|
||||
if didSleepWake {
|
||||
if sitAccumActiveSec > 0 {
|
||||
WorkerDebugLog.write("sit reset: woke from \(Int(sleepGap))s sleep gap")
|
||||
}
|
||||
sitAccumActiveSec = 0
|
||||
defaults.set(0.0, forKey: K.sitLastNotified)
|
||||
}
|
||||
|
||||
let idle = SystemIdle.seconds
|
||||
if idle >= sitBreakResetThresholdSec {
|
||||
// User has been away long enough that we count it as a
|
||||
// real break. Reset the active-time counter and the
|
||||
// notification dedupe so the next sitting session can
|
||||
// trigger a fresh alert.
|
||||
if sitAccumActiveSec > 0 {
|
||||
WorkerDebugLog.write("sit reset: idle=\(Int(idle))s ≥ threshold")
|
||||
}
|
||||
sitAccumActiveSec = 0
|
||||
defaults.set(0.0, forKey: K.sitLastNotified)
|
||||
} else if idle < 60 {
|
||||
// User is currently active (last input within ~1 min).
|
||||
// Each 1Hz tick under that condition counts as one
|
||||
// second of "real sitting" — naturally pauses while
|
||||
// they're idle 1-5 min (e.g. on a call) and resets if
|
||||
// they leave for longer.
|
||||
sitAccumActiveSec += 1
|
||||
}
|
||||
// else: idle 1-5 min → hold steady, neither grow nor reset.
|
||||
|
||||
sitElapsedSec = sitAccumActiveSec
|
||||
|
||||
// Threshold notification, deduped by triggerSec.
|
||||
let triggerSec = sitTriggerMin * 60
|
||||
if triggerSec > 0 && elapsed >= triggerSec {
|
||||
if triggerSec > 0 && sitElapsedSec >= triggerSec {
|
||||
let lastFired = defaults.double(forKey: K.sitLastNotified)
|
||||
let nowTs = Date().timeIntervalSince1970
|
||||
// Only fire if we crossed *this* boundary (i.e. last
|
||||
// fired more than triggerSec ago, or never).
|
||||
if nowTs - lastFired >= Double(triggerSec) {
|
||||
defaults.set(nowTs, forKey: K.sitLastNotified)
|
||||
WorkerNotificationCenter.shared.notify(
|
||||
title: "该起来动一下了",
|
||||
body: "你已连续坐了 \(sitTriggerMin) 分钟,起身喝口水吧。"
|
||||
)
|
||||
WorkerDebugLog.write("sit threshold notification fired (\(elapsed)s)")
|
||||
WorkerDebugLog.write("sit threshold notification fired (\(sitElapsedSec)s)")
|
||||
}
|
||||
}
|
||||
|
||||
// Persist accumulator once every N ticks (cheap & resilient).
|
||||
sitTickCount += 1
|
||||
if sitTickCount >= sitPersistEveryTicks {
|
||||
sitTickCount = 0
|
||||
defaults.set(sitAccumActiveSec, forKey: K.sitAccumActive)
|
||||
}
|
||||
} else {
|
||||
sitElapsedSec = 0
|
||||
}
|
||||
@ -170,7 +273,7 @@ final class WorkerStore: ObservableObject {
|
||||
// 3. Weekend countdown — recompute every tick (cheap).
|
||||
recomputeWeekend()
|
||||
|
||||
// 4. Detect day rollover for pomodoro / water counters.
|
||||
// 4. Detect day rollover for pomodoro / water / sit counters.
|
||||
rolloverIfNeeded()
|
||||
}
|
||||
|
||||
@ -180,22 +283,38 @@ final class WorkerStore: ObservableObject {
|
||||
if pomodoroPhase == .paused {
|
||||
pomodoroPhase = pausedPhase
|
||||
pomodoroRemaining = pausedRemaining
|
||||
pomodoroPhaseEndsAt = Date(timeIntervalSinceNow: TimeInterval(pausedRemaining))
|
||||
persistPomodoroRuntime()
|
||||
return
|
||||
}
|
||||
pomodoroPhase = .focus
|
||||
pomodoroRemaining = pomodoroFocusMin * 60
|
||||
pomodoroPhaseEndsAt = Date(timeIntervalSinceNow: TimeInterval(pomodoroRemaining))
|
||||
persistPomodoroRuntime()
|
||||
}
|
||||
|
||||
func pomodoroPause() {
|
||||
guard pomodoroPhase == .focus || pomodoroPhase == .rest else { return }
|
||||
pausedPhase = pomodoroPhase
|
||||
// Snapshot the *current* remaining so resume picks up from here,
|
||||
// not from the original start.
|
||||
if let endsAt = pomodoroPhaseEndsAt {
|
||||
pausedRemaining = max(0, Int(endsAt.timeIntervalSinceNow))
|
||||
} else {
|
||||
pausedRemaining = pomodoroRemaining
|
||||
}
|
||||
pomodoroPhase = .paused
|
||||
pomodoroPhaseEndsAt = nil
|
||||
persistPomodoroRuntime()
|
||||
}
|
||||
|
||||
func pomodoroReset() {
|
||||
pomodoroPhase = .idle
|
||||
pomodoroRemaining = pomodoroFocusMin * 60
|
||||
pomodoroPhaseEndsAt = nil
|
||||
pausedRemaining = pomodoroFocusMin * 60
|
||||
// Keep cycleIndex — resetting mid-cycle shouldn't lose your progress.
|
||||
persistPomodoroRuntime()
|
||||
}
|
||||
|
||||
func pomodoroSetFocus(_ min: Int) {
|
||||
@ -213,28 +332,72 @@ final class WorkerStore: ObservableObject {
|
||||
defaults.set(v, forKey: K.pomodoroBreakMin)
|
||||
}
|
||||
|
||||
func pomodoroSetLongBreak(_ min: Int) {
|
||||
let v = max(1, min)
|
||||
pomodoroLongBreakMin = v
|
||||
defaults.set(v, forKey: K.pomodoroLongBreakMin)
|
||||
}
|
||||
|
||||
func pomodoroSetAutoLoop(_ on: Bool) {
|
||||
pomodoroAutoLoop = on
|
||||
defaults.set(on, forKey: K.pomodoroAutoLoop)
|
||||
}
|
||||
|
||||
private func pomodoroPhaseEnded() {
|
||||
switch pomodoroPhase {
|
||||
case .focus:
|
||||
// Increment today's tally.
|
||||
// Increment today's tally + advance the cycle counter.
|
||||
pomodoroTodayCount += 1
|
||||
persistPomodoroToday()
|
||||
pomodoroCycleProgress = min(4, pomodoroCycleProgress + 1)
|
||||
defaults.set(pomodoroCycleProgress, forKey: K.pomodoroCycleProg)
|
||||
|
||||
// 4th focus in the cycle → long break; otherwise short.
|
||||
let isLongBreak = pomodoroCycleProgress >= 4
|
||||
let breakMin = isLongBreak ? pomodoroLongBreakMin : pomodoroBreakMin
|
||||
let bodyText = isLongBreak
|
||||
? "完成 4 个番茄,享受 \(breakMin) 分钟长休息。"
|
||||
: "干得漂亮!开始 \(breakMin) 分钟休息。"
|
||||
WorkerNotificationCenter.shared.notify(
|
||||
title: "番茄完成 🍅",
|
||||
body: "干得漂亮!开始 \(pomodoroBreakMin) 分钟休息。"
|
||||
body: bodyText
|
||||
)
|
||||
pomodoroPhase = .rest
|
||||
pomodoroRemaining = pomodoroBreakMin * 60
|
||||
pomodoroRemaining = breakMin * 60
|
||||
pomodoroPhaseEndsAt = Date(timeIntervalSinceNow: TimeInterval(pomodoroRemaining))
|
||||
|
||||
case .rest:
|
||||
// If we just finished a long break, the next focus starts a
|
||||
// fresh 4-pack — reset the dot row.
|
||||
let wasLongBreak = pomodoroCycleProgress >= 4
|
||||
if wasLongBreak {
|
||||
pomodoroCycleProgress = 0
|
||||
defaults.set(0, forKey: K.pomodoroCycleProg)
|
||||
}
|
||||
// Auto-loop: jump straight into the next focus instead of
|
||||
// sitting idle waiting for the user to come tap "开始" again.
|
||||
// Classic Pomodoro rhythm — start once, run all morning.
|
||||
if pomodoroAutoLoop {
|
||||
WorkerNotificationCenter.shared.notify(
|
||||
title: "下一个番茄开始 🍅",
|
||||
body: "回到工作 — \(pomodoroFocusMin) 分钟专注开始。"
|
||||
)
|
||||
pomodoroPhase = .focus
|
||||
pomodoroRemaining = pomodoroFocusMin * 60
|
||||
pomodoroPhaseEndsAt = Date(timeIntervalSinceNow: TimeInterval(pomodoroRemaining))
|
||||
} else {
|
||||
WorkerNotificationCenter.shared.notify(
|
||||
title: "休息结束",
|
||||
body: "回到工作 — 再来一个 \(pomodoroFocusMin) 分钟番茄?"
|
||||
)
|
||||
pomodoroPhase = .idle
|
||||
pomodoroRemaining = pomodoroFocusMin * 60
|
||||
pomodoroPhaseEndsAt = nil
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
persistPomodoroRuntime()
|
||||
}
|
||||
|
||||
private func persistPomodoroToday() {
|
||||
@ -243,19 +406,34 @@ final class WorkerStore: ObservableObject {
|
||||
defaults.set(dict, forKey: K.pomodoroHistory)
|
||||
}
|
||||
|
||||
/// Persist the runtime fields that change with start/pause/reset/phase
|
||||
/// transitions. Keeps a relaunch consistent with what the user saw.
|
||||
private func persistPomodoroRuntime() {
|
||||
defaults.set(pomodoroPhase.rawValue, forKey: K.pomodoroPhaseRaw)
|
||||
defaults.set(pomodoroPhaseEndsAt?.timeIntervalSince1970 ?? 0.0, forKey: K.pomodoroEndsAt)
|
||||
defaults.set(pausedPhase.rawValue, forKey: K.pomodoroPausedPhase)
|
||||
defaults.set(pausedRemaining, forKey: K.pomodoroPausedRemain)
|
||||
}
|
||||
|
||||
// MARK: - Sit
|
||||
|
||||
func sitStartNow() {
|
||||
sitStart = Date()
|
||||
sitAccumActiveSec = 0
|
||||
sitElapsedSec = 0
|
||||
defaults.set(Date().timeIntervalSince1970, forKey: K.sitStart)
|
||||
sitTickCount = 0
|
||||
defaults.set(true, forKey: K.sitEnabled)
|
||||
defaults.set(0, forKey: K.sitAccumActive)
|
||||
defaults.set(0.0, forKey: K.sitLastNotified)
|
||||
}
|
||||
|
||||
func sitStop() {
|
||||
sitStart = nil
|
||||
sitAccumActiveSec = 0
|
||||
sitElapsedSec = 0
|
||||
defaults.set(0.0, forKey: K.sitStart)
|
||||
sitTickCount = 0
|
||||
defaults.set(false, forKey: K.sitEnabled)
|
||||
defaults.set(0, forKey: K.sitAccumActive)
|
||||
defaults.set(0.0, forKey: K.sitLastNotified)
|
||||
}
|
||||
|
||||
@ -374,33 +552,97 @@ final class WorkerStore: ObservableObject {
|
||||
pomodoroTodayCount = pomDict[today] ?? 0
|
||||
let waterDict = (defaults.dictionary(forKey: K.waterHistory) as? [String: Int]) ?? [:]
|
||||
waterCupsToday = waterDict[today] ?? 0
|
||||
// Sitting through midnight is weird — start a fresh day's
|
||||
// active-time count so the badge doesn't show "已坐 1380 分".
|
||||
sitAccumActiveSec = 0
|
||||
defaults.set(0, forKey: K.sitAccumActive)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Load + recompute
|
||||
|
||||
private func loadPersisted() {
|
||||
// Pomodoro
|
||||
// Pomodoro — settings.
|
||||
let storedFocus = defaults.integer(forKey: K.pomodoroFocusMin)
|
||||
pomodoroFocusMin = storedFocus > 0 ? storedFocus : 25
|
||||
let storedBreak = defaults.integer(forKey: K.pomodoroBreakMin)
|
||||
pomodoroBreakMin = storedBreak > 0 ? storedBreak : 5
|
||||
pomodoroRemaining = pomodoroFocusMin * 60
|
||||
let storedLong = defaults.integer(forKey: K.pomodoroLongBreakMin)
|
||||
pomodoroLongBreakMin = storedLong > 0 ? storedLong : 15
|
||||
// autoLoop default true — only flip if explicitly written to false.
|
||||
if defaults.object(forKey: K.pomodoroAutoLoop) != nil {
|
||||
pomodoroAutoLoop = defaults.bool(forKey: K.pomodoroAutoLoop)
|
||||
} else {
|
||||
pomodoroAutoLoop = true
|
||||
}
|
||||
pomodoroCycleProgress = max(0, min(4, defaults.integer(forKey: K.pomodoroCycleProg)))
|
||||
|
||||
let pomDict = (defaults.dictionary(forKey: K.pomodoroHistory) as? [String: Int]) ?? [:]
|
||||
let today = Self.dateKey(Date(), calendar: calendar)
|
||||
pomodoroTodayCount = pomDict[today] ?? 0
|
||||
lastSeenDay = today
|
||||
|
||||
// Sit
|
||||
// Pomodoro — runtime resume.
|
||||
let pausedRaw = defaults.string(forKey: K.pomodoroPausedPhase) ?? "focus"
|
||||
pausedPhase = PomodoroPhase(rawValue: pausedRaw) ?? .focus
|
||||
let storedPausedRem = defaults.integer(forKey: K.pomodoroPausedRemain)
|
||||
pausedRemaining = storedPausedRem > 0 ? storedPausedRem : pomodoroFocusMin * 60
|
||||
|
||||
let phaseRaw = defaults.string(forKey: K.pomodoroPhaseRaw) ?? "idle"
|
||||
let savedPhase = PomodoroPhase(rawValue: phaseRaw) ?? .idle
|
||||
let endsAtTs = defaults.double(forKey: K.pomodoroEndsAt)
|
||||
|
||||
if savedPhase == .paused {
|
||||
// Pause survives across launches with its remaining intact.
|
||||
pomodoroPhase = .paused
|
||||
pomodoroRemaining = pausedRemaining
|
||||
pomodoroPhaseEndsAt = nil
|
||||
} else if endsAtTs > 0 && (savedPhase == .focus || savedPhase == .rest) {
|
||||
let endsAt = Date(timeIntervalSince1970: endsAtTs)
|
||||
let now = Date()
|
||||
if endsAt > now {
|
||||
// Still running.
|
||||
pomodoroPhase = savedPhase
|
||||
pomodoroPhaseEndsAt = endsAt
|
||||
pomodoroRemaining = max(0, Int(endsAt.timeIntervalSinceNow))
|
||||
} else {
|
||||
// Phase ended while we were away. If a focus was lost,
|
||||
// count it once. We don't try to fast-forward through
|
||||
// multiple phases — just resume to idle and let the
|
||||
// user start fresh.
|
||||
if savedPhase == .focus {
|
||||
pomodoroTodayCount += 1
|
||||
persistPomodoroToday()
|
||||
pomodoroCycleProgress = min(4, pomodoroCycleProgress + 1)
|
||||
defaults.set(pomodoroCycleProgress, forKey: K.pomodoroCycleProg)
|
||||
WorkerDebugLog.write("pomodoro: caught up 1 missed focus on launch")
|
||||
}
|
||||
pomodoroPhase = .idle
|
||||
pomodoroRemaining = pomodoroFocusMin * 60
|
||||
pomodoroPhaseEndsAt = nil
|
||||
defaults.set(0.0, forKey: K.pomodoroEndsAt)
|
||||
defaults.set(PomodoroPhase.idle.rawValue, forKey: K.pomodoroPhaseRaw)
|
||||
}
|
||||
} else {
|
||||
pomodoroPhase = .idle
|
||||
pomodoroRemaining = pomodoroFocusMin * 60
|
||||
pomodoroPhaseEndsAt = nil
|
||||
}
|
||||
|
||||
// Sit — settings + accumulator.
|
||||
let storedTrig = defaults.integer(forKey: K.sitTriggerMin)
|
||||
sitTriggerMin = storedTrig > 0 ? storedTrig : 45
|
||||
let sitTs = defaults.double(forKey: K.sitStart)
|
||||
if sitTs > 0 {
|
||||
sitStart = Date(timeIntervalSince1970: sitTs)
|
||||
// Read enabled flag with explicit "never set" check so default-on
|
||||
// doesn't bite users who have disabled monitoring.
|
||||
if defaults.object(forKey: K.sitEnabled) != nil {
|
||||
let on = defaults.bool(forKey: K.sitEnabled)
|
||||
sitStart = on ? Date() : nil
|
||||
} else {
|
||||
sitStart = nil
|
||||
}
|
||||
sitAccumActiveSec = max(0, defaults.integer(forKey: K.sitAccumActive))
|
||||
sitElapsedSec = sitAccumActiveSec
|
||||
sitTickCount = 0
|
||||
|
||||
// Water
|
||||
let storedGoal = defaults.integer(forKey: K.waterGoal)
|
||||
@ -422,9 +664,8 @@ final class WorkerStore: ObservableObject {
|
||||
|
||||
private func recomputeAll() {
|
||||
recomputeWeekend()
|
||||
if let start = sitStart {
|
||||
sitElapsedSec = max(0, Int(Date().timeIntervalSince(start)))
|
||||
}
|
||||
// sitElapsedSec is the accumulator; nothing to recompute on its
|
||||
// own — the next tickFire will refresh it.
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@ -11,8 +11,8 @@ struct PomodoroView: View {
|
||||
@ObservedObject var store: WorkerStore
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
phaseBadge
|
||||
VStack(spacing: 14) {
|
||||
phaseHeader
|
||||
|
||||
timerRing
|
||||
|
||||
@ -24,14 +24,22 @@ struct PomodoroView: View {
|
||||
|
||||
statsRow
|
||||
|
||||
settingsRow
|
||||
settingsRowPrimary
|
||||
settingsRowSecondary
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
// MARK: - Phase badge
|
||||
// MARK: - Phase header (badge + cycle dots)
|
||||
|
||||
private var phaseHeader: some View {
|
||||
HStack(spacing: 10) {
|
||||
phaseBadge
|
||||
cycleDots
|
||||
}
|
||||
}
|
||||
|
||||
private var phaseBadge: some View {
|
||||
HStack(spacing: 8) {
|
||||
@ -48,6 +56,24 @@ struct PomodoroView: View {
|
||||
.background(Capsule().fill(WorkerTheme.overlay06))
|
||||
}
|
||||
|
||||
/// 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
|
||||
@ -217,7 +243,7 @@ struct PomodoroView: View {
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
private var settingsRow: some View {
|
||||
private var settingsRowPrimary: some View {
|
||||
HStack(spacing: 10) {
|
||||
stepperPill(
|
||||
title: "专注",
|
||||
@ -226,7 +252,7 @@ struct PomodoroView: View {
|
||||
onPlus: { store.pomodoroSetFocus(store.pomodoroFocusMin + 5) }
|
||||
)
|
||||
stepperPill(
|
||||
title: "休息",
|
||||
title: "短休",
|
||||
value: store.pomodoroBreakMin,
|
||||
onMinus: { store.pomodoroSetBreak(store.pomodoroBreakMin - 1) },
|
||||
onPlus: { store.pomodoroSetBreak(store.pomodoroBreakMin + 1) }
|
||||
@ -235,6 +261,47 @@ struct PomodoroView: View {
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@ -237,11 +237,12 @@ struct SitView: View {
|
||||
}
|
||||
|
||||
private var tipText: some View {
|
||||
Text("每隔 \(store.sitTriggerMin) 分钟提醒一次,记得起来动一动。")
|
||||
Text("只统计你真正在键盘前的时间。\n离开 5 分钟以上自动归零,午休/会议不会被算进去。")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(WorkerTheme.fg45)
|
||||
.padding(.horizontal, 20)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
.lineSpacing(2)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user