68fed087-9049-446f-bfc5-3f8.../Sources/engine/WorkerStore.swift

441 lines
14 KiB
Swift
Raw Normal View History

//
// WorkerStore.swift
// plugin v0.1
//
// Single source of truth for all five tabs. Holds:
// - pomodoro state machine (idle / focus / break / paused)
// - sit-timer countup (start timestamp)
// - water tally (cups today)
// - clockout target time (HH:mm)
// - weekend countdown (computed)
//
// All persisted to UserDefaults with suite "com.mioisland.plugin.worker".
// A single 1Hz Timer drives all derived "now" values.
//
import Combine
import Foundation
import SwiftUI
// MARK: - Persistence keys
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 sitTriggerMin = "sit.triggerMin" // Int, default 45
static let sitLastNotified = "sit.lastNotified" // Double last fired notification ts, to dedupe
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"
}
private let suiteName = "com.mioisland.plugin.worker"
// MARK: - Pomodoro phase
enum PomodoroPhase: String {
case idle // not running
case focus // 25 min focus going
case rest // 5 min break going
case paused // paused mid-phase
var label: String {
switch self {
case .idle: return "准备开始"
case .focus: return "专注中"
case .rest: return "休息中"
case .paused: return "已暂停"
}
}
}
// MARK: - Store
@MainActor
final class WorkerStore: ObservableObject {
static let shared = WorkerStore()
private let defaults: UserDefaults
// MARK: Published state
/// Pomodoro
@Published var pomodoroPhase: PomodoroPhase = .idle
@Published var pomodoroRemaining: Int = 25 * 60 // seconds
@Published var pomodoroFocusMin: Int = 25
@Published var pomodoroBreakMin: Int = 5
@Published var pomodoroTodayCount: Int = 0
/// 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
@Published var sitTriggerMin: Int = 45
@Published var sitElapsedSec: Int = 0 // computed each tick
/// Water
@Published var waterCupsToday: Int = 0
@Published var waterGoal: Int = 8
/// Clockout
@Published var clockoutHour: Int = 18
@Published var clockoutMinute: Int = 0
/// Weekend (derived)
@Published var weekendDays: Int = 0
@Published var weekendHours: Int = 0
@Published var weekendMinutes: Int = 0
/// Notification status surfaced to UI for the in-panel fallback.
@Published var notificationsAuthorized: Bool = false
// MARK: Private
private var tick: Timer?
private let calendar: Calendar = {
var c = Calendar(identifier: .gregorian)
c.locale = Locale(identifier: "zh_CN")
c.timeZone = TimeZone.current
return c
}()
// MARK: - Init
private init() {
// UserDefaults(suiteName:) returns nil only when the suite name
// is invalid (e.g. the global suite). For a normal bundle ID it
// always succeeds, but fall back to .standard defensively.
self.defaults = UserDefaults(suiteName: suiteName) ?? .standard
loadPersisted()
}
// MARK: - Lifecycle
func start() {
WorkerDebugLog.write("store start")
recomputeAll()
tick?.invalidate()
tick = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
Task { @MainActor in self?.tickFire() }
}
}
func stop() {
WorkerDebugLog.write("store stop")
tick?.invalidate()
tick = nil
}
// MARK: - Tick (1Hz)
private func tickFire() {
// 1. Pomodoro countdown
if pomodoroPhase == .focus || pomodoroPhase == .rest {
if pomodoroRemaining > 0 {
pomodoroRemaining -= 1
}
if pomodoroRemaining <= 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).
let triggerSec = sitTriggerMin * 60
if triggerSec > 0 && elapsed >= 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)")
}
}
} else {
sitElapsedSec = 0
}
// 3. Weekend countdown recompute every tick (cheap).
recomputeWeekend()
// 4. Detect day rollover for pomodoro / water counters.
rolloverIfNeeded()
}
// MARK: - Pomodoro
func pomodoroStart() {
if pomodoroPhase == .paused {
pomodoroPhase = pausedPhase
pomodoroRemaining = pausedRemaining
return
}
pomodoroPhase = .focus
pomodoroRemaining = pomodoroFocusMin * 60
}
func pomodoroPause() {
guard pomodoroPhase == .focus || pomodoroPhase == .rest else { return }
pausedPhase = pomodoroPhase
pausedRemaining = pomodoroRemaining
pomodoroPhase = .paused
}
func pomodoroReset() {
pomodoroPhase = .idle
pomodoroRemaining = pomodoroFocusMin * 60
}
func pomodoroSetFocus(_ min: Int) {
let v = max(1, min)
pomodoroFocusMin = v
defaults.set(v, forKey: K.pomodoroFocusMin)
if pomodoroPhase == .idle {
pomodoroRemaining = v * 60
}
}
func pomodoroSetBreak(_ min: Int) {
let v = max(1, min)
pomodoroBreakMin = v
defaults.set(v, forKey: K.pomodoroBreakMin)
}
private func pomodoroPhaseEnded() {
switch pomodoroPhase {
case .focus:
// Increment today's tally.
pomodoroTodayCount += 1
persistPomodoroToday()
WorkerNotificationCenter.shared.notify(
title: "番茄完成 🍅",
body: "干得漂亮!开始 \(pomodoroBreakMin) 分钟休息。"
)
pomodoroPhase = .rest
pomodoroRemaining = pomodoroBreakMin * 60
case .rest:
WorkerNotificationCenter.shared.notify(
title: "休息结束",
body: "回到工作 — 再来一个 \(pomodoroFocusMin) 分钟番茄?"
)
pomodoroPhase = .idle
pomodoroRemaining = pomodoroFocusMin * 60
default:
break
}
}
private func persistPomodoroToday() {
var dict = (defaults.dictionary(forKey: K.pomodoroHistory) as? [String: Int]) ?? [:]
dict[Self.dateKey(Date(), calendar: calendar)] = pomodoroTodayCount
defaults.set(dict, forKey: K.pomodoroHistory)
}
// MARK: - Sit
func sitStartNow() {
sitStart = Date()
sitElapsedSec = 0
defaults.set(Date().timeIntervalSince1970, forKey: K.sitStart)
defaults.set(0.0, forKey: K.sitLastNotified)
}
func sitStop() {
sitStart = nil
sitElapsedSec = 0
defaults.set(0.0, forKey: K.sitStart)
defaults.set(0.0, forKey: K.sitLastNotified)
}
func sitSetTrigger(_ min: Int) {
let v = max(5, min)
sitTriggerMin = v
defaults.set(v, forKey: K.sitTriggerMin)
// Reset dedupe window so the new threshold gets a fresh check.
defaults.set(0.0, forKey: K.sitLastNotified)
}
// MARK: - Water
func waterAddCup() {
waterCupsToday += 1
persistWaterToday()
}
func waterRemoveCup() {
guard waterCupsToday > 0 else { return }
waterCupsToday -= 1
persistWaterToday()
}
func waterSetGoal(_ goal: Int) {
let v = max(1, goal)
waterGoal = v
defaults.set(v, forKey: K.waterGoal)
}
private func persistWaterToday() {
var dict = (defaults.dictionary(forKey: K.waterHistory) as? [String: Int]) ?? [:]
dict[Self.dateKey(Date(), calendar: calendar)] = waterCupsToday
defaults.set(dict, forKey: K.waterHistory)
}
// MARK: - Clockout
func clockoutSet(hour: Int, minute: Int) {
let h = max(0, min(23, hour))
let m = max(0, min(59, minute))
clockoutHour = h
clockoutMinute = m
defaults.set(String(format: "%02d:%02d", h, m), forKey: K.clockoutHHmm)
}
/// Seconds remaining until today's clockout. If it's already past,
/// returns the seconds until tomorrow's clockout (rolls over).
var clockoutRemainingSec: Int {
let now = Date()
var comps = calendar.dateComponents([.year, .month, .day], from: now)
comps.hour = clockoutHour
comps.minute = clockoutMinute
comps.second = 0
var target = calendar.date(from: comps) ?? now
if target <= now {
target = calendar.date(byAdding: .day, value: 1, to: target) ?? target
}
return max(0, Int(target.timeIntervalSince(now)))
}
/// 0.0 = full workday left, 1.0 = at/past clockout time. Used for
/// the red bar fill. Anchor: 9 hours before clockout = full bar.
var clockoutProgress: Double {
let remaining = Double(clockoutRemainingSec)
let workdaySec: Double = 9 * 3600
let elapsed = workdaySec - remaining
return max(0.0, min(1.0, elapsed / workdaySec))
}
// MARK: - Weekend
private func recomputeWeekend() {
// Next Saturday 00:00 local. If today is already Saturday/Sunday,
// we still target the *next* Saturday (so users on weekends see
// a fresh ~7-day countdown that mirrors the spec's " =
// " intent).
let now = Date()
let weekday = calendar.component(.weekday, from: now) // Sun=1 ... Sat=7
// Days until next Saturday. If today is Saturday before midnight,
// we already passed the boundary today, so the next one is +7.
var daysUntilSat = (7 - weekday + 7) % 7
if daysUntilSat == 0 { daysUntilSat = 7 }
var comps = calendar.dateComponents([.year, .month, .day], from: now)
comps.hour = 0
comps.minute = 0
comps.second = 0
guard let midnightToday = calendar.date(from: comps),
let target = calendar.date(byAdding: .day, value: daysUntilSat, to: midnightToday) else {
return
}
let total = max(0, Int(target.timeIntervalSince(now)))
weekendDays = total / 86400
let rem1 = total % 86400
weekendHours = rem1 / 3600
let rem2 = rem1 % 3600
weekendMinutes = rem2 / 60
}
// MARK: - Day rollover
private var lastSeenDay: String = ""
private func rolloverIfNeeded() {
let today = Self.dateKey(Date(), calendar: calendar)
if lastSeenDay.isEmpty {
lastSeenDay = today
return
}
if today != lastSeenDay {
WorkerDebugLog.write("day rollover \(lastSeenDay)\(today)")
lastSeenDay = today
// Reload day-scoped counters.
let pomDict = (defaults.dictionary(forKey: K.pomodoroHistory) as? [String: Int]) ?? [:]
pomodoroTodayCount = pomDict[today] ?? 0
let waterDict = (defaults.dictionary(forKey: K.waterHistory) as? [String: Int]) ?? [:]
waterCupsToday = waterDict[today] ?? 0
}
}
// MARK: - Load + recompute
private func loadPersisted() {
// Pomodoro
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 pomDict = (defaults.dictionary(forKey: K.pomodoroHistory) as? [String: Int]) ?? [:]
let today = Self.dateKey(Date(), calendar: calendar)
pomodoroTodayCount = pomDict[today] ?? 0
lastSeenDay = today
// Sit
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)
} else {
sitStart = nil
}
// Water
let storedGoal = defaults.integer(forKey: K.waterGoal)
waterGoal = storedGoal > 0 ? storedGoal : 8
let waterDict = (defaults.dictionary(forKey: K.waterHistory) as? [String: Int]) ?? [:]
waterCupsToday = waterDict[today] ?? 0
// Clockout
let hhmm = defaults.string(forKey: K.clockoutHHmm) ?? "18:00"
let parts = hhmm.split(separator: ":").map { Int($0) ?? 0 }
if parts.count == 2 {
clockoutHour = parts[0]
clockoutMinute = parts[1]
} else {
clockoutHour = 18
clockoutMinute = 0
}
}
private func recomputeAll() {
recomputeWeekend()
if let start = sitStart {
sitElapsedSec = max(0, Int(Date().timeIntervalSince(start)))
}
}
// MARK: - Helpers
private static func dateKey(_ date: Date, calendar: Calendar) -> String {
let f = DateFormatter()
f.calendar = calendar
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = calendar.timeZone
f.dateFormat = "yyyy-MM-dd"
return f.string(from: date)
}
}