From 32006b6bb4fea374d4d06aafb0a34cc8398b795b Mon Sep 17 00:00:00 2001 From: mo Date: Thu, 30 Jul 2026 11:15:51 +0800 Subject: [PATCH] feat: show idle countdown in menu bar and ring during reminding --- Sources/HapticBreak/Core/AppController.swift | 4 ++ Sources/HapticBreak/Core/BreakTimer.swift | 28 ++++++++-- Sources/HapticBreak/Localization/L10n.swift | 1 + Sources/HapticBreak/UI/AppViewModel.swift | 21 ++++++- .../HapticBreak/UI/MenuBarController.swift | 2 +- Sources/HapticBreak/UI/PopoverView.swift | 3 +- Sources/HapticBreak/UI/Ring/RingViews.swift | 56 +++++++++++++++++++ 7 files changed, 108 insertions(+), 7 deletions(-) diff --git a/Sources/HapticBreak/Core/AppController.swift b/Sources/HapticBreak/Core/AppController.swift index 4e7f3c6..305a417 100644 --- a/Sources/HapticBreak/Core/AppController.swift +++ b/Sources/HapticBreak/Core/AppController.swift @@ -399,6 +399,10 @@ final class AppController: NSObject, BreakTimerDelegate { /// write fires `objectWillChange` regardless of equality, and this runs once per second — unconditional /// writes would invalidate every observer (collapsed popover, any open window) five times per tick. private func syncViewModel() { + // Reminding countdown: read-only from the timer (purely for visual display). + let cd = timer.remindingCountdown + if viewModel.remindingCountdown != cd { viewModel.remindingCountdown = cd } + if viewModel.remaining != timer.remaining { viewModel.remaining = timer.remaining } if viewModel.total != timer.total { viewModel.total = timer.total } if viewModel.phase != timer.phase { diff --git a/Sources/HapticBreak/Core/BreakTimer.swift b/Sources/HapticBreak/Core/BreakTimer.swift index e717c1d..52144f3 100644 --- a/Sources/HapticBreak/Core/BreakTimer.swift +++ b/Sources/HapticBreak/Core/BreakTimer.swift @@ -106,6 +106,17 @@ final class BreakTimer { /// Matches `RestConfirmer.idleThreshold`, so the honest-rest confirmation follows naturally. private let ackIdleThreshold: TimeInterval = 30 + /// Most recent idle-seconds reading (exposed read-only so the UI can visualize the + /// reminding countdown without recomputing `30 - idleSeconds` outside the timer). + private(set) var lastIdleSeconds: TimeInterval = 0 + + /// Reminding idle countdown (30→0). Returns -1 when not in the reminding phase. + /// This is a *stored* value (not computed) because `completeAcknowledge()` changes + /// `phase` before the delegate fires — a computed property checking `phase` would always + /// return -1 by the time the delegate reads it, losing the final "0s" frame. + private var _remindingCountdown: Int = -1 + var remindingCountdown: Int { _remindingCountdown } + init(settings: Settings, now: @escaping () -> Date = Date.init) { self.settings = settings self.now = now @@ -172,6 +183,7 @@ final class BreakTimer { let tickDate = now() let gap = tickDate.timeIntervalSince(lastTick) lastTick = tickDate + lastIdleSeconds = idleSeconds // 1) Compute idle reset (work segment only, enabled and threshold > 0) if settings.idleEnabled, settings.idleResetMinutes > 0, phase == .working { @@ -220,24 +232,28 @@ final class BreakTimer { driftSeconds += 1 } - // 3) Reminding phase: wait for acknowledgment, re-nudging gently on a bounded schedule + // 3) Reset the countdown when we're no longer in the reminding phase, so the UI + // doesn't show a stale value (it's also recalculated inside tickReminding). + if phase != .reminding { _remindingCountdown = -1 } + + // 4) Reminding phase: wait for acknowledgment, re-nudging gently on a bounded schedule if phase == .reminding { tickReminding(idleSeconds: idleSeconds, seconds: seconds) delegate?.breakTimerStateChanged(self) return false } - // 4) Approaching reminder: one early "heads-up tap" (CR-02) + // 5) Approaching reminder: one early "heads-up tap" (CR-02) if settings.gentleHeadsUp, phase == .working, !headsUpFired, remaining > 0, remaining <= preWarnThreshold() { headsUpFired = true delegate?.breakTimerWillFireSoon(self) } - // 5) Decrement by the elapsed whole seconds (never below 0) + // 6) Decrement by the elapsed whole seconds (never below 0) if remaining > 0 { remaining = max(0, remaining - seconds) } - // 6) Expiry: typing-aware defer (CR-01) or normal expiry + // 7) Expiry: typing-aware defer (CR-01) or normal expiry if remaining <= 0 { if phase == .working, settings.typingAwareDefer, idleSeconds < typingPauseThreshold, deferredSeconds < maxDeferSeconds { @@ -256,6 +272,10 @@ final class BreakTimer { /// otherwise replay the full user preset every `remindPulseSeconds`, auto-postponing at the cap. /// The teaching hint fires once per cycle, on the nudge that reaches `min(3, cap)`. private func tickReminding(idleSeconds: TimeInterval, seconds: Int) { + // Capture the countdown *before* any acknowledge call — `completeAcknowledge()` + // changes `phase`, so the stored value is the only way the delegate can read it. + _remindingCountdown = max(0, min(30, 30 - Int(idleSeconds))) + if idleSeconds >= ackIdleThreshold { completeAcknowledge(.stepAway) return diff --git a/Sources/HapticBreak/Localization/L10n.swift b/Sources/HapticBreak/Localization/L10n.swift index 2e432d4..f934b35 100644 --- a/Sources/HapticBreak/Localization/L10n.swift +++ b/Sources/HapticBreak/Localization/L10n.swift @@ -127,6 +127,7 @@ extension L10n { "status.waitingTyping": ["等待打字停顿…", "Waiting for a typing pause…", "入力の区切りを待っています…"], "status.reminding": ["该休息了", "Time for a break", "休憩の時間"], "status.breakShort": ["休息", "Break", "休憩"], + "status.countdown": ["%ds", "%ds", "%ds"], "status.paused": ["已暂停", "Paused", "一時停止中"], "status.idlePaused": ["空闲暂停", "Paused (idle)", "アイドルで一時停止"], "status.focusPaused": ["专注模式暂停", "Paused (Focus)", "集中モードで一時停止"], diff --git a/Sources/HapticBreak/UI/AppViewModel.swift b/Sources/HapticBreak/UI/AppViewModel.swift index 3f7ce90..560c007 100644 --- a/Sources/HapticBreak/UI/AppViewModel.swift +++ b/Sources/HapticBreak/UI/AppViewModel.swift @@ -37,6 +37,10 @@ final class AppViewModel: ObservableObject { /// Flipped to `true` after several unanswered nudges to show the teaching hint card /// (explaining triple-tap / step-away acknowledgment). Cleared when the phase leaves reminding. @Published var showNudgeHint: Bool = false + /// 30 s idle-countdown mirror (30→0) during the reminding phase; –1 when not in reminding. + /// Updated once per tick by AppController.syncViewModel() — the UI reads this to drive the + /// countdown arc, ring center readout, and menu-bar text without touching any BreakTimer logic. + @Published var remindingCountdown: Int = -1 let settings = Settings.shared weak var controller: AppController? @@ -44,8 +48,23 @@ final class AppViewModel: ObservableObject { var isPaused: Bool { pauseReason.isPaused } var progress: Double { total > 0 ? Double(total - remaining) / Double(total) : 0 } /// While a reminder awaits acknowledgment there is no countdown — show a short invitation instead. + /// When the UI has a live idle-countdown mirror (reminding + countdown ≥ 0) show a bare "Ns" for + /// the ring center readout (e.g. "25s"). The menu bar uses `menuBarTitle` which combines the + /// break label + countdown (e.g. "休息 25s"). var timeString: String { - phase == .reminding ? L.t("status.breakShort") : Self.format(remaining) + if phase == .reminding && remindingCountdown >= 0 { + return L.t("status.countdown", remindingCountdown) + } + return phase == .reminding ? L.t("status.breakShort") : Self.format(remaining) + } + + /// Menu-bar title: during reminding with a live countdown this is "休息 25s" (status label + "Ns"); + /// otherwise identical to `timeString`. + var menuBarTitle: String { + if phase == .reminding && remindingCountdown >= 0 { + return L.t("status.breakShort") + " \(remindingCountdown)s" + } + return timeString } static func format(_ seconds: Int) -> String { diff --git a/Sources/HapticBreak/UI/MenuBarController.swift b/Sources/HapticBreak/UI/MenuBarController.swift index e08f4ab..f9df661 100644 --- a/Sources/HapticBreak/UI/MenuBarController.swift +++ b/Sources/HapticBreak/UI/MenuBarController.swift @@ -94,7 +94,7 @@ final class MenuBarController: NSObject { let image: NSImage? switch viewModel.settings.menuBarStyle { case .iconCountdown: - title = " " + viewModel.timeString + title = " " + viewModel.menuBarTitle image = iconImage case .iconOnly: title = "\u{200B}" diff --git a/Sources/HapticBreak/UI/PopoverView.swift b/Sources/HapticBreak/UI/PopoverView.swift index a1924f7..4dbaafc 100644 --- a/Sources/HapticBreak/UI/PopoverView.swift +++ b/Sources/HapticBreak/UI/PopoverView.swift @@ -36,7 +36,8 @@ struct PopoverView: View { breathing: viewModel.phase == .resting && !viewModel.isPaused && viewModel.panelVisible, onImpact: { viewModel.ringImpact() }, onTap: ringTap, - tapHint: ringTapHint) + tapHint: ringTapHint, + countdown: viewModel.remindingCountdown >= 0 ? viewModel.remindingCountdown : nil) .padding(.top, 14) VStack(spacing: 8) { diff --git a/Sources/HapticBreak/UI/Ring/RingViews.swift b/Sources/HapticBreak/UI/Ring/RingViews.swift index d223b5d..cfe9f28 100644 --- a/Sources/HapticBreak/UI/Ring/RingViews.swift +++ b/Sources/HapticBreak/UI/Ring/RingViews.swift @@ -177,6 +177,25 @@ private struct CenterReadout: View { } } +/// Reminding idle-countdown arc: a separate subtle arc tracking 30→0 s. Drawn slightly wider / lower +/// opacity so it reads as a "shadow" of the remaining idle window, distinct from the minute ring. +/// Stateless pure render — the parent drives animation via `.animation()` on `progress`. +private struct CountdownArc: View { + var progress: Double // 0...1 + var color: Color + var lineWidth: CGFloat + var size: CGFloat + var body: some View { + Circle() + .trim(from: 0, to: max(0, min(1, progress))) + .stroke(color.opacity(0.45), style: StrokeStyle(lineWidth: lineWidth + 4, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .padding((lineWidth + 4) / 2) + .frame(width: size, height: size) + .allowsHitTesting(false) + } +} + /// Rest-phase breathing halo: a soft glow behind the ring that swells and settles on an 8-second /// breath cycle. Driven by the existing 1 Hz elapsed value — the target flips every 4 s and the /// implicit ease interpolates, so there is **no repeatForever** (the panel-CPU hard rule). @@ -228,6 +247,9 @@ struct CountdownRing: View { /// The hover hint (symbol + label) temporarily replaces the status readout for discoverability. var onTap: (() -> Void)? = nil var tapHint: (symbol: String, label: String)? = nil + /// Reminding idle countdown in seconds (30→0). When non-nil a subtle CountdownArc appears inside + /// the minute ring, animated with spring-bounce on idle reset. `nil` hides the arc entirely. + var countdown: Int? = nil private let lineWidth: CGFloat = 8 @@ -235,6 +257,10 @@ struct CountdownRing: View { @State private var model = RingModel(.init(remaining: 0, total: 1, animated: true)) @StateObject private var anim = RingAnimator() @State private var hovering = false + /// Reminding idle-countdown animation state — driven by `onChange(of: countdown)`. + @State private var displayedCountdown: Double = 30 + @State private var lastBounce = Date.distantPast + @State private var countdownInitialized = false private var input: RingModel.Input { .init(remaining: remaining, total: total, animated: animated) } private var geo: RingGeometry { RingGeometry(size: size, lineWidth: lineWidth) } @@ -248,6 +274,13 @@ struct CountdownRing: View { MinuteArc(lit: anim.displayedLit, grids: model.grids, color: color, lineWidth: lineWidth, flash: anim.minuteFlash) + // Reminding idle countdown arc — appears inside the minute ring, shrinks 30→0, + // spring-bounces on activity reset. Independent of L1/L2 minute-deduction pipeline. + if countdown != nil { + CountdownArc(progress: displayedCountdown / 30.0, + color: color, lineWidth: lineWidth, size: size) + } + // Continuous ornaments live on Core Animation layers. The shimmer leaves the tree when the ring // is not running, so nothing spins in the background. if animated { @@ -303,6 +336,29 @@ struct CountdownRing: View { // above), so its repeating animation is guaranteed to stop when the panel collapses. if !isOn { anim.snap(toLit: model.lit) } } + .onChange(of: countdown) { newValue in + guard let target = newValue else { return } + if !countdownInitialized { + displayedCountdown = Double(target) + countdownInitialized = true + return + } + if target > Int(displayedCountdown.rounded()) { + // Idle reset: spring-bounce back to full. Debounce to 1 s so jittery input + // doesn't retrigger the bounce every tick. + let now = Date() + guard now.timeIntervalSince(lastBounce) >= 1.0 else { return } + lastBounce = now + withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) { + displayedCountdown = Double(target) + } + } else { + // Normal countdown: smooth linear decrement. + withAnimation(.linear(duration: 0.3)) { + displayedCountdown = Double(target) + } + } + } } }