Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Sources/HapticBreak/Core/AppController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
28 changes: 24 additions & 4 deletions Sources/HapticBreak/Core/BreakTimer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions Sources/HapticBreak/Localization/L10n.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)", "集中モードで一時停止"],
Expand Down
21 changes: 20 additions & 1 deletion Sources/HapticBreak/UI/AppViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,34 @@ 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?

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 {
Expand Down
2 changes: 1 addition & 1 deletion Sources/HapticBreak/UI/MenuBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
3 changes: 2 additions & 1 deletion Sources/HapticBreak/UI/PopoverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
56 changes: 56 additions & 0 deletions Sources/HapticBreak/UI/Ring/RingViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -228,13 +247,20 @@ 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

/// L1 truth (lit/grids); L2 animation state.
@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) }
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
}
}
}
}