feat: user timer, ui improvements

This commit is contained in:
Michael Freno
2026-01-09 18:36:11 -05:00
parent 63dde81b97
commit 368f0c88cc
13 changed files with 556 additions and 32 deletions

View File

@@ -200,7 +200,7 @@ private func showReminderWindow(_ content: AnyView) {
}
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 600, height: 550),
contentRect: NSRect(x: 0, y: 0, width: 700, height: 700),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false

View File

@@ -31,6 +31,8 @@ struct GazeApp: App {
}
}
.windowStyle(.hiddenTitleBar)
.windowResizability(.contentSize)
.defaultSize(width: 700, height: 700)
.commands {
CommandGroup(replacing: .newItem) { }
}

View File

@@ -7,7 +7,7 @@
import Foundation
struct TimerState {
struct TimerState: Equatable {
let type: TimerType
var remainingSeconds: Int
var isPaused: Bool
@@ -21,4 +21,11 @@ struct TimerState {
self.isActive = isActive
self.targetDate = Date().addingTimeInterval(Double(intervalSeconds))
}
static func == (lhs: TimerState, rhs: TimerState) -> Bool {
lhs.type == rhs.type && lhs.remainingSeconds == rhs.remainingSeconds
&& lhs.isPaused == rhs.isPaused && lhs.isActive == rhs.isActive
&& lhs.targetDate.timeIntervalSince1970.rounded()
== rhs.targetDate.timeIntervalSince1970.rounded()
}
}

View File

@@ -8,7 +8,7 @@
import Foundation
/// Represents a user-defined timer with customizable properties
struct UserTimer: Codable, Equatable {
struct UserTimer: Codable, Equatable, Identifiable {
let id: String
var type: UserTimerType
var timeOnScreenSeconds: Int

View File

@@ -13,6 +13,9 @@ class TimerEngine: ObservableObject {
@Published var timerStates: [TimerType: TimerState] = [:]
@Published var activeReminder: ReminderEvent?
// Track user timer states separately
private var userTimerStates: [String: TimerState] = [:]
private var timerSubscription: AnyCancellable?
private let settingsManager: SettingsManager
@@ -35,6 +38,11 @@ class TimerEngine: ObservableObject {
}
}
// Start user timers
for userTimer in settingsManager.settings.userTimers {
startUserTimer(userTimer)
}
timerSubscription = Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
@@ -48,6 +56,7 @@ class TimerEngine: ObservableObject {
timerSubscription?.cancel()
timerSubscription = nil
timerStates.removeAll()
userTimerStates.removeAll()
}
func pause() {
@@ -87,6 +96,7 @@ class TimerEngine: ObservableObject {
private func handleTick() {
guard activeReminder == nil else { return }
// Handle regular timers first
for (type, state) in timerStates {
guard state.isActive && !state.isPaused else { continue }
// prevent overshoot - in case user closes laptop while timer is running, we don't want to
@@ -110,6 +120,36 @@ class TimerEngine: ObservableObject {
break
}
}
// Handle user timers
handleUserTimerTicks()
}
private func handleUserTimerTicks() {
for (id, state) in userTimerStates {
if !state.isActive || state.isPaused { continue }
// Update user timer countdown
userTimerStates[id]?.remainingSeconds -= 1
if let updatedState = userTimerStates[id], updatedState.remainingSeconds <= 0 {
// Trigger the user timer reminder
triggerUserTimerReminder(forId: id)
}
}
}
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
)
}
}
func triggerReminder(for type: TimerType) {
@@ -125,11 +165,44 @@ class TimerEngine: ObservableObject {
}
}
// User timer management methods
func startUserTimer(_ userTimer: UserTimer) {
userTimerStates[userTimer.id] = TimerState(
type: .lookAway, // Placeholder - we'll need to make this more flexible
intervalSeconds: userTimer.timeOnScreenSeconds,
isPaused: false,
isActive: true
)
}
func stopUserTimer(_ userTimerId: String) {
userTimerStates[userTimerId] = nil
}
func pauseUserTimer(_ userTimerId: String) {
if var state = userTimerStates[userTimerId] {
state.isPaused = true
userTimerStates[userTimerId] = state
}
}
func resumeUserTimer(_ userTimerId: String) {
if var state = userTimerStates[userTimerId] {
state.isPaused = false
userTimerStates[userTimerId] = state
}
}
func getTimeRemaining(for type: TimerType) -> TimeInterval {
guard let state = timerStates[type] else { return 0 }
return TimeInterval(state.remainingSeconds)
}
func getUserTimeRemaining(for userId: String) -> TimeInterval {
guard let state = userTimerStates[userId] else { return 0 }
return TimeInterval(state.remainingSeconds)
}
func getFormattedTimeRemaining(for type: TimerType) -> String {
let seconds = Int(getTimeRemaining(for: type))
let minutes = seconds / 60
@@ -143,4 +216,18 @@ class TimerEngine: ObservableObject {
return String(format: "%d:%02d", minutes, remainingSeconds)
}
}
func getUserFormattedTimeRemaining(for userId: String) -> String {
let seconds = Int(getUserTimeRemaining(for: userId))
let minutes = seconds / 60
let remainingSeconds = seconds % 60
if minutes >= 60 {
let hours = minutes / 60
let remainingMinutes = minutes % 60
return String(format: "%d:%02d:%02d", hours, remainingMinutes, remainingSeconds)
} else {
return String(format: "%d:%02d", minutes, remainingSeconds)
}
}
}

View File

@@ -97,6 +97,17 @@ struct MenuBarContentView: View {
)
}
}
// Show user timers if any exist
ForEach(settingsManager.settings.userTimers, id: \.id) { userTimer in
UserTimerStatusRow(
timer: userTimer,
state: nil, // We'll implement proper state tracking later
onTap: {
onOpenSettingsTab(3) // Switch to User Timers tab
}
)
}
}
.padding(.bottom, 8)
@@ -316,6 +327,82 @@ struct InactiveTimerRow: View {
}
}
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 {
Image(systemName: "clock.fill")
.foregroundColor(.purple)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text(timer.message ?? "Custom Timer")
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(1)
if let state = state {
Text(timeRemaining(state))
.font(.caption)
.foregroundColor(.secondary)
.monospacedDigit()
} else {
Text("Not active")
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
Image(systemName: timer.type == .subtle ? "eye.circle" : "rectangle.on.rectangle")
.font(.caption)
.foregroundColor(.secondary)
.padding(6)
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
}
.buttonStyle(.plain)
.glassEffect(
isHovered ? .regular.tint(.purple.opacity(0.5)) : .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"
return "\(typeText) timer - \(durationText)"
}
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)
}
}
}
#Preview("Menu Bar Content") {
let settingsManager = SettingsManager.shared
let timerEngine = TimerEngine(settingsManager: settingsManager)

View File

@@ -92,7 +92,7 @@ struct BlinkSetupView: View {
Spacer()
}
.frame(width: 600, height: 450)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.background(.clear)
}

View File

@@ -110,7 +110,7 @@ struct LookAwaySetupView: View {
Spacer()
}
.frame(width: 600, height: 450)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.background(.clear)
}

View File

@@ -30,6 +30,7 @@ struct OnboardingContainerView: View {
@State private var postureEnabled = true
@State private var postureIntervalMinutes = 30
@State private var launchAtLogin = false
@State private var subtleReminderSizePercentage = 5.0
@State private var isAnimatingOut = false
@Environment(\.dismiss) private var dismiss
@@ -75,6 +76,7 @@ struct OnboardingContainerView: View {
SettingsOnboardingView(
launchAtLogin: $launchAtLogin,
subtleReminderSizePercentage: $subtleReminderSizePercentage,
isOnboarding: true
)
.tag(4)
@@ -136,6 +138,7 @@ struct OnboardingContainerView: View {
}
}
}
.frame(minWidth: 1000, minHeight: 750)
.opacity(isAnimatingOut ? 0 : 1)
.scaleEffect(isAnimatingOut ? 0.3 : 1.0)
}
@@ -159,6 +162,7 @@ struct OnboardingContainerView: View {
)
settingsManager.settings.launchAtLogin = launchAtLogin
settingsManager.settings.subtleReminderSizePercentage = subtleReminderSizePercentage
settingsManager.settings.hasCompletedOnboarding = true
// Apply launch at login setting

View File

@@ -92,7 +92,7 @@ struct PostureSetupView: View {
Spacer()
}
.frame(width: 600, height: 450)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.background(.clear)
}

View File

@@ -9,6 +9,7 @@ import SwiftUI
struct SettingsOnboardingView: View {
@Binding var launchAtLogin: Bool
@Binding var subtleReminderSizePercentage: Double
var isOnboarding: Bool = true
var body: some View {
@@ -48,6 +49,29 @@ struct SettingsOnboardingView: View {
.padding()
.glassEffect(.regular, in: .rect(cornerRadius: 12))
// Subtle Reminder Size Configuration
VStack(alignment: .leading, spacing: 12) {
Text("Subtle Reminder Size")
.font(.headline)
Text("Adjust the size of blink and posture reminders")
.font(.caption)
.foregroundColor(.secondary)
HStack {
Slider(
value: $subtleReminderSizePercentage,
in: 2...35,
step: 1
)
Text("\(Int(subtleReminderSizePercentage))%")
.frame(width: 50, alignment: .trailing)
.monospacedDigit()
}
}
.padding()
.glassEffect(.regular, in: .rect(cornerRadius: 12))
// Links Section
VStack(spacing: 12) {
Text("Support & Contribute")
@@ -116,7 +140,7 @@ struct SettingsOnboardingView: View {
Spacer()
}
.frame(width: 600, height: 450)
.frame(minWidth: 650, minHeight: 650)
.padding()
.background(.clear)
}
@@ -137,6 +161,7 @@ struct SettingsOnboardingView: View {
#Preview("Settings Onboarding - Launch Disabled") {
SettingsOnboardingView(
launchAtLogin: .constant(false),
subtleReminderSizePercentage: .constant(5.0),
isOnboarding: true
)
}
@@ -144,6 +169,7 @@ struct SettingsOnboardingView: View {
#Preview("Settings Onboarding - Launch Enabled") {
SettingsOnboardingView(
launchAtLogin: .constant(true),
subtleReminderSizePercentage: .constant(10.0),
isOnboarding: true
)
}

View File

@@ -0,0 +1,295 @@
//
// UserTimersView.swift
// Gaze
//
// Created by Mike Freno on 1/9/26.
//
import SwiftUI
struct UserTimersView: View {
@Binding var userTimers: [UserTimer]
@State private var editingTimer: UserTimer?
@State private var showingAddTimer = false
var body: some View {
VStack(spacing: 30) {
Image(systemName: "clock.badge.checkmark")
.font(.system(size: 60))
.foregroundColor(.purple)
Text("Custom Timers")
.font(.system(size: 28, weight: .bold))
Text("Create your own reminder schedules")
.font(.title3)
.foregroundColor(.secondary)
HStack(spacing: 12) {
Image(systemName: "info.circle")
.foregroundColor(.white)
Text("Add up to 3 custom timers with your own intervals and messages")
.font(.headline)
.foregroundColor(.white)
}
.padding()
.glassEffect(.regular.tint(.purple), in: .rect(cornerRadius: 8))
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Active Timers (\(userTimers.count)/3)")
.font(.headline)
Spacer()
if userTimers.count < 3 {
Button(action: {
showingAddTimer = true
}) {
Label("Add Timer", systemImage: "plus.circle.fill")
}
.buttonStyle(.borderedProminent)
}
}
if userTimers.isEmpty {
VStack(spacing: 12) {
Image(systemName: "clock.badge.questionmark")
.font(.system(size: 40))
.foregroundColor(.secondary)
Text("No custom timers yet")
.font(.subheadline)
.foregroundColor(.secondary)
Text("Click 'Add Timer' to create your first custom reminder")
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(40)
} else {
ScrollView {
VStack(spacing: 8) {
ForEach(userTimers) { timer in
UserTimerRow(
timer: timer,
onEdit: {
editingTimer = timer
},
onDelete: {
if let index = userTimers.firstIndex(where: {
$0.id == timer.id
}) {
userTimers.remove(at: index)
}
}
)
}
}
}
.frame(maxHeight: 200)
}
}
.padding()
.glassEffect(.regular, in: .rect(cornerRadius: 12))
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.background(.clear)
.sheet(isPresented: $showingAddTimer) {
UserTimerEditSheet(
timer: nil,
onSave: { newTimer in
userTimers.append(newTimer)
showingAddTimer = false
},
onCancel: {
showingAddTimer = false
}
)
}
.sheet(item: $editingTimer) { timer in
UserTimerEditSheet(
timer: timer,
onSave: { updatedTimer in
if let index = userTimers.firstIndex(where: { $0.id == timer.id }) {
userTimers[index] = updatedTimer
}
editingTimer = nil
},
onCancel: {
editingTimer = nil
}
)
}
}
}
struct UserTimerRow: View {
let timer: UserTimer
var onEdit: () -> Void
var onDelete: () -> Void
@State private var isHovered = false
var body: some View {
HStack(spacing: 12) {
Image(systemName: timer.type == .subtle ? "eye.circle" : "rectangle.on.rectangle")
.foregroundColor(.purple)
.frame(width: 24)
VStack(alignment: .leading, spacing: 4) {
Text(timer.message ?? "Custom Timer")
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(1)
Text("\(timer.type.displayName)\(timer.timeOnScreenSeconds)s on screen")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
HStack(spacing: 4) {
Button(action: onEdit) {
Image(systemName: "pencil.circle")
.font(.title3)
.foregroundColor(.accentColor)
}
.buttonStyle(.plain)
Button(action: onDelete) {
Image(systemName: "trash.circle")
.font(.title3)
.foregroundColor(.red)
}
.buttonStyle(.plain)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.secondary.opacity(isHovered ? 0.1 : 0.05))
)
.onHover { hovering in
isHovered = hovering
}
}
}
struct UserTimerEditSheet: View {
let timer: UserTimer?
var onSave: (UserTimer) -> Void
var onCancel: () -> Void
@State private var message: String
@State private var type: UserTimerType
@State private var timeOnScreen: Int
init(
timer: UserTimer?,
onSave: @escaping (UserTimer) -> Void,
onCancel: @escaping () -> Void
) {
self.timer = timer
self.onSave = onSave
self.onCancel = onCancel
_message = State(initialValue: timer?.message ?? "")
_type = State(initialValue: timer?.type ?? .subtle)
_timeOnScreen = State(initialValue: timer?.timeOnScreenSeconds ?? 30)
}
var body: some View {
VStack(spacing: 24) {
Text(timer == nil ? "Add Custom Timer" : "Edit Custom Timer")
.font(.title2)
.fontWeight(.bold)
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("Display Type")
.font(.headline)
Picker("Display Type", selection: $type) {
ForEach(UserTimerType.allCases) { timerType in
Text(timerType.displayName).tag(timerType)
}
}
.pickerStyle(.segmented)
Text(
type == .subtle
? "Small reminder in corner of screen"
: "Full screen reminder with animation"
)
.font(.caption)
.foregroundColor(.secondary)
}
VStack(alignment: .leading, spacing: 8) {
Text("Duration on Screen")
.font(.headline)
HStack {
Slider(
value: Binding(
get: { Double(timeOnScreen) },
set: { timeOnScreen = Int($0) }
),
in: 5...120,
step: 5
)
Text("\(timeOnScreen)s")
.frame(width: 50, alignment: .trailing)
.monospacedDigit()
}
}
VStack(alignment: .leading, spacing: 8) {
Text("Message (Optional)")
.font(.headline)
TextField("Enter custom reminder message", text: $message)
.textFieldStyle(.roundedBorder)
Text("Leave blank to show a default timer notification")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
.glassEffect(.regular, in: .rect(cornerRadius: 12))
HStack(spacing: 12) {
Button("Cancel", action: onCancel)
.keyboardShortcut(.escape)
Button(timer == nil ? "Add" : "Save") {
let newTimer = UserTimer(
id: timer?.id ?? UUID().uuidString,
type: type,
timeOnScreenSeconds: timeOnScreen,
message: message.isEmpty ? nil : message
)
onSave(newTimer)
}
.keyboardShortcut(.return)
.buttonStyle(.borderedProminent)
}
}
.padding(24)
.frame(width: 400)
}
}
#Preview("User Timers - Empty") {
UserTimersView(userTimers: .constant([]))
}
#Preview("User Timers - With Timers") {
UserTimersView(
userTimers: .constant([
UserTimer(
id: "1", type: .subtle, timeOnScreenSeconds: 30, message: "Take a break"),
UserTimer(
id: "2", type: .overlay, timeOnScreenSeconds: 60,
message: "Stretch your legs"),
])
)
}

View File

@@ -18,19 +18,26 @@ struct SettingsWindowView: View {
@State private var postureEnabled: Bool
@State private var postureIntervalMinutes: Int
@State private var launchAtLogin: Bool
@State private var subtleReminderSizePercentage: Double
init(settingsManager: SettingsManager, initialTab: Int = 0) {
self.settingsManager = settingsManager
_currentTab = State(initialValue: initialTab)
_lookAwayEnabled = State(initialValue: settingsManager.settings.lookAwayTimer.enabled)
_lookAwayIntervalMinutes = State(initialValue: settingsManager.settings.lookAwayTimer.intervalSeconds / 60)
_lookAwayCountdownSeconds = State(initialValue: settingsManager.settings.lookAwayCountdownSeconds)
_lookAwayIntervalMinutes = State(
initialValue: settingsManager.settings.lookAwayTimer.intervalSeconds / 60)
_lookAwayCountdownSeconds = State(
initialValue: settingsManager.settings.lookAwayCountdownSeconds)
_blinkEnabled = State(initialValue: settingsManager.settings.blinkTimer.enabled)
_blinkIntervalMinutes = State(initialValue: settingsManager.settings.blinkTimer.intervalSeconds / 60)
_blinkIntervalMinutes = State(
initialValue: settingsManager.settings.blinkTimer.intervalSeconds / 60)
_postureEnabled = State(initialValue: settingsManager.settings.postureTimer.enabled)
_postureIntervalMinutes = State(initialValue: settingsManager.settings.postureTimer.intervalSeconds / 60)
_postureIntervalMinutes = State(
initialValue: settingsManager.settings.postureTimer.intervalSeconds / 60)
_launchAtLogin = State(initialValue: settingsManager.settings.launchAtLogin)
_subtleReminderSizePercentage = State(
initialValue: settingsManager.settings.subtleReminderSizePercentage)
}
var body: some View {
@@ -64,16 +71,22 @@ struct SettingsWindowView: View {
Label("Posture", systemImage: "figure.stand")
}
UserTimersView(userTimers: $settingsManager.settings.userTimers)
.tag(3)
.tabItem {
Label("User Timers", systemImage: "plus.circle")
}
SettingsOnboardingView(
launchAtLogin: $launchAtLogin,
subtleReminderSizePercentage: $subtleReminderSizePercentage,
isOnboarding: false
)
.tag(3)
.tag(4)
.tabItem {
Label("General", systemImage: "gearshape.fill")
}
}
.padding()
Divider()
@@ -94,8 +107,10 @@ struct SettingsWindowView: View {
}
.padding()
}
.frame(width: 600, height: 550)
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("SwitchToSettingsTab"))) { notification in
.frame(minWidth: 700, minHeight: 800)
.onReceive(
NotificationCenter.default.publisher(for: Notification.Name("SwitchToSettingsTab"))
) { notification in
if let tab = notification.object as? Int {
currentTab = tab
}
@@ -120,6 +135,7 @@ struct SettingsWindowView: View {
)
settingsManager.settings.launchAtLogin = launchAtLogin
settingsManager.settings.subtleReminderSizePercentage = subtleReminderSizePercentage
do {
if launchAtLogin {