diff --git a/Gaze/AppDelegate.swift b/Gaze/AppDelegate.swift index 571feb3..68cb8d1 100644 --- a/Gaze/AppDelegate.swift +++ b/Gaze/AppDelegate.swift @@ -169,6 +169,21 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { 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) diff --git a/Gaze/Models/ReminderEvent.swift b/Gaze/Models/ReminderEvent.swift index 55bbfc5..88c4de9 100644 --- a/Gaze/Models/ReminderEvent.swift +++ b/Gaze/Models/ReminderEvent.swift @@ -11,15 +11,32 @@ enum ReminderEvent: Equatable { case lookAwayTriggered(countdownSeconds: Int) case blinkTriggered case postureTriggered + case userTimerTriggered(UserTimer) - var type: TimerType { + var iconName: String { switch self { case .lookAwayTriggered: - return .lookAway + return "eye.fill" case .blinkTriggered: - return .blink + return "eye.slash.fill" 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 } } } + diff --git a/Gaze/Models/UserTimer.swift b/Gaze/Models/UserTimer.swift index 4e1a3c7..084c8c6 100644 --- a/Gaze/Models/UserTimer.swift +++ b/Gaze/Models/UserTimer.swift @@ -23,7 +23,7 @@ struct UserTimer: Codable, Equatable, Identifiable, Hashable { id: String = UUID().uuidString, title: String? = nil, type: UserTimerType = .subtle, - timeOnScreenSeconds: Int = 30, + timeOnScreenSeconds: Int? = nil, intervalMinutes: Int = 15, message: String? = nil, colorHex: String? = nil, @@ -32,7 +32,8 @@ struct UserTimer: Codable, Equatable, Identifiable, Hashable { self.id = id self.title = title ?? "User Reminder" 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.message = message self.colorHex = colorHex ?? UserTimer.defaultColors[0] diff --git a/Gaze/Services/TimerEngine.swift b/Gaze/Services/TimerEngine.swift index 5b3cea4..ac3f5a0 100644 --- a/Gaze/Services/TimerEngine.swift +++ b/Gaze/Services/TimerEngine.swift @@ -15,6 +15,11 @@ class TimerEngine: ObservableObject { // Track user timer states separately private var userTimerStates: [String: TimerState] = [:] + + // Expose user timer states for read-only access + var userTimerStatesReadOnly: [String: TimerState] { + return userTimerStates + } private var timerSubscription: AnyCancellable? private let settingsManager: SettingsManager @@ -119,11 +124,12 @@ class TimerEngine: ObservableObject { for userTimer in settingsManager.settings.userTimers { if let existingState = userTimerStates[userTimer.id] { // Check if interval changed - if existingState.originalIntervalSeconds != userTimer.timeOnScreenSeconds { + let newIntervalSeconds = userTimer.intervalMinutes * 60 + if existingState.originalIntervalSeconds != newIntervalSeconds { // Interval changed - reset with new interval userTimerStates[userTimer.id] = TimerState( type: .lookAway, // Placeholder - intervalSeconds: userTimer.timeOnScreenSeconds, + intervalSeconds: newIntervalSeconds, isPaused: existingState.isPaused, isActive: userTimer.enabled ) @@ -158,6 +164,14 @@ class TimerEngine: ObservableObject { timerStates[type]?.isPaused = false } } + + func pauseTimer(type: TimerType) { + timerStates[type]?.isPaused = true + } + + func resumeTimer(type: TimerType) { + timerStates[type]?.isPaused = false + } func skipNext(type: TimerType) { guard let state = timerStates[type] else { return } @@ -174,10 +188,28 @@ class TimerEngine: ObservableObject { guard let reminder = activeReminder else { return } activeReminder = nil - skipNext(type: reminder.type) - - if case .lookAwayTriggered = reminder { - resume() + // 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 { + skipNext(type: .lookAway) + 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) { - // 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 }) { - userTimerStates[id] = TimerState( - type: .lookAway, // Placeholder - user timers won't use this - intervalSeconds: userTimer.timeOnScreenSeconds, - isPaused: false, - isActive: true - ) + activeReminder = .userTimerTriggered(userTimer) } } @@ -257,7 +282,7 @@ class TimerEngine: ObservableObject { func startUserTimer(_ userTimer: UserTimer) { userTimerStates[userTimer.id] = TimerState( type: .lookAway, // Placeholder - we'll need to make this more flexible - intervalSeconds: userTimer.timeOnScreenSeconds, + intervalSeconds: userTimer.intervalMinutes * 60, isPaused: false, isActive: true ) @@ -280,6 +305,16 @@ class TimerEngine: ObservableObject { userTimerStates[userTimerId] = state } } + + func toggleUserTimerPause(_ userTimerId: String) { + if let state = userTimerStates[userTimerId] { + if state.isPaused { + resumeUserTimer(userTimerId) + } else { + pauseUserTimer(userTimerId) + } + } + } func getTimeRemaining(for type: TimerType) -> TimeInterval { guard let state = timerStates[type] else { return 0 } @@ -305,6 +340,10 @@ class TimerEngine: ObservableObject { } } + func isUserTimerPaused(_ userTimerId: String) -> Bool { + return userTimerStates[userTimerId]?.isPaused ?? true + } + func getUserFormattedTimeRemaining(for userId: String) -> String { let seconds = Int(getUserTimeRemaining(for: userId)) let minutes = seconds / 60 diff --git a/Gaze/Views/MenuBar/MenuBarContentView.swift b/Gaze/Views/MenuBar/MenuBarContentView.swift index eb67b7c..ff76c2b 100644 --- a/Gaze/Views/MenuBar/MenuBarContentView.swift +++ b/Gaze/Views/MenuBar/MenuBarContentView.swift @@ -187,10 +187,11 @@ struct MenuBarContentView: View { .padding(.horizontal) .padding(.top, 8) - ForEach(TimerType.allCases) { timerType in - if timerEngine.timerStates[timerType] != nil { - TimerStatusRow( - type: timerType, + // Show regular timers with individual pause/resume controls + ForEach(Array(timerEngine.timerStates.keys), id: \.self) { timerType in + if let state = timerEngine.timerStates[timerType] { + TimerStatusRowWithIndividualControls( + variant: .builtIn(timerType), timerEngine: timerEngine, onSkip: { timerEngine.skipNext(type: timerType) @@ -198,13 +199,13 @@ struct MenuBarContentView: View { onDevTrigger: { timerEngine.triggerReminder(for: timerType) }, - onTap: { - onOpenSettingsTab(timerType.tabIndex) - } - ) - } else { - InactiveTimerRow( - type: timerType, + onTogglePause: { isPaused in + if isPaused { + timerEngine.pauseTimer(type: timerType) + } else { + timerEngine.resumeTimer(type: timerType) + } + }, onTap: { 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) { userTimer in - UserTimerStatusRow( - timer: userTimer, - state: nil, // We'll implement proper state tracking later + TimerStatusRowWithIndividualControls( + variant: .user(userTimer), + timerEngine: timerEngine, + onSkip: { + //TODO + }, + onTogglePause: { isPaused in + if isPaused { + timerEngine.pauseUserTimer(userTimer.id) + } else { + timerEngine.resumeUserTimer(userTimer.id) + } + }, onTap: { onOpenSettingsTab(3) // Switch to User Timers tab } @@ -231,7 +242,7 @@ struct MenuBarContentView: View { // Controls VStack(spacing: 4) { Button(action: { - if isPaused(timerEngine: timerEngine) { + if isAllPaused(timerEngine: timerEngine) { timerEngine.resume() } else { timerEngine.pause() @@ -239,10 +250,10 @@ struct MenuBarContentView: View { }) { HStack { Image( - systemName: isPaused(timerEngine: timerEngine) + systemName: isAllPaused(timerEngine: timerEngine) ? "play.circle" : "pause.circle") Text( - isPaused(timerEngine: timerEngine) + isAllPaused(timerEngine: timerEngine) ? "Resume All Timers" : "Pause All Timers") Spacer() } @@ -292,37 +303,103 @@ struct MenuBarContentView: View { } } - private func isPaused(timerEngine: TimerEngine) -> Bool { - timerEngine.timerStates.values.first?.isPaused ?? false + private func isAllPaused(timerEngine: TimerEngine) -> Bool { + // Check if all timers are paused + let activeStates = timerEngine.timerStates.values.filter { $0.isActive } + return !activeStates.isEmpty && activeStates.allSatisfy { $0.isPaused } } } -struct TimerStatusRow: View { - let type: TimerType +struct TimerStatusRowWithIndividualControls: View { + 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 var onSkip: () -> Void var onDevTrigger: (() -> Void)? = nil + var onTogglePause: (Bool) -> Void var onTap: (() -> Void)? = nil @State private var isHoveredSkip = false @State private var isHoveredDevTrigger = false @State private var isHoveredBody = false + @State private var isHoveredPauseButton = false 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 { HStack { HStack { - Image(systemName: type.iconName) - .foregroundColor(isHoveredBody ? .white : iconColor) + // Show color indicator circle for user timers + 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) VStack(alignment: .leading, spacing: 2) { - Text(type.displayName) + Text(variant.displayName) .font(.subheadline) .fontWeight(.medium) .foregroundColor(isHoveredBody ? .white : .primary) + .lineLimit(1) + if let state = state { Text(timeRemaining(state)) .font(.caption) @@ -353,13 +430,39 @@ struct TimerStatusRow: View { ? GlassStyle.regular.tint(.yellow) : GlassStyle.regular, in: .circle ) - .help("Trigger \(type.displayName) reminder now (dev)") + .help("Trigger \(variant.displayName) reminder now (dev)") .onHover { hovering in isHoveredDevTrigger = hovering } } #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) { Image(systemName: "forward.fill") .font(.caption) @@ -373,7 +476,7 @@ struct TimerStatusRow: View { ? GlassStyle.regular.tint(.accentColor) : GlassStyle.regular, in: .circle ) - .help("Skip to next \(type.displayName) reminder") + .help("Skip to next \(variant.displayName) reminder") .onHover { hovering in isHoveredSkip = hovering } @@ -381,152 +484,16 @@ struct TimerStatusRow: View { .padding(.horizontal, 8) .padding(.vertical, 6) .glassEffectIfAvailable( - isHoveredBody ? GlassStyle.regular.tint(.accentColor) : GlassStyle.regular, + isHoveredBody + ? GlassStyle.regular.tint(variant.color) + : GlassStyle.regular, in: .rect(cornerRadius: 6) ) .padding(.horizontal, 8) .onHover { hovering in isHoveredBody = hovering } - .help(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)" + .help(variant.tooltipText) } private func timeRemaining(_ state: TimerState) -> String { diff --git a/Gaze/Views/Reminders/UserTimerOverlayReminderView.swift b/Gaze/Views/Reminders/UserTimerOverlayReminderView.swift new file mode 100644 index 0000000..cea88af --- /dev/null +++ b/Gaze/Views/Reminders/UserTimerOverlayReminderView.swift @@ -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: {} + ) +} diff --git a/Gaze/Views/Reminders/UserTimerReminderView.swift b/Gaze/Views/Reminders/UserTimerReminderView.swift new file mode 100644 index 0000000..21ebcb2 --- /dev/null +++ b/Gaze/Views/Reminders/UserTimerReminderView.swift @@ -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) +} diff --git a/Gaze/Views/Setup/BlinkSetupView.swift b/Gaze/Views/Setup/BlinkSetupView.swift index b2f3594..3c42732 100644 --- a/Gaze/Views/Setup/BlinkSetupView.swift +++ b/Gaze/Views/Setup/BlinkSetupView.swift @@ -5,8 +5,8 @@ // Created by Mike Freno on 1/7/26. // -import SwiftUI import AppKit +import SwiftUI struct BlinkSetupView: View { @Binding var enabled: Bool @@ -55,7 +55,8 @@ struct BlinkSetupView: View { .foregroundColor(.white) } .padding() - .glassEffectIfAvailable(GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8)) + .glassEffectIfAvailable( + GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8)) VStack(alignment: .leading, spacing: 20) { Toggle("Enable Blink Reminders", isOn: $enabled) @@ -72,7 +73,7 @@ struct BlinkSetupView: View { value: Binding( get: { Double(intervalMinutes) }, set: { intervalMinutes = Int($0) } - ), in: 1...15, step: 1) + ), in: 1...20, step: 1) Text("\(intervalMinutes) min") .frame(width: 60, alignment: .trailing) @@ -97,7 +98,7 @@ struct BlinkSetupView: View { .font(.caption) .foregroundColor(.secondary) } - + // Preview button Button(action: { showPreviewWindow() @@ -114,7 +115,9 @@ struct BlinkSetupView: View { .contentShape(RoundedRectangle(cornerRadius: 10)) } .buttonStyle(.plain) - .glassEffectIfAvailable(GlassStyle.regular.tint(.accentColor).interactive(), in: .rect(cornerRadius: 10)) + .glassEffectIfAvailable( + GlassStyle.regular.tint(.accentColor).interactive(), in: .rect(cornerRadius: 10) + ) } Spacer() @@ -123,34 +126,35 @@ struct BlinkSetupView: View { .padding() .background(.clear) } - + private func showPreviewWindow() { guard let screen = NSScreen.main else { return } - + let window = NSWindow( contentRect: screen.frame, styleMask: [.borderless, .fullSizeContentView], backing: .buffered, defer: false ) - + window.level = .floating window.isOpaque = false window.backgroundColor = .clear window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] window.acceptsMouseMovedEvents = true - - let contentView = BlinkReminderView(sizePercentage: subtleReminderSize.percentage) { [weak window] in + + let contentView = BlinkReminderView(sizePercentage: subtleReminderSize.percentage) { + [weak window] in window?.close() } - + window.contentView = NSHostingView(rootView: contentView) window.makeFirstResponder(window.contentView) - + let windowController = NSWindowController(window: window) windowController.showWindow(nil) window.makeKeyAndOrderFront(nil) - + previewWindowController = windowController } } diff --git a/Gaze/Views/Setup/GeneralSetupView.swift b/Gaze/Views/Setup/GeneralSetupView.swift index ecee9f6..af1b9b5 100644 --- a/Gaze/Views/Setup/GeneralSetupView.swift +++ b/Gaze/Views/Setup/GeneralSetupView.swift @@ -182,7 +182,7 @@ struct GeneralSetupView: View { HStack { Image(systemName: "cup.and.saucer.fill") .font(.title3) - .foregroundColor(.orange) + .foregroundColor(.brown) VStack(alignment: .leading, spacing: 2) { Text("Buy Me a Coffee") .font(.subheadline) @@ -197,7 +197,6 @@ struct GeneralSetupView: View { } .padding() .frame(maxWidth: .infinity) - .background(Color.orange.opacity(0.1)) .cornerRadius(10) .contentShape(RoundedRectangle(cornerRadius: 10)) } diff --git a/Gaze/Views/Setup/PostureSetupView.swift b/Gaze/Views/Setup/PostureSetupView.swift index 80616c6..2fbbbf6 100644 --- a/Gaze/Views/Setup/PostureSetupView.swift +++ b/Gaze/Views/Setup/PostureSetupView.swift @@ -57,7 +57,8 @@ struct PostureSetupView: View { .foregroundColor(.white) } .padding() - .glassEffectIfAvailable(GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8)) + .glassEffectIfAvailable( + GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8)) VStack(alignment: .leading, spacing: 20) { Toggle("Enable Posture Reminders", isOn: $enabled) @@ -74,7 +75,7 @@ struct PostureSetupView: View { value: Binding( get: { Double(intervalMinutes) }, set: { intervalMinutes = Int($0) } - ), in: 15...60, step: 5) + ), in: 15...90, step: 5) Text("\(intervalMinutes) min") .frame(width: 60, alignment: .trailing) @@ -116,7 +117,9 @@ struct PostureSetupView: View { .contentShape(RoundedRectangle(cornerRadius: 10)) } .buttonStyle(.plain) - .glassEffectIfAvailable(GlassStyle.regular.tint(.accentColor).interactive(), in: .rect(cornerRadius: 10)) + .glassEffectIfAvailable( + GlassStyle.regular.tint(.accentColor).interactive(), in: .rect(cornerRadius: 10) + ) } Spacer() @@ -142,7 +145,8 @@ struct PostureSetupView: View { window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] window.acceptsMouseMovedEvents = true - let contentView = PostureReminderView(sizePercentage: subtleReminderSize.percentage) { [weak window] in + let contentView = PostureReminderView(sizePercentage: subtleReminderSize.percentage) { + [weak window] in window?.close() } diff --git a/Gaze/Views/Setup/UserTimersView.swift b/Gaze/Views/Setup/UserTimersView.swift index acf313d..b5d2a6e 100644 --- a/Gaze/Views/Setup/UserTimersView.swift +++ b/Gaze/Views/Setup/UserTimersView.swift @@ -230,8 +230,12 @@ struct UserTimerEditSheet: View { _title = State( initialValue: timer?.title ?? UserTimer.generateTitle(for: existingTimersCount)) _message = State(initialValue: timer?.message ?? "") - _type = State(initialValue: timer?.type ?? .subtle) - _timeOnScreen = State(initialValue: timer?.timeOnScreenSeconds ?? 30) + let timerType = timer?.type ?? .subtle + _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) _selectedColorHex = State( initialValue: timer?.colorHex @@ -292,6 +296,15 @@ struct UserTimerEditSheet: View { } } .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( type == .subtle diff --git a/releases/appcast.xml b/releases/appcast.xml index e16f1e8..c2bdfdd 100644 --- a/releases/appcast.xml +++ b/releases/appcast.xml @@ -2,13 +2,38 @@ Gaze + + 0.2.3 + Sun, 11 Jan 2026 21:48:03 -0500 + 4 + 0.2.3 + 13.0 + + + + + + + + + 0.2.2 + Sun, 11 Jan 2026 20:28:48 -0500 + 3 + 0.2.2 + 14.6 + + + + + + 0.2.1 Sun, 11 Jan 2026 19:58:11 -0500 2 0.2.1 14.6 - +