From 4b2ac2fc59112fcd6f71407ac2b603a06423157a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E7=BF=94=E5=AE=87?= Date: Wed, 20 May 2026 09:50:46 +0800 Subject: [PATCH] feat(water): pomodoro auto-+1 + hourly workday reminder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Sources/engine/WorkerStore.swift | 68 ++++++++++++++++++++++++++ Sources/ui/WaterView.swift | 84 +++++++++++++++++++++++++++----- 2 files changed, 141 insertions(+), 11 deletions(-) diff --git a/Sources/engine/WorkerStore.swift b/Sources/engine/WorkerStore.swift index 80a6402..07533d9 100644 --- a/Sources/engine/WorkerStore.swift +++ b/Sources/engine/WorkerStore.swift @@ -42,6 +42,9 @@ private enum K { 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 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 lastClockoutCelebDate = "clockout.lastCelebDate" // String yyyy-MM-dd, dedupe per-day } @@ -136,6 +139,13 @@ final class WorkerStore: ObservableObject { /// Water @Published var waterCupsToday: Int = 0 @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 @Published var clockoutHour: Int = 18 @@ -295,6 +305,9 @@ final class WorkerStore: ObservableObject { // 3. Weekend countdown — recompute every tick (cheap). recomputeWeekend() + // 3.5. Water hourly reminder — workday window only. + maybeFireWaterHourlyReminder(now: now) + // 4. Clockout — fire celebration on the boundary crossing edge. let curClockoutRem = clockoutRemainingSec if let prev = prevClockoutRemSec, prev > 0 && curClockoutRem == 0 { @@ -311,6 +324,34 @@ final class WorkerStore: ObservableObject { 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 + /// Glass tone × 3 + sets the in-panel banner flag for ~5s. private func triggerClockoutCelebrationIfNeeded() { @@ -435,6 +476,15 @@ final class WorkerStore: ObservableObject { pomodoroCycleProgress = min(4, pomodoroCycleProgress + 1) 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. let isLongBreak = pomodoroCycleProgress >= 4 let breakMin = isLongBreak ? pomodoroLongBreakMin : pomodoroBreakMin @@ -550,6 +600,16 @@ final class WorkerStore: ObservableObject { 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() { var dict = (defaults.dictionary(forKey: K.waterHistory) as? [String: Int]) ?? [:] dict[Self.dateKey(Date(), calendar: calendar)] = waterCupsToday @@ -749,6 +809,14 @@ final class WorkerStore: ObservableObject { waterGoal = storedGoal > 0 ? storedGoal : 8 let waterDict = (defaults.dictionary(forKey: K.waterHistory) as? [String: Int]) ?? [:] 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 let hhmm = defaults.string(forKey: K.clockoutHHmm) ?? "18:00" diff --git a/Sources/ui/WaterView.swift b/Sources/ui/WaterView.swift index 2c28417..eb0b67e 100644 --- a/Sources/ui/WaterView.swift +++ b/Sources/ui/WaterView.swift @@ -11,24 +11,86 @@ struct WaterView: View { @ObservedObject var store: WorkerStore var body: some View { - VStack(spacing: 18) { - statusBadge + ScrollView { + VStack(spacing: 14) { + statusBadge - cupHero + cupHero - controls + controls - Divider() - .background(WorkerTheme.overlay08) - .padding(.horizontal, 24) + Divider() + .background(WorkerTheme.overlay08) + .padding(.horizontal, 24) - goalRow + goalRow + toggleRows - tipText + tipText - Spacer(minLength: 0) + Spacer(minLength: 0) + } + .padding(.top, 8) + .padding(.bottom, 8) } - .padding(.top, 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