fix: User reminders fixed, UI improvements
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
150
Gaze/Views/Reminders/UserTimerOverlayReminderView.swift
Normal file
150
Gaze/Views/Reminders/UserTimerOverlayReminderView.swift
Normal 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: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
85
Gaze/Views/Reminders/UserTimerReminderView.swift
Normal file
85
Gaze/Views/Reminders/UserTimerReminderView.swift
Normal 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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user