mirror of
https://github.com/carey314/mio-plugin-worker.git
synced 2026-08-10 07:04:32 +00:00
441 lines
14 KiB
Swift
441 lines
14 KiB
Swift
|
|
//
|
||
|
|
// 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)
|
||
|
|
}
|
||
|
|
}
|