mirror of
https://github.com/carey314/mio-plugin-worker.git
synced 2026-08-10 07:04:32 +00:00
feat(water): pomodoro auto-+1 + hourly workday reminder
Two cross-tab integrations from the 2026-05-20 product superpower review: 1. waterAutoFromPomodoro (default true) — each pomodoro focus phase end auto-logs 1 cup. Lazy users get streak data without taps, pomodoro break is the natural "go drink water" cue. 2. waterHourlyReminder (default true) — Mon–Fri 9..18 hourly nag if cups < goal. Deduped by last-fired-hour so plugin restart in the same hour won't double-fire. UI: WaterView now wraps in ScrollView (toggles push it past 580pt), adds two togglePill rows below the goalRow. Custom slim-track indicator since SwiftUI's Toggle styling clashes with the plugin's dark capsule aesthetic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f0a116ccf7
commit
4b2ac2fc59
@ -42,6 +42,9 @@ private enum K {
|
|||||||
static let sitAccumActive = "sit.accumActive" // Int seconds, persisted active time
|
static let sitAccumActive = "sit.accumActive" // Int seconds, persisted active time
|
||||||
static let waterHistory = "water.history" // [String: Int] (date → cups)
|
static let waterHistory = "water.history" // [String: Int] (date → cups)
|
||||||
static let waterGoal = "water.goal" // Int, default 8
|
static let waterGoal = "water.goal" // Int, default 8
|
||||||
|
static let waterAutoFromPomo = "water.autoFromPomo" // Bool, default true
|
||||||
|
static let waterHourlyReminder = "water.hourlyReminder" // Bool, default true
|
||||||
|
static let lastWaterReminderHr = "water.lastReminderHour" // Int, hour 0-23 last fired (per day)
|
||||||
static let clockoutHHmm = "clockout.hhmm" // String "18:00"
|
static let clockoutHHmm = "clockout.hhmm" // String "18:00"
|
||||||
static let lastClockoutCelebDate = "clockout.lastCelebDate" // String yyyy-MM-dd, dedupe per-day
|
static let lastClockoutCelebDate = "clockout.lastCelebDate" // String yyyy-MM-dd, dedupe per-day
|
||||||
}
|
}
|
||||||
@ -136,6 +139,13 @@ final class WorkerStore: ObservableObject {
|
|||||||
/// Water
|
/// Water
|
||||||
@Published var waterCupsToday: Int = 0
|
@Published var waterCupsToday: Int = 0
|
||||||
@Published var waterGoal: Int = 8
|
@Published var waterGoal: Int = 8
|
||||||
|
/// When ON (default), completing a pomodoro focus phase auto-adds
|
||||||
|
/// 1 cup. Lazy-tracker users get streak-built without manual taps.
|
||||||
|
@Published var waterAutoFromPomodoro: Bool = true
|
||||||
|
/// When ON (default), hourly notification fires during 9-18 if today's
|
||||||
|
/// cups < goal. Dedupe by last-fired-hour so plugin restart doesn't
|
||||||
|
/// double-fire the same hour.
|
||||||
|
@Published var waterHourlyReminder: Bool = true
|
||||||
|
|
||||||
/// Clockout
|
/// Clockout
|
||||||
@Published var clockoutHour: Int = 18
|
@Published var clockoutHour: Int = 18
|
||||||
@ -295,6 +305,9 @@ final class WorkerStore: ObservableObject {
|
|||||||
// 3. Weekend countdown — recompute every tick (cheap).
|
// 3. Weekend countdown — recompute every tick (cheap).
|
||||||
recomputeWeekend()
|
recomputeWeekend()
|
||||||
|
|
||||||
|
// 3.5. Water hourly reminder — workday window only.
|
||||||
|
maybeFireWaterHourlyReminder(now: now)
|
||||||
|
|
||||||
// 4. Clockout — fire celebration on the boundary crossing edge.
|
// 4. Clockout — fire celebration on the boundary crossing edge.
|
||||||
let curClockoutRem = clockoutRemainingSec
|
let curClockoutRem = clockoutRemainingSec
|
||||||
if let prev = prevClockoutRemSec, prev > 0 && curClockoutRem == 0 {
|
if let prev = prevClockoutRemSec, prev > 0 && curClockoutRem == 0 {
|
||||||
@ -311,6 +324,34 @@ final class WorkerStore: ObservableObject {
|
|||||||
rolloverIfNeeded()
|
rolloverIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hourly water nag — fires once at the top of the hour during
|
||||||
|
/// workday (9..18) on weekdays if the user's behind their cup goal.
|
||||||
|
/// Dedupes by storing the last-fired hour; plugin restart inside
|
||||||
|
/// the same hour won't re-fire.
|
||||||
|
private func maybeFireWaterHourlyReminder(now: Date) {
|
||||||
|
guard waterHourlyReminder else { return }
|
||||||
|
// Weekend = no nag — combined with the existing isWeekend gate
|
||||||
|
// for clockout, this keeps Saturday/Sunday quiet.
|
||||||
|
let weekday = calendar.component(.weekday, from: now)
|
||||||
|
guard (2...6).contains(weekday) else { return }
|
||||||
|
let hour = calendar.component(.hour, from: now)
|
||||||
|
guard (9...18).contains(hour) else { return }
|
||||||
|
// Already reached goal — no nag.
|
||||||
|
guard waterCupsToday < waterGoal else { return }
|
||||||
|
let lastFiredHour = defaults.integer(forKey: K.lastWaterReminderHr)
|
||||||
|
// Sentinel: 0 means "never fired" — first run at any hour >= 9
|
||||||
|
// will pass the != check. We accept one wasted re-fire on the
|
||||||
|
// boundary case where last == hour 0 from a fresh install.
|
||||||
|
guard lastFiredHour != hour else { return }
|
||||||
|
defaults.set(hour, forKey: K.lastWaterReminderHr)
|
||||||
|
let remaining = waterGoal - waterCupsToday
|
||||||
|
WorkerNotificationCenter.shared.notify(
|
||||||
|
title: "💧 喝水时间到",
|
||||||
|
body: "今天已喝 \(waterCupsToday) 杯,距 \(waterGoal) 杯目标还差 \(remaining) 杯。"
|
||||||
|
)
|
||||||
|
WorkerDebugLog.write("water hourly reminder fired @ \(hour):00, cups \(waterCupsToday)/\(waterGoal)")
|
||||||
|
}
|
||||||
|
|
||||||
/// Per-day-deduped celebration trigger. Fires UN notification +
|
/// Per-day-deduped celebration trigger. Fires UN notification +
|
||||||
/// Glass tone × 3 + sets the in-panel banner flag for ~5s.
|
/// Glass tone × 3 + sets the in-panel banner flag for ~5s.
|
||||||
private func triggerClockoutCelebrationIfNeeded() {
|
private func triggerClockoutCelebrationIfNeeded() {
|
||||||
@ -435,6 +476,15 @@ final class WorkerStore: ObservableObject {
|
|||||||
pomodoroCycleProgress = min(4, pomodoroCycleProgress + 1)
|
pomodoroCycleProgress = min(4, pomodoroCycleProgress + 1)
|
||||||
defaults.set(pomodoroCycleProgress, forKey: K.pomodoroCycleProg)
|
defaults.set(pomodoroCycleProgress, forKey: K.pomodoroCycleProg)
|
||||||
|
|
||||||
|
// Cross-tab boost: a completed focus phase auto-logs a cup
|
||||||
|
// of water unless the user has flipped the toggle off.
|
||||||
|
// Rationale: people who do pomodoro typically need a break +
|
||||||
|
// water anyway — couple the data so they don't have to tap.
|
||||||
|
if waterAutoFromPomodoro {
|
||||||
|
waterAddCup()
|
||||||
|
WorkerDebugLog.write("water +1 from pomodoro phase end")
|
||||||
|
}
|
||||||
|
|
||||||
// 4th focus in the cycle → long break; otherwise short.
|
// 4th focus in the cycle → long break; otherwise short.
|
||||||
let isLongBreak = pomodoroCycleProgress >= 4
|
let isLongBreak = pomodoroCycleProgress >= 4
|
||||||
let breakMin = isLongBreak ? pomodoroLongBreakMin : pomodoroBreakMin
|
let breakMin = isLongBreak ? pomodoroLongBreakMin : pomodoroBreakMin
|
||||||
@ -550,6 +600,16 @@ final class WorkerStore: ObservableObject {
|
|||||||
defaults.set(v, forKey: K.waterGoal)
|
defaults.set(v, forKey: K.waterGoal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func waterSetAutoFromPomodoro(_ on: Bool) {
|
||||||
|
waterAutoFromPomodoro = on
|
||||||
|
defaults.set(on, forKey: K.waterAutoFromPomo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waterSetHourlyReminder(_ on: Bool) {
|
||||||
|
waterHourlyReminder = on
|
||||||
|
defaults.set(on, forKey: K.waterHourlyReminder)
|
||||||
|
}
|
||||||
|
|
||||||
private func persistWaterToday() {
|
private func persistWaterToday() {
|
||||||
var dict = (defaults.dictionary(forKey: K.waterHistory) as? [String: Int]) ?? [:]
|
var dict = (defaults.dictionary(forKey: K.waterHistory) as? [String: Int]) ?? [:]
|
||||||
dict[Self.dateKey(Date(), calendar: calendar)] = waterCupsToday
|
dict[Self.dateKey(Date(), calendar: calendar)] = waterCupsToday
|
||||||
@ -749,6 +809,14 @@ final class WorkerStore: ObservableObject {
|
|||||||
waterGoal = storedGoal > 0 ? storedGoal : 8
|
waterGoal = storedGoal > 0 ? storedGoal : 8
|
||||||
let waterDict = (defaults.dictionary(forKey: K.waterHistory) as? [String: Int]) ?? [:]
|
let waterDict = (defaults.dictionary(forKey: K.waterHistory) as? [String: Int]) ?? [:]
|
||||||
waterCupsToday = waterDict[today] ?? 0
|
waterCupsToday = waterDict[today] ?? 0
|
||||||
|
// Toggle defaults — "explicit-set check" so an installed-with-
|
||||||
|
// false user doesn't get flipped back to true on update.
|
||||||
|
waterAutoFromPomodoro = defaults.object(forKey: K.waterAutoFromPomo) != nil
|
||||||
|
? defaults.bool(forKey: K.waterAutoFromPomo)
|
||||||
|
: true
|
||||||
|
waterHourlyReminder = defaults.object(forKey: K.waterHourlyReminder) != nil
|
||||||
|
? defaults.bool(forKey: K.waterHourlyReminder)
|
||||||
|
: true
|
||||||
|
|
||||||
// Clockout
|
// Clockout
|
||||||
let hhmm = defaults.string(forKey: K.clockoutHHmm) ?? "18:00"
|
let hhmm = defaults.string(forKey: K.clockoutHHmm) ?? "18:00"
|
||||||
|
|||||||
@ -11,7 +11,8 @@ struct WaterView: View {
|
|||||||
@ObservedObject var store: WorkerStore
|
@ObservedObject var store: WorkerStore
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 18) {
|
ScrollView {
|
||||||
|
VStack(spacing: 14) {
|
||||||
statusBadge
|
statusBadge
|
||||||
|
|
||||||
cupHero
|
cupHero
|
||||||
@ -23,12 +24,73 @@ struct WaterView: View {
|
|||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
|
|
||||||
goalRow
|
goalRow
|
||||||
|
toggleRows
|
||||||
|
|
||||||
tipText
|
tipText
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Toggle rows (auto-from-pomodoro + hourly reminder)
|
||||||
|
|
||||||
|
private var toggleRows: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
togglePill(
|
||||||
|
icon: "timer",
|
||||||
|
label: "番茄完成 +1 杯",
|
||||||
|
isOn: store.waterAutoFromPomodoro,
|
||||||
|
tint: WorkerTheme.tomato,
|
||||||
|
action: { store.waterSetAutoFromPomodoro(!store.waterAutoFromPomodoro) }
|
||||||
|
)
|
||||||
|
togglePill(
|
||||||
|
icon: "bell.fill",
|
||||||
|
label: "整点提醒(9–18 点)",
|
||||||
|
isOn: store.waterHourlyReminder,
|
||||||
|
tint: WorkerTheme.water,
|
||||||
|
action: { store.waterSetHourlyReminder(!store.waterHourlyReminder) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func togglePill(icon: String, label: String, isOn: Bool, tint: Color, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(isOn ? tint : WorkerTheme.fg55)
|
||||||
|
.frame(width: 18)
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11.5, weight: .medium))
|
||||||
|
.foregroundColor(isOn ? WorkerTheme.fgPrimary : WorkerTheme.fg70)
|
||||||
|
Spacer()
|
||||||
|
// Slim track-style toggle indicator.
|
||||||
|
ZStack(alignment: isOn ? .trailing : .leading) {
|
||||||
|
Capsule()
|
||||||
|
.fill(isOn ? tint.opacity(0.35) : WorkerTheme.overlay08)
|
||||||
|
.frame(width: 28, height: 16)
|
||||||
|
Circle()
|
||||||
|
.fill(isOn ? tint : WorkerTheme.fg55)
|
||||||
|
.frame(width: 12, height: 12)
|
||||||
|
.padding(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.frame(height: 32)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(WorkerTheme.overlay04)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(isOn ? tint.opacity(0.35) : WorkerTheme.overlay08, lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Status
|
// MARK: - Status
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user