Skip to content
Merged
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
30 changes: 28 additions & 2 deletions StandLock/AppState/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ final class AppCoordinator: ObservableObject {
@Published var preferences: AppPreferences = AppPreferences()
@Published var hasCompletedOnboarding: Bool = false
@Published private(set) var breakProgress: Double = 0
@Published private(set) var menuBarTimerText: String?

let permissionChecker = PermissionChecker()

Expand Down Expand Up @@ -121,6 +122,7 @@ final class AppCoordinator: ObservableObject {
guard let data = try? JSONEncoder().encode(preferences) else { return }
UserDefaults.standard.set(data, forKey: "preferences")
coordinator?.updatePreferences(preferences)
updateMenuBarTimer()
}

private func syncPreferencesWithPermissions() {
Expand Down Expand Up @@ -220,6 +222,7 @@ final class AppCoordinator: ObservableObject {
nextBreakTime = date
breakScheduledAt = Date()
recalculateProgress()
updateMenuBarTimer()

case .breakStarted(let e):
deferralReason = nil
Expand All @@ -232,17 +235,20 @@ final class AppCoordinator: ObservableObject {
isBreakActive = false
currentBreakRemaining = 0
breakProgress = 0
menuBarTimerText = nil

case .breakDeferred(let reason, _):
deferralReason = reason

case .schedulePaused(let until):
isPaused = true
pausedUntil = until
menuBarTimerText = nil

case .scheduleResumed:
isPaused = false
pausedUntil = nil
updateMenuBarTimer()

case .statisticsUpdated(let stats):
todayStats = stats
Expand Down Expand Up @@ -304,13 +310,33 @@ final class AppCoordinator: ObservableObject {
private func startProgressTimer() {
progressTimer = Task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(10))
guard !Task.isCancelled else { break }
recalculateProgress()
updateMenuBarTimer()

let interval: Duration = if let remaining = nextBreakTime?.timeIntervalSinceNow,
menuBarTimerText != nil, remaining < 60 {
.seconds(1)
} else {
.seconds(10)
}
try? await Task.sleep(for: interval, tolerance: interval == .seconds(10) ? .seconds(2) : .milliseconds(100))
guard !Task.isCancelled else { break }
}
}
}

private func updateMenuBarTimer() {
let remaining = nextBreakTime?.timeIntervalSinceNow ?? 0
menuBarTimerText = formatMenuBarTimer(
secondsRemaining: remaining,
showFullTimer: preferences.showFullWorkTimer,
countdownMinutes: preferences.menuBarCountdownMinutes,
isBreakActive: isBreakActive,
isPaused: isPaused,
hasScheduledBreak: nextBreakTime != nil
)
}

private func recalculateProgress() {
if isPaused { return }
breakProgress = calculateBreakProgress(
Expand Down
4 changes: 4 additions & 0 deletions StandLock/StandLockApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ struct StandLockApp: App {
.environmentObject(appDelegate.updateObserver)
} label: {
Image(nsImage: MenuBarIcon.make(progress: appCoordinator.breakProgress))
if let timerText = appCoordinator.menuBarTimerText {
Text(timerText)
.monospacedDigit()
}
}
.menuBarExtraStyle(.window)

Expand Down
42 changes: 42 additions & 0 deletions StandLock/Views/Settings/GeneralSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import SwiftUI
import ServiceManagement

struct GeneralSettingsView: View {
@EnvironmentObject private var coordinator: AppCoordinator
@State private var launchAtStartup = SMAppService.mainApp.status == .enabled
@State private var errorMessage: String?
@State private var isUpdating = false
Expand Down Expand Up @@ -32,6 +33,47 @@ struct GeneralSettingsView: View {
.foregroundStyle(.red)
}
}

Section("Menu Bar Display") {
Toggle(isOn: $coordinator.preferences.showFullWorkTimer) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Show Full Work Timer")
Text("Always display remaining time next to the icon")
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "timer")
}
}
.onChange(of: coordinator.preferences.showFullWorkTimer) { _ in
coordinator.savePreferences()
}

if !coordinator.preferences.showFullWorkTimer {
Picker(selection: $coordinator.preferences.menuBarCountdownMinutes) {
Text("1 min").tag(1)
Text("2 min").tag(2)
Text("3 min").tag(3)
Text("5 min").tag(5)
} label: {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Countdown Start")
Text("Show timer in the last minutes before break")
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "clock.badge.checkmark")
}
}
.onChange(of: coordinator.preferences.menuBarCountdownMinutes) { _ in
coordinator.savePreferences()
}
}
}
}
.formStyle(.grouped)
}
Expand Down
26 changes: 26 additions & 0 deletions StandLockKit/Sources/StandLockCore/BreakProgress.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,32 @@ public func calculateBreakProgress(
return min(1.0, max(0.0, elapsed / total))
}

public func formatMenuBarTimer(
secondsRemaining: TimeInterval,
showFullTimer: Bool,
countdownMinutes: Int,
isBreakActive: Bool,
isPaused: Bool,
hasScheduledBreak: Bool
) -> String? {
if isBreakActive || isPaused || !hasScheduledBreak { return nil }
let remaining = max(0, secondsRemaining)

if !showFullTimer {
let threshold = TimeInterval(countdownMinutes * 60)
if remaining > threshold { return nil }
}

let wholeSeconds = remaining.rounded(.down)
if wholeSeconds < 60 {
let seconds = Int(wholeSeconds) % 60
return String(format: "0:%02d", seconds)
} else {
let minutes = Int(ceil(wholeSeconds / 60))
return "\(minutes)m"
}
}

public enum ProgressDisplayBranch: Sendable, Equatable {
case empty
case partial
Expand Down
12 changes: 11 additions & 1 deletion StandLockKit/Sources/StandLockCore/Models/AppPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ public struct AppPreferences: Codable, Sendable, Equatable {

public var resetIntervalOnSkip: Bool

public var showFullWorkTimer: Bool
public var menuBarCountdownMinutes: Int

public init(
gentleDailySkipLimit: Int = 5,
firmSkipDelay: TimeInterval = 10,
Expand All @@ -36,7 +39,9 @@ public struct AppPreferences: Codable, Sendable, Equatable {
focusModeDetection: DetectionBehavior = .deferBreak,
idleDetectionEnabled: Bool = true,
pauseMediaDuringBreak: Bool = true,
resetIntervalOnSkip: Bool = true
resetIntervalOnSkip: Bool = true,
showFullWorkTimer: Bool = false,
menuBarCountdownMinutes: Int = 1
) {
self.gentleDailySkipLimit = gentleDailySkipLimit
self.firmSkipDelay = firmSkipDelay
Expand All @@ -53,6 +58,8 @@ public struct AppPreferences: Codable, Sendable, Equatable {
self.idleDetectionEnabled = idleDetectionEnabled
self.pauseMediaDuringBreak = pauseMediaDuringBreak
self.resetIntervalOnSkip = resetIntervalOnSkip
self.showFullWorkTimer = showFullWorkTimer
self.menuBarCountdownMinutes = menuBarCountdownMinutes
}

private enum CodingKeys: String, CodingKey {
Expand All @@ -65,6 +72,7 @@ public struct AppPreferences: Codable, Sendable, Equatable {
case focusModeDetection, idleDetectionEnabled
case pauseMediaDuringBreak
case resetIntervalOnSkip
case showFullWorkTimer, menuBarCountdownMinutes
}

public init(from decoder: Decoder) throws {
Expand All @@ -84,6 +92,8 @@ public struct AppPreferences: Codable, Sendable, Equatable {
idleDetectionEnabled = try c.decodeIfPresent(Bool.self, forKey: .idleDetectionEnabled) ?? true
pauseMediaDuringBreak = try c.decodeIfPresent(Bool.self, forKey: .pauseMediaDuringBreak) ?? true
resetIntervalOnSkip = try c.decodeIfPresent(Bool.self, forKey: .resetIntervalOnSkip) ?? true
showFullWorkTimer = try c.decodeIfPresent(Bool.self, forKey: .showFullWorkTimer) ?? false
menuBarCountdownMinutes = try c.decodeIfPresent(Int.self, forKey: .menuBarCountdownMinutes) ?? 1
}
}

Expand Down
86 changes: 86 additions & 0 deletions StandLockKit/Tests/StandLockCoreTests/BreakProgressTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,90 @@ struct BreakProgressTests {
@Test func overOneClampsToFull() {
#expect(ProgressDisplayBranch(progress: 2.0) == .full)
}

// MARK: - formatMenuBarTimer

@Test func timerHiddenWhenBreakActive() {
#expect(formatMenuBarTimer(
secondsRemaining: 30, showFullTimer: true, countdownMinutes: 1,
isBreakActive: true, isPaused: false, hasScheduledBreak: true
) == nil)
}

@Test func timerHiddenWhenPaused() {
#expect(formatMenuBarTimer(
secondsRemaining: 30, showFullTimer: true, countdownMinutes: 1,
isBreakActive: false, isPaused: true, hasScheduledBreak: true
) == nil)
}

@Test func timerHiddenWhenNoSchedule() {
#expect(formatMenuBarTimer(
secondsRemaining: 30, showFullTimer: true, countdownMinutes: 1,
isBreakActive: false, isPaused: false, hasScheduledBreak: false
) == nil)
}

@Test func fullTimerShowsMinutes() {
#expect(formatMenuBarTimer(
secondsRemaining: 1920, showFullTimer: true, countdownMinutes: 1,
isBreakActive: false, isPaused: false, hasScheduledBreak: true
) == "32m")
}

@Test func fullTimerShowsSecondsUnder60() {
#expect(formatMenuBarTimer(
secondsRemaining: 45, showFullTimer: true, countdownMinutes: 1,
isBreakActive: false, isPaused: false, hasScheduledBreak: true
) == "0:45")
}

@Test func countdownHiddenAboveThreshold() {
#expect(formatMenuBarTimer(
secondsRemaining: 120, showFullTimer: false, countdownMinutes: 1,
isBreakActive: false, isPaused: false, hasScheduledBreak: true
) == nil)
}

@Test func countdownVisibleWithinThreshold() {
#expect(formatMenuBarTimer(
secondsRemaining: 45, showFullTimer: false, countdownMinutes: 1,
isBreakActive: false, isPaused: false, hasScheduledBreak: true
) == "0:45")
}

@Test func countdownThreeMinutesShowsMinutes() {
#expect(formatMenuBarTimer(
secondsRemaining: 150, showFullTimer: false, countdownMinutes: 3,
isBreakActive: false, isPaused: false, hasScheduledBreak: true
) == "3m")
}

@Test func minuteRoundsUp() {
#expect(formatMenuBarTimer(
secondsRemaining: 90, showFullTimer: true, countdownMinutes: 1,
isBreakActive: false, isPaused: false, hasScheduledBreak: true
) == "2m")
}

@Test func exactMinuteBoundary() {
#expect(formatMenuBarTimer(
secondsRemaining: 60, showFullTimer: true, countdownMinutes: 1,
isBreakActive: false, isPaused: false, hasScheduledBreak: true
) == "1m")
}

@Test func fractionalSecondsAboveMinuteNotRoundedUp() {
#expect(formatMenuBarTimer(
secondsRemaining: 60.01, showFullTimer: true, countdownMinutes: 1,
isBreakActive: false, isPaused: false, hasScheduledBreak: true
) == "1m")
}

@Test func zeroSecondsFormatsCorrectly() {
#expect(formatMenuBarTimer(
secondsRemaining: 0, showFullTimer: true, countdownMinutes: 1,
isBreakActive: false, isPaused: false, hasScheduledBreak: true
) == "0:00")
}
}
Loading