fix: User reminders fixed, UI improvements

This commit is contained in:
Michael Freno
2026-01-11 23:41:00 -05:00
parent 38f08d772e
commit e1f18f8344
12 changed files with 530 additions and 211 deletions

View File

@@ -169,6 +169,21 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
self?.timerEngine?.dismissReminder() self?.timerEngine?.dismissReminder()
} }
) )
case .userTimerTriggered(let timer):
if timer.type == .overlay {
contentView = AnyView(
UserTimerOverlayReminderView(timer: timer) { [weak self] in
self?.timerEngine?.dismissReminder()
}
)
} else {
let sizePercentage = settingsManager?.settings.subtleReminderSize.percentage ?? 5.0
contentView = AnyView(
UserTimerReminderView(timer: timer, sizePercentage: sizePercentage) { [weak self] in
self?.timerEngine?.dismissReminder()
}
)
}
} }
showReminderWindow(contentView) showReminderWindow(contentView)

View File

@@ -11,15 +11,32 @@ enum ReminderEvent: Equatable {
case lookAwayTriggered(countdownSeconds: Int) case lookAwayTriggered(countdownSeconds: Int)
case blinkTriggered case blinkTriggered
case postureTriggered case postureTriggered
case userTimerTriggered(UserTimer)
var type: TimerType { var iconName: String {
switch self { switch self {
case .lookAwayTriggered: case .lookAwayTriggered:
return .lookAway return "eye.fill"
case .blinkTriggered: case .blinkTriggered:
return .blink return "eye.slash.fill"
case .postureTriggered: case .postureTriggered:
return .posture return "figure.stand"
case .userTimerTriggered:
return "clock.fill"
}
}
var displayName: String {
switch self {
case .lookAwayTriggered:
return "Look Away"
case .blinkTriggered:
return "Blink"
case .postureTriggered:
return "Posture"
case .userTimerTriggered(let timer):
return timer.title
} }
} }
} }

View File

@@ -23,7 +23,7 @@ struct UserTimer: Codable, Equatable, Identifiable, Hashable {
id: String = UUID().uuidString, id: String = UUID().uuidString,
title: String? = nil, title: String? = nil,
type: UserTimerType = .subtle, type: UserTimerType = .subtle,
timeOnScreenSeconds: Int = 30, timeOnScreenSeconds: Int? = nil,
intervalMinutes: Int = 15, intervalMinutes: Int = 15,
message: String? = nil, message: String? = nil,
colorHex: String? = nil, colorHex: String? = nil,
@@ -32,7 +32,8 @@ struct UserTimer: Codable, Equatable, Identifiable, Hashable {
self.id = id self.id = id
self.title = title ?? "User Reminder" self.title = title ?? "User Reminder"
self.type = type self.type = type
self.timeOnScreenSeconds = timeOnScreenSeconds // Subtle timers always use 3 seconds, overlay timers default to 10
self.timeOnScreenSeconds = timeOnScreenSeconds ?? (type == .subtle ? 3 : 10)
self.intervalMinutes = intervalMinutes self.intervalMinutes = intervalMinutes
self.message = message self.message = message
self.colorHex = colorHex ?? UserTimer.defaultColors[0] self.colorHex = colorHex ?? UserTimer.defaultColors[0]

View File

@@ -16,6 +16,11 @@ class TimerEngine: ObservableObject {
// Track user timer states separately // Track user timer states separately
private var userTimerStates: [String: TimerState] = [:] private var userTimerStates: [String: TimerState] = [:]
// Expose user timer states for read-only access
var userTimerStatesReadOnly: [String: TimerState] {
return userTimerStates
}
private var timerSubscription: AnyCancellable? private var timerSubscription: AnyCancellable?
private let settingsManager: SettingsManager private let settingsManager: SettingsManager
@@ -119,11 +124,12 @@ class TimerEngine: ObservableObject {
for userTimer in settingsManager.settings.userTimers { for userTimer in settingsManager.settings.userTimers {
if let existingState = userTimerStates[userTimer.id] { if let existingState = userTimerStates[userTimer.id] {
// Check if interval changed // Check if interval changed
if existingState.originalIntervalSeconds != userTimer.timeOnScreenSeconds { let newIntervalSeconds = userTimer.intervalMinutes * 60
if existingState.originalIntervalSeconds != newIntervalSeconds {
// Interval changed - reset with new interval // Interval changed - reset with new interval
userTimerStates[userTimer.id] = TimerState( userTimerStates[userTimer.id] = TimerState(
type: .lookAway, // Placeholder type: .lookAway, // Placeholder
intervalSeconds: userTimer.timeOnScreenSeconds, intervalSeconds: newIntervalSeconds,
isPaused: existingState.isPaused, isPaused: existingState.isPaused,
isActive: userTimer.enabled isActive: userTimer.enabled
) )
@@ -159,6 +165,14 @@ class TimerEngine: ObservableObject {
} }
} }
func pauseTimer(type: TimerType) {
timerStates[type]?.isPaused = true
}
func resumeTimer(type: TimerType) {
timerStates[type]?.isPaused = false
}
func skipNext(type: TimerType) { func skipNext(type: TimerType) {
guard let state = timerStates[type] else { return } guard let state = timerStates[type] else { return }
let config = settingsManager.timerConfiguration(for: type) let config = settingsManager.timerConfiguration(for: type)
@@ -174,10 +188,28 @@ class TimerEngine: ObservableObject {
guard let reminder = activeReminder else { return } guard let reminder = activeReminder else { return }
activeReminder = nil activeReminder = nil
skipNext(type: reminder.type) // Skip to next interval based on reminder type
switch reminder {
case .lookAwayTriggered, .blinkTriggered, .postureTriggered:
// For built-in timers, we need to extract the TimerType
if case .lookAwayTriggered = reminder { if case .lookAwayTriggered = reminder {
skipNext(type: .lookAway)
resume() resume()
} else if case .blinkTriggered = reminder {
skipNext(type: .blink)
} else if case .postureTriggered = reminder {
skipNext(type: .posture)
}
case .userTimerTriggered(let timer):
// Reset the user timer
if let state = userTimerStates[timer.id] {
userTimerStates[timer.id] = TimerState(
type: .lookAway, // Placeholder
intervalSeconds: timer.intervalMinutes * 60,
isPaused: state.isPaused,
isActive: state.isActive
)
}
} }
} }
@@ -228,15 +260,8 @@ class TimerEngine: ObservableObject {
} }
private func triggerUserTimerReminder(forId id: String) { private func triggerUserTimerReminder(forId id: String) {
// Here we'd implement how to show a subtle reminder for user timers
// For now, just reset the timer
if let userTimer = settingsManager.settings.userTimers.first(where: { $0.id == id }) { if let userTimer = settingsManager.settings.userTimers.first(where: { $0.id == id }) {
userTimerStates[id] = TimerState( activeReminder = .userTimerTriggered(userTimer)
type: .lookAway, // Placeholder - user timers won't use this
intervalSeconds: userTimer.timeOnScreenSeconds,
isPaused: false,
isActive: true
)
} }
} }
@@ -257,7 +282,7 @@ class TimerEngine: ObservableObject {
func startUserTimer(_ userTimer: UserTimer) { func startUserTimer(_ userTimer: UserTimer) {
userTimerStates[userTimer.id] = TimerState( userTimerStates[userTimer.id] = TimerState(
type: .lookAway, // Placeholder - we'll need to make this more flexible type: .lookAway, // Placeholder - we'll need to make this more flexible
intervalSeconds: userTimer.timeOnScreenSeconds, intervalSeconds: userTimer.intervalMinutes * 60,
isPaused: false, isPaused: false,
isActive: true isActive: true
) )
@@ -281,6 +306,16 @@ class TimerEngine: ObservableObject {
} }
} }
func toggleUserTimerPause(_ userTimerId: String) {
if let state = userTimerStates[userTimerId] {
if state.isPaused {
resumeUserTimer(userTimerId)
} else {
pauseUserTimer(userTimerId)
}
}
}
func getTimeRemaining(for type: TimerType) -> TimeInterval { func getTimeRemaining(for type: TimerType) -> TimeInterval {
guard let state = timerStates[type] else { return 0 } guard let state = timerStates[type] else { return 0 }
return TimeInterval(state.remainingSeconds) return TimeInterval(state.remainingSeconds)
@@ -305,6 +340,10 @@ class TimerEngine: ObservableObject {
} }
} }
func isUserTimerPaused(_ userTimerId: String) -> Bool {
return userTimerStates[userTimerId]?.isPaused ?? true
}
func getUserFormattedTimeRemaining(for userId: String) -> String { func getUserFormattedTimeRemaining(for userId: String) -> String {
let seconds = Int(getUserTimeRemaining(for: userId)) let seconds = Int(getUserTimeRemaining(for: userId))
let minutes = seconds / 60 let minutes = seconds / 60

View File

@@ -187,10 +187,11 @@ struct MenuBarContentView: View {
.padding(.horizontal) .padding(.horizontal)
.padding(.top, 8) .padding(.top, 8)
ForEach(TimerType.allCases) { timerType in // Show regular timers with individual pause/resume controls
if timerEngine.timerStates[timerType] != nil { ForEach(Array(timerEngine.timerStates.keys), id: \.self) { timerType in
TimerStatusRow( if let state = timerEngine.timerStates[timerType] {
type: timerType, TimerStatusRowWithIndividualControls(
variant: .builtIn(timerType),
timerEngine: timerEngine, timerEngine: timerEngine,
onSkip: { onSkip: {
timerEngine.skipNext(type: timerType) timerEngine.skipNext(type: timerType)
@@ -198,13 +199,13 @@ struct MenuBarContentView: View {
onDevTrigger: { onDevTrigger: {
timerEngine.triggerReminder(for: timerType) timerEngine.triggerReminder(for: timerType)
}, },
onTap: { onTogglePause: { isPaused in
onOpenSettingsTab(timerType.tabIndex) if isPaused {
} timerEngine.pauseTimer(type: timerType)
)
} else { } else {
InactiveTimerRow( timerEngine.resumeTimer(type: timerType)
type: timerType, }
},
onTap: { onTap: {
onOpenSettingsTab(timerType.tabIndex) onOpenSettingsTab(timerType.tabIndex)
} }
@@ -212,12 +213,22 @@ struct MenuBarContentView: View {
} }
} }
// Show user timers if any exist and are enabled // Show user timers with individual pause/resume controls
ForEach(settingsManager.settings.userTimers.filter { $0.enabled }, id: \.id) { ForEach(settingsManager.settings.userTimers.filter { $0.enabled }, id: \.id) {
userTimer in userTimer in
UserTimerStatusRow( TimerStatusRowWithIndividualControls(
timer: userTimer, variant: .user(userTimer),
state: nil, // We'll implement proper state tracking later timerEngine: timerEngine,
onSkip: {
//TODO
},
onTogglePause: { isPaused in
if isPaused {
timerEngine.pauseUserTimer(userTimer.id)
} else {
timerEngine.resumeUserTimer(userTimer.id)
}
},
onTap: { onTap: {
onOpenSettingsTab(3) // Switch to User Timers tab onOpenSettingsTab(3) // Switch to User Timers tab
} }
@@ -231,7 +242,7 @@ struct MenuBarContentView: View {
// Controls // Controls
VStack(spacing: 4) { VStack(spacing: 4) {
Button(action: { Button(action: {
if isPaused(timerEngine: timerEngine) { if isAllPaused(timerEngine: timerEngine) {
timerEngine.resume() timerEngine.resume()
} else { } else {
timerEngine.pause() timerEngine.pause()
@@ -239,10 +250,10 @@ struct MenuBarContentView: View {
}) { }) {
HStack { HStack {
Image( Image(
systemName: isPaused(timerEngine: timerEngine) systemName: isAllPaused(timerEngine: timerEngine)
? "play.circle" : "pause.circle") ? "play.circle" : "pause.circle")
Text( Text(
isPaused(timerEngine: timerEngine) isAllPaused(timerEngine: timerEngine)
? "Resume All Timers" : "Pause All Timers") ? "Resume All Timers" : "Pause All Timers")
Spacer() Spacer()
} }
@@ -292,37 +303,103 @@ struct MenuBarContentView: View {
} }
} }
private func isPaused(timerEngine: TimerEngine) -> Bool { private func isAllPaused(timerEngine: TimerEngine) -> Bool {
timerEngine.timerStates.values.first?.isPaused ?? false // Check if all timers are paused
let activeStates = timerEngine.timerStates.values.filter { $0.isActive }
return !activeStates.isEmpty && activeStates.allSatisfy { $0.isPaused }
} }
} }
struct TimerStatusRow: View { struct TimerStatusRowWithIndividualControls: View {
let type: TimerType enum TimerVariant {
case builtIn(TimerType)
case user(UserTimer)
var displayName: String {
switch self {
case .builtIn(let type): return type.displayName
case .user(let timer): return timer.title
}
}
var iconName: String {
switch self {
case .builtIn(let type): return type.iconName
case .user: return "clock.fill"
}
}
var color: Color {
switch self {
case .builtIn(_):
return .accentColor
case .user(let timer): return timer.color
}
}
var tooltipText: String {
switch self {
case .builtIn(let type): return type.tooltipText
case .user(let timer):
let typeText = timer.type == .subtle ? "Subtle" : "Overlay"
let durationText = "\(timer.timeOnScreenSeconds)s on screen"
let statusText = timer.enabled ? "" : " (Disabled)"
return "\(typeText) timer - \(durationText)\(statusText)"
}
}
}
let variant: TimerVariant
@ObservedObject var timerEngine: TimerEngine @ObservedObject var timerEngine: TimerEngine
var onSkip: () -> Void var onSkip: () -> Void
var onDevTrigger: (() -> Void)? = nil var onDevTrigger: (() -> Void)? = nil
var onTogglePause: (Bool) -> Void
var onTap: (() -> Void)? = nil var onTap: (() -> Void)? = nil
@State private var isHoveredSkip = false @State private var isHoveredSkip = false
@State private var isHoveredDevTrigger = false @State private var isHoveredDevTrigger = false
@State private var isHoveredBody = false @State private var isHoveredBody = false
@State private var isHoveredPauseButton = false
private var state: TimerState? { private var state: TimerState? {
timerEngine.timerStates[type] switch variant {
case .builtIn(let type):
return timerEngine.timerStates[type]
case .user(let timer):
return timerEngine.userTimerStatesReadOnly[timer.id]
}
}
private var isPaused: Bool {
switch variant {
case .builtIn:
return state?.isPaused ?? false
case .user(let timer):
return !timer.enabled
}
} }
var body: some View { var body: some View {
HStack { HStack {
HStack { HStack {
Image(systemName: type.iconName) // Show color indicator circle for user timers
.foregroundColor(isHoveredBody ? .white : iconColor) if case .user(let timer) = variant {
Circle()
.fill(isHoveredBody ? .white : timer.color)
.frame(width: 8, height: 8)
}
Image(systemName: variant.iconName)
.foregroundColor(isHoveredBody ? .white : variant.color)
.frame(width: 20) .frame(width: 20)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(type.displayName) Text(variant.displayName)
.font(.subheadline) .font(.subheadline)
.fontWeight(.medium) .fontWeight(.medium)
.foregroundColor(isHoveredBody ? .white : .primary) .foregroundColor(isHoveredBody ? .white : .primary)
.lineLimit(1)
if let state = state { if let state = state {
Text(timeRemaining(state)) Text(timeRemaining(state))
.font(.caption) .font(.caption)
@@ -353,13 +430,39 @@ struct TimerStatusRow: View {
? GlassStyle.regular.tint(.yellow) : GlassStyle.regular, ? GlassStyle.regular.tint(.yellow) : GlassStyle.regular,
in: .circle in: .circle
) )
.help("Trigger \(type.displayName) reminder now (dev)") .help("Trigger \(variant.displayName) reminder now (dev)")
.onHover { hovering in .onHover { hovering in
isHoveredDevTrigger = hovering isHoveredDevTrigger = hovering
} }
} }
#endif #endif
// Individual pause/resume button
Button(action: {
onTogglePause(!isPaused)
}) {
Image(
systemName: isPaused ? "play.circle" : "pause.circle"
)
.font(.caption)
.foregroundColor(isHoveredPauseButton ? .white : .accentColor)
.padding(6)
.contentShape(Circle())
}
.buttonStyle(.plain)
.glassEffectIfAvailable(
isHoveredPauseButton
? GlassStyle.regular.tint(.accentColor) : GlassStyle.regular,
in: .circle
)
.help(
isPaused
? "Resume \(variant.displayName)" : "Pause \(variant.displayName)"
)
.onHover { hovering in
isHoveredPauseButton = hovering
}
Button(action: onSkip) { Button(action: onSkip) {
Image(systemName: "forward.fill") Image(systemName: "forward.fill")
.font(.caption) .font(.caption)
@@ -373,7 +476,7 @@ struct TimerStatusRow: View {
? GlassStyle.regular.tint(.accentColor) : GlassStyle.regular, ? GlassStyle.regular.tint(.accentColor) : GlassStyle.regular,
in: .circle in: .circle
) )
.help("Skip to next \(type.displayName) reminder") .help("Skip to next \(variant.displayName) reminder")
.onHover { hovering in .onHover { hovering in
isHoveredSkip = hovering isHoveredSkip = hovering
} }
@@ -381,152 +484,16 @@ struct TimerStatusRow: View {
.padding(.horizontal, 8) .padding(.horizontal, 8)
.padding(.vertical, 6) .padding(.vertical, 6)
.glassEffectIfAvailable( .glassEffectIfAvailable(
isHoveredBody ? GlassStyle.regular.tint(.accentColor) : GlassStyle.regular, isHoveredBody
? GlassStyle.regular.tint(variant.color)
: GlassStyle.regular,
in: .rect(cornerRadius: 6) in: .rect(cornerRadius: 6)
) )
.padding(.horizontal, 8) .padding(.horizontal, 8)
.onHover { hovering in .onHover { hovering in
isHoveredBody = hovering isHoveredBody = hovering
} }
.help(tooltipText) .help(variant.tooltipText)
}
private var tooltipText: String {
type.tooltipText
}
private var iconColor: Color {
switch type {
case .lookAway: return .accentColor
case .blink: return .green
case .posture: return .orange
}
}
private func timeRemaining(_ state: TimerState) -> String {
let seconds = state.remainingSeconds
let minutes = seconds / 60
let remainingSeconds = seconds % 60
if minutes >= 60 {
let hours = minutes / 60
let remainingMinutes = minutes % 60
return String(format: "%dh %dm", hours, remainingMinutes)
} else if minutes > 0 {
return String(format: "%dm %ds", minutes, remainingSeconds)
} else {
return String(format: "%ds", remainingSeconds)
}
}
}
struct InactiveTimerRow: View {
let type: TimerType
var onTap: () -> Void
@State private var isHovered = false
var body: some View {
Button(action: onTap) {
HStack {
Image(systemName: type.iconName)
.foregroundColor(isHovered ? .white : .secondary)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text(type.displayName)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(isHovered ? .white : .secondary)
}
Spacer()
Image(systemName: "plus.circle")
.font(.title3)
.foregroundColor(isHovered ? .white : .accentColor)
.padding(6)
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.contentShape(RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
.glassEffectIfAvailable(
isHovered ? GlassStyle.regular.tint(.accentColor) : GlassStyle.regular,
in: .rect(cornerRadius: 6)
)
.padding(.horizontal, 8)
.onHover { hovering in
isHovered = hovering
}
.help("Enable \(type.displayName) reminders")
}
}
struct UserTimerStatusRow: View {
let timer: UserTimer
let state: TimerState?
var onTap: () -> Void
@State private var isHovered = false
var body: some View {
Button(action: onTap) {
HStack {
Circle()
.fill(isHovered ? .white : timer.color)
.frame(width: 8, height: 8)
Image(systemName: "clock.fill")
.foregroundColor(isHovered ? .white : timer.color)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text(timer.title)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(isHovered ? .white : .primary)
.lineLimit(1)
if let state = state {
Text(timeRemaining(state))
.font(.caption)
.foregroundColor(isHovered ? .white.opacity(0.8) : .secondary)
.monospacedDigit()
} else {
Text(timer.enabled ? "Not active" : "Disabled")
.font(.caption)
.foregroundColor(isHovered ? .white.opacity(0.8) : .secondary)
}
}
Spacer()
Image(systemName: timer.type == .subtle ? "eye.circle" : "rectangle.on.rectangle")
.font(.caption)
.foregroundColor(isHovered ? .white : .secondary)
.padding(6)
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.contentShape(RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
.glassEffectIfAvailable(
isHovered ? GlassStyle.regular.tint(timer.color) : GlassStyle.regular,
in: .rect(cornerRadius: 6)
)
.padding(.horizontal, 8)
.onHover { hovering in
isHovered = hovering
}
.help(tooltipText)
}
private var tooltipText: String {
let typeText = timer.type == .subtle ? "Subtle" : "Overlay"
let durationText = "\(timer.timeOnScreenSeconds)s on screen"
let statusText = timer.enabled ? "" : " (Disabled)"
return "\(typeText) timer - \(durationText)\(statusText)"
} }
private func timeRemaining(_ state: TimerState) -> String { private func timeRemaining(_ state: TimerState) -> String {

View File

@@ -0,0 +1,150 @@
//
// UserTimerOverlayReminderView.swift
// Gaze
//
// Created by OpenCode on 1/11/26.
//
import AppKit
import SwiftUI
struct UserTimerOverlayReminderView: View {
let timer: UserTimer
var onDismiss: () -> Void
@State private var remainingSeconds: Int
@State private var countdownTimer: Timer?
@State private var keyMonitor: Any?
init(timer: UserTimer, onDismiss: @escaping () -> Void) {
self.timer = timer
self.onDismiss = onDismiss
self._remainingSeconds = State(initialValue: timer.timeOnScreenSeconds)
}
var body: some View {
ZStack {
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
.ignoresSafeArea()
Color.black.opacity(0.5)
.ignoresSafeArea()
VStack(spacing: 40) {
Text(timer.title)
.font(.system(size: 64, weight: .bold))
.foregroundColor(.white)
if let message = timer.message, !message.isEmpty {
Text(message)
.font(.system(size: 28))
.foregroundColor(.white.opacity(0.9))
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
Image(systemName: "clock.fill")
.font(.system(size: 120))
.foregroundColor(timer.color)
.padding(.vertical, 30)
// Countdown display
ZStack {
Circle()
.stroke(Color.white.opacity(0.3), lineWidth: 8)
.frame(width: 120, height: 120)
Circle()
.trim(from: 0, to: progress)
.stroke(timer.color, lineWidth: 8)
.frame(width: 120, height: 120)
.rotationEffect(.degrees(-90))
.animation(.linear(duration: 1), value: progress)
Text("\(remainingSeconds)")
.font(.system(size: 48, weight: .bold))
.foregroundColor(.white)
.monospacedDigit()
}
Text("Press ESC or Space to dismiss")
.font(.subheadline)
.foregroundColor(.white.opacity(0.6))
}
// Dismiss button in corner
VStack {
HStack {
Spacer()
Button(action: dismiss) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 32))
.foregroundColor(.white.opacity(0.7))
}
.buttonStyle(.plain)
.padding(30)
}
Spacer()
}
}
.onAppear {
startCountdown()
setupKeyMonitor()
}
.onDisappear {
countdownTimer?.invalidate()
removeKeyMonitor()
}
}
private var progress: CGFloat {
CGFloat(remainingSeconds) / CGFloat(timer.timeOnScreenSeconds)
}
private func startCountdown() {
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
if remainingSeconds > 0 {
remainingSeconds -= 1
} else {
dismiss()
}
}
}
private func dismiss() {
countdownTimer?.invalidate()
onDismiss()
}
private func setupKeyMonitor() {
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
if event.keyCode == 53 { // ESC key
dismiss()
return nil
} else if event.keyCode == 49 { // Space key
dismiss()
return nil
}
return event
}
}
private func removeKeyMonitor() {
if let monitor = keyMonitor {
NSEvent.removeMonitor(monitor)
keyMonitor = nil
}
}
}
#Preview("User Timer Overlay Reminder") {
UserTimerOverlayReminderView(
timer: UserTimer(
title: "Water Break",
type: .overlay,
timeOnScreenSeconds: 10,
intervalMinutes: 60,
message: "Time to drink some water and stay hydrated!"
),
onDismiss: {}
)
}

View File

@@ -0,0 +1,85 @@
//
// UserTimerReminderView.swift
// Gaze
//
// Created by OpenCode on 1/11/26.
//
import SwiftUI
struct UserTimerReminderView: View {
let timer: UserTimer
let sizePercentage: Double
var onDismiss: () -> Void
@State private var scale: CGFloat = 0
@State private var opacity: Double = 0
private let screenHeight = NSScreen.main?.frame.height ?? 800
private let screenWidth = NSScreen.main?.frame.width ?? 1200
private var baseSize: CGFloat {
screenWidth * (sizePercentage / 100.0)
}
var body: some View {
VStack {
VStack(spacing: 12) {
Image(systemName: "clock.fill")
.font(.system(size: baseSize * 0.4))
.foregroundColor(timer.color)
if let message = timer.message, !message.isEmpty {
Text(message)
.font(.system(size: baseSize * 0.24))
.foregroundColor(.primary)
.multilineTextAlignment(.center)
.lineLimit(2)
}
}
.scaleEffect(scale * 2)
}
.opacity(opacity)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.padding(.top, screenHeight * 0.075)
.onAppear {
startAnimation()
}
}
private func startAnimation() {
// Fade in and grow
withAnimation(.easeOut(duration: 0.4)) {
opacity = 1.0
scale = 1.0
}
// Subtle reminders always display for 3 seconds
let holdDuration = 3.0
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4 + holdDuration) {
withAnimation(.easeIn(duration: 0.4)) {
opacity = 0
scale = 0.8
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
onDismiss()
}
}
}
}
#Preview("User Timer Reminder") {
UserTimerReminderView(
timer: UserTimer(
title: "Stand Up",
type: .subtle,
timeOnScreenSeconds: 5,
intervalMinutes: 30,
message: "Time to stand and stretch!"
),
sizePercentage: 10.0,
onDismiss: {}
)
.frame(width: 800, height: 600)
}

View File

@@ -5,8 +5,8 @@
// Created by Mike Freno on 1/7/26. // Created by Mike Freno on 1/7/26.
// //
import SwiftUI
import AppKit import AppKit
import SwiftUI
struct BlinkSetupView: View { struct BlinkSetupView: View {
@Binding var enabled: Bool @Binding var enabled: Bool
@@ -55,7 +55,8 @@ struct BlinkSetupView: View {
.foregroundColor(.white) .foregroundColor(.white)
} }
.padding() .padding()
.glassEffectIfAvailable(GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8)) .glassEffectIfAvailable(
GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8))
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
Toggle("Enable Blink Reminders", isOn: $enabled) Toggle("Enable Blink Reminders", isOn: $enabled)
@@ -72,7 +73,7 @@ struct BlinkSetupView: View {
value: Binding( value: Binding(
get: { Double(intervalMinutes) }, get: { Double(intervalMinutes) },
set: { intervalMinutes = Int($0) } set: { intervalMinutes = Int($0) }
), in: 1...15, step: 1) ), in: 1...20, step: 1)
Text("\(intervalMinutes) min") Text("\(intervalMinutes) min")
.frame(width: 60, alignment: .trailing) .frame(width: 60, alignment: .trailing)
@@ -114,7 +115,9 @@ struct BlinkSetupView: View {
.contentShape(RoundedRectangle(cornerRadius: 10)) .contentShape(RoundedRectangle(cornerRadius: 10))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.glassEffectIfAvailable(GlassStyle.regular.tint(.accentColor).interactive(), in: .rect(cornerRadius: 10)) .glassEffectIfAvailable(
GlassStyle.regular.tint(.accentColor).interactive(), in: .rect(cornerRadius: 10)
)
} }
Spacer() Spacer()
@@ -140,7 +143,8 @@ struct BlinkSetupView: View {
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
window.acceptsMouseMovedEvents = true window.acceptsMouseMovedEvents = true
let contentView = BlinkReminderView(sizePercentage: subtleReminderSize.percentage) { [weak window] in let contentView = BlinkReminderView(sizePercentage: subtleReminderSize.percentage) {
[weak window] in
window?.close() window?.close()
} }

View File

@@ -182,7 +182,7 @@ struct GeneralSetupView: View {
HStack { HStack {
Image(systemName: "cup.and.saucer.fill") Image(systemName: "cup.and.saucer.fill")
.font(.title3) .font(.title3)
.foregroundColor(.orange) .foregroundColor(.brown)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Buy Me a Coffee") Text("Buy Me a Coffee")
.font(.subheadline) .font(.subheadline)
@@ -197,7 +197,6 @@ struct GeneralSetupView: View {
} }
.padding() .padding()
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(Color.orange.opacity(0.1))
.cornerRadius(10) .cornerRadius(10)
.contentShape(RoundedRectangle(cornerRadius: 10)) .contentShape(RoundedRectangle(cornerRadius: 10))
} }

View File

@@ -57,7 +57,8 @@ struct PostureSetupView: View {
.foregroundColor(.white) .foregroundColor(.white)
} }
.padding() .padding()
.glassEffectIfAvailable(GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8)) .glassEffectIfAvailable(
GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8))
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
Toggle("Enable Posture Reminders", isOn: $enabled) Toggle("Enable Posture Reminders", isOn: $enabled)
@@ -74,7 +75,7 @@ struct PostureSetupView: View {
value: Binding( value: Binding(
get: { Double(intervalMinutes) }, get: { Double(intervalMinutes) },
set: { intervalMinutes = Int($0) } set: { intervalMinutes = Int($0) }
), in: 15...60, step: 5) ), in: 15...90, step: 5)
Text("\(intervalMinutes) min") Text("\(intervalMinutes) min")
.frame(width: 60, alignment: .trailing) .frame(width: 60, alignment: .trailing)
@@ -116,7 +117,9 @@ struct PostureSetupView: View {
.contentShape(RoundedRectangle(cornerRadius: 10)) .contentShape(RoundedRectangle(cornerRadius: 10))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.glassEffectIfAvailable(GlassStyle.regular.tint(.accentColor).interactive(), in: .rect(cornerRadius: 10)) .glassEffectIfAvailable(
GlassStyle.regular.tint(.accentColor).interactive(), in: .rect(cornerRadius: 10)
)
} }
Spacer() Spacer()
@@ -142,7 +145,8 @@ struct PostureSetupView: View {
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
window.acceptsMouseMovedEvents = true window.acceptsMouseMovedEvents = true
let contentView = PostureReminderView(sizePercentage: subtleReminderSize.percentage) { [weak window] in let contentView = PostureReminderView(sizePercentage: subtleReminderSize.percentage) {
[weak window] in
window?.close() window?.close()
} }

View File

@@ -230,8 +230,12 @@ struct UserTimerEditSheet: View {
_title = State( _title = State(
initialValue: timer?.title ?? UserTimer.generateTitle(for: existingTimersCount)) initialValue: timer?.title ?? UserTimer.generateTitle(for: existingTimersCount))
_message = State(initialValue: timer?.message ?? "") _message = State(initialValue: timer?.message ?? "")
_type = State(initialValue: timer?.type ?? .subtle) let timerType = timer?.type ?? .subtle
_timeOnScreen = State(initialValue: timer?.timeOnScreenSeconds ?? 30) _type = State(initialValue: timerType)
// Subtle timers always use 3 seconds (not configurable)
// Overlay timers default to 10 seconds (configurable)
_timeOnScreen = State(
initialValue: timer?.timeOnScreenSeconds ?? (timerType == .subtle ? 3 : 10))
_intervalMinutes = State(initialValue: timer?.intervalMinutes ?? 15) _intervalMinutes = State(initialValue: timer?.intervalMinutes ?? 15)
_selectedColorHex = State( _selectedColorHex = State(
initialValue: timer?.colorHex initialValue: timer?.colorHex
@@ -292,6 +296,15 @@ struct UserTimerEditSheet: View {
} }
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.onChange(of: type) { newType in
// When switching to subtle, set timeOnScreen to 3 (not user-configurable)
if newType == .subtle {
timeOnScreen = 3
} else if timeOnScreen == 3 {
// When switching from subtle to overlay, set to default overlay duration
timeOnScreen = 10
}
}
Text( Text(
type == .subtle type == .subtle

View File

@@ -2,13 +2,38 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0"> <rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel> <channel>
<title>Gaze</title> <title>Gaze</title>
<item>
<title>0.2.3</title>
<pubDate>Sun, 11 Jan 2026 21:48:03 -0500</pubDate>
<sparkle:version>4</sparkle:version>
<sparkle:shortVersionString>0.2.3</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
<enclosure url="https://freno.me/api/downloads/Gaze-0.2.3.dmg" length="4833403" type="application/octet-stream" sparkle:edSignature="AnHFpeYxjG2PZb5sS5riXCxYEmUrtf36rx5vUlbY8O1RyVXW+NjFsRwcZpb3Xmj/ZtdU2znzTFChOqMXtioyBA=="/>
<sparkle:deltas>
<enclosure url="https://freno.me/api/downloads/Gaze4-3.delta" sparkle:deltaFrom="3" length="131402" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="858560" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,uk" sparkle:edSignature="7HyNp+njh3c9WN2Nla8mskAcv9QOvDqgTcy8guvsaobQ6f1JiRTNgNkPkhUkw2X0dw2epK9jcR/lqc5vshUXBg=="/>
<enclosure url="https://freno.me/api/downloads/Gaze4-2.delta" sparkle:deltaFrom="2" length="131066" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="858560" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,uk" sparkle:edSignature="+5X2MFA/3Dq6IyeHPb8ze6sMUfzfhxzKI0Ph7OtZdLJhwNqMrl9Maoc3+qSwcKNqXveAEzM+pUPbeyqm6EnyAA=="/>
<enclosure url="https://freno.me/api/downloads/Gaze4-1.delta" sparkle:deltaFrom="1" length="149846" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="858560" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,uk" sparkle:edSignature="hrUN7WhmJAgAO+J7Wr9Y+XUIAVZOmSsyxWfMoXi/GCSLoJVX7Yl5b2uU//n5o0XCH3KIGUv+C3zBG40ecFFACw=="/>
</sparkle:deltas>
</item>
<item>
<title>0.2.2</title>
<pubDate>Sun, 11 Jan 2026 20:28:48 -0500</pubDate>
<sparkle:version>3</sparkle:version>
<sparkle:shortVersionString>0.2.2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
<enclosure url="https://freno.me/api/downloads/Gaze-0.2.2.dmg" length="4832775" type="application/octet-stream" sparkle:edSignature="yRtjr01QYU5lwrRZKGccC5rEgjnM3qD4sv1MEtVVOkNW4sVQx9cZAAY9ZyOHxHi/ylE3ehtyZ59PfrOWhxKnCg=="/>
<sparkle:deltas>
<enclosure url="https://freno.me/api/downloads/Gaze3-2.delta" sparkle:deltaFrom="2" length="13130" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="858560" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,uk" sparkle:edSignature="B8CTj4hEE3beVCc4waxVgXpWAPkM85PH7Bt6Xqh/qOPaddzP3X/q7wWoGi0LhPA0rKXDL9/jyWG+A/s/FQQ9Cw=="/>
<enclosure url="https://freno.me/api/downloads/Gaze3-1.delta" sparkle:deltaFrom="1" length="94630" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="858560" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,uk" sparkle:edSignature="6WNC5rnEiGVQQgey8QFp07YmdUCuAQCktDIzpkCO7VCyl63iVOlHbmg2jDEWQ18sO5hPuvNHQhh47+exgFTQCQ=="/>
</sparkle:deltas>
</item>
<item> <item>
<title>0.2.1</title> <title>0.2.1</title>
<pubDate>Sun, 11 Jan 2026 19:58:11 -0500</pubDate> <pubDate>Sun, 11 Jan 2026 19:58:11 -0500</pubDate>
<sparkle:version>2</sparkle:version> <sparkle:version>2</sparkle:version>
<sparkle:shortVersionString>0.2.1</sparkle:shortVersionString> <sparkle:shortVersionString>0.2.1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion> <sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
<enclosure url="https://freno.me/api/downloads/Gaze-0.2.1.dmg" length="4832775" type="application/octet-stream" sparkle:edSignature="rIMuezCOzKhKKZV+HgJV7LNoXFkjVse82toPgSYNfp2YWO8EITVaCBr4ZP2wqXZlRfdE4J6r9BDewbkKxkf7AQ=="/> <enclosure url="https://freno.me/api/downloads/Gaze-0.2.1.dmg" length="4832782" type="application/octet-stream" sparkle:edSignature="LinJnMeOBhFDMItGzrJVeNRPuHlDHN3Ld3yld3+viggBNF+UtWd6E+lVjh7lpCPj04MNt7iyyRsjupyoZ7gFBg=="/>
<sparkle:deltas> <sparkle:deltas>
<enclosure url="https://freno.me/api/downloads/Gaze2-1.delta" sparkle:deltaFrom="1" length="94254" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="858560" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,uk" sparkle:edSignature="qfxSfqD9iVJ7GVL19V8T4OuOTz0ZgqJNceBH6W+dwoKel1R+BTPkU9Ia8xR12v07GoXkyyqc+ba79OOL7jIpBw=="/> <enclosure url="https://freno.me/api/downloads/Gaze2-1.delta" sparkle:deltaFrom="1" length="94254" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="858560" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,uk" sparkle:edSignature="qfxSfqD9iVJ7GVL19V8T4OuOTz0ZgqJNceBH6W+dwoKel1R+BTPkU9Ia8xR12v07GoXkyyqc+ba79OOL7jIpBw=="/>
</sparkle:deltas> </sparkle:deltas>