465 lines
16 KiB
Swift
465 lines
16 KiB
Swift
//
|
|
// MenuBarContentView.swift
|
|
// Gaze
|
|
//
|
|
// Created by Mike Freno on 1/7/26.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct MenuBarContentWrapper: View {
|
|
@ObservedObject var appDelegate: AppDelegate
|
|
@Bindable var settingsManager: SettingsManager
|
|
var onQuit: () -> Void
|
|
var onOpenSettings: () -> Void
|
|
var onOpenSettingsTab: (Int) -> Void
|
|
var onOpenOnboarding: () -> Void
|
|
|
|
var body: some View {
|
|
MenuBarContentView(
|
|
timerEngine: appDelegate.timerEngine,
|
|
settingsManager: settingsManager,
|
|
onQuit: onQuit,
|
|
onOpenSettings: onOpenSettings,
|
|
onOpenSettingsTab: onOpenSettingsTab,
|
|
onOpenOnboarding: onOpenOnboarding
|
|
)
|
|
}
|
|
}
|
|
|
|
// Hover button style for menubar items
|
|
struct MenuBarButtonStyle: ButtonStyle {
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(
|
|
configuration.isPressed
|
|
? Color.accentColor.opacity(0.2) : Color.gray.opacity(0.1)
|
|
)
|
|
.opacity(configuration.isPressed ? 1 : 0)
|
|
)
|
|
.contentShape(Rectangle())
|
|
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
|
|
}
|
|
}
|
|
|
|
struct MenuBarHoverButtonStyle: ButtonStyle {
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
@State private var isHovered = false
|
|
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.foregroundColor(isHovered ? .white : .primary)
|
|
.glassEffectIfAvailable(
|
|
isHovered
|
|
? GlassStyle.regular.tint(.accentColor).interactive()
|
|
: GlassStyle.regular,
|
|
in: .rect(cornerRadius: 6),
|
|
colorScheme: colorScheme
|
|
)
|
|
.contentShape(Rectangle())
|
|
.onHover { hovering in
|
|
isHovered = hovering
|
|
}
|
|
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
|
|
.animation(.easeInOut(duration: 0.1), value: isHovered)
|
|
.animation(.easeInOut(duration: 0.05), value: configuration.isPressed)
|
|
}
|
|
}
|
|
|
|
struct MenuBarContentView: View {
|
|
var timerEngine: TimerEngine?
|
|
@Bindable var settingsManager: SettingsManager
|
|
@Environment(\.dismiss) private var dismiss
|
|
var onQuit: () -> Void
|
|
var onOpenSettings: () -> Void
|
|
var onOpenSettingsTab: (Int) -> Void
|
|
var onOpenOnboarding: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
if settingsManager.settings.hasCompletedOnboarding {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Active Timers")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.padding(.horizontal)
|
|
.padding(.top, 8)
|
|
|
|
ForEach(
|
|
timerEngine.map { getSortedTimerIdentifiers(timerEngine: $0) } ?? [],
|
|
id: \.self
|
|
) {
|
|
identifier in
|
|
if let engine = timerEngine, engine.timerStates[identifier] != nil {
|
|
TimerStatusRowWithIndividualControls(
|
|
identifier: identifier,
|
|
timerEngine: engine,
|
|
settingsManager: settingsManager,
|
|
onSkip: {
|
|
engine.skipNext(identifier: identifier)
|
|
},
|
|
onDevTrigger: {
|
|
engine.triggerReminder(for: identifier)
|
|
},
|
|
onTogglePause: { isPaused in
|
|
if isPaused {
|
|
engine.pauseTimer(identifier: identifier)
|
|
} else {
|
|
engine.resumeTimer(identifier: identifier)
|
|
}
|
|
},
|
|
onTap: {
|
|
switch identifier {
|
|
case .builtIn(let type):
|
|
onOpenSettingsTab(type.tabIndex)
|
|
case .user:
|
|
onOpenSettingsTab(3) // User Timers tab
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.padding(.bottom, 8)
|
|
|
|
Divider()
|
|
|
|
// Controls
|
|
VStack(spacing: 4) {
|
|
Button(action: {
|
|
if let engine = timerEngine {
|
|
if isAllPaused(timerEngine: engine) {
|
|
engine.resume()
|
|
} else {
|
|
engine.pause()
|
|
}
|
|
}
|
|
}) {
|
|
HStack {
|
|
Image(
|
|
systemName: timerEngine.map { isAllPaused(timerEngine: $0) }
|
|
?? false
|
|
? "play.circle" : "pause.circle")
|
|
Text(
|
|
timerEngine.map { isAllPaused(timerEngine: $0) } ?? false
|
|
? "Resume All Timers" : "Pause All Timers")
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 6)
|
|
}
|
|
.buttonStyle(MenuBarHoverButtonStyle())
|
|
|
|
Button(action: {
|
|
onOpenSettings()
|
|
}) {
|
|
HStack {
|
|
Image(systemName: "gearshape")
|
|
Text("Settings...")
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 6)
|
|
}
|
|
.buttonStyle(MenuBarHoverButtonStyle())
|
|
}
|
|
.padding(.vertical, 8)
|
|
.padding(.horizontal, 8)
|
|
|
|
Divider()
|
|
} else {
|
|
VStack(spacing: 4) {
|
|
Button(action: {
|
|
onOpenOnboarding()
|
|
}) {
|
|
HStack {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundColor(.accentColor)
|
|
Text("Complete Onboarding")
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 6)
|
|
}
|
|
.buttonStyle(MenuBarHoverButtonStyle())
|
|
}
|
|
.padding(.vertical, 8)
|
|
.padding(.horizontal, 8)
|
|
}
|
|
|
|
HStack {
|
|
Button(action: onQuit) {
|
|
HStack {
|
|
Image(systemName: "power")
|
|
.foregroundColor(.red)
|
|
Text("Quit Gaze")
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 6)
|
|
}
|
|
.buttonStyle(MenuBarHoverButtonStyle())
|
|
.padding(.vertical, 8)
|
|
Spacer()
|
|
Text(
|
|
"v\(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0")"
|
|
)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
|
|
}
|
|
.frame(width: 300)
|
|
.onReceive(
|
|
NotificationCenter.default.publisher(for: Notification.Name("CloseMenuBarPopover"))
|
|
) { _ in
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
private func isAllPaused(timerEngine: TimerEngine?) -> Bool {
|
|
// Check if all timers are paused
|
|
guard let engine = timerEngine else { return false }
|
|
let activeStates = engine.timerStates.values.filter { $0.isActive }
|
|
return !activeStates.isEmpty && activeStates.allSatisfy { $0.isPaused }
|
|
}
|
|
|
|
private func getSortedTimerIdentifiers(timerEngine: TimerEngine?) -> [TimerIdentifier] {
|
|
guard let engine = timerEngine else { return [] }
|
|
return engine.timerStates.keys.sorted { id1, id2 in
|
|
// Sort built-in timers before user timers
|
|
switch (id1, id2) {
|
|
case (.builtIn(let t1), .builtIn(let t2)):
|
|
return t1.tabIndex < t2.tabIndex
|
|
case (.builtIn, .user):
|
|
return true
|
|
case (.user, .builtIn):
|
|
return false
|
|
case (.user(let id1), .user(let id2)):
|
|
return id1 < id2
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct TimerStatusRowWithIndividualControls: View {
|
|
let identifier: TimerIdentifier
|
|
@ObservedObject var timerEngine: TimerEngine
|
|
@Bindable var settingsManager: SettingsManager
|
|
var onSkip: () -> Void
|
|
var onDevTrigger: (() -> Void)? = nil
|
|
var onTogglePause: (Bool) -> Void
|
|
var onTap: (() -> Void)? = nil
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
@State private var isHoveredSkip = false
|
|
@State private var isHoveredDevTrigger = false
|
|
@State private var isHoveredBody = false
|
|
@State private var isHoveredPauseButton = false
|
|
|
|
private var state: TimerState? {
|
|
return timerEngine.timerStates[identifier]
|
|
}
|
|
|
|
private var isPaused: Bool {
|
|
return state?.isPaused ?? false
|
|
}
|
|
|
|
private var displayName: String {
|
|
switch identifier {
|
|
case .builtIn(let type):
|
|
return type.displayName
|
|
case .user(let id):
|
|
return settingsManager.settings.userTimers.first(where: { $0.id == id })?.title
|
|
?? "User Timer"
|
|
}
|
|
}
|
|
|
|
private var iconName: String {
|
|
switch identifier {
|
|
case .builtIn(let type):
|
|
return type.iconName
|
|
case .user:
|
|
return "clock.fill"
|
|
}
|
|
}
|
|
|
|
private var color: Color {
|
|
switch identifier {
|
|
case .builtIn(let type):
|
|
switch type {
|
|
case .lookAway: return .accentColor
|
|
case .blink: return .green
|
|
case .posture: return .orange
|
|
}
|
|
case .user(let id):
|
|
return settingsManager.settings.userTimers.first(where: { $0.id == id })?.color
|
|
?? .purple
|
|
}
|
|
}
|
|
|
|
private var tooltipText: String {
|
|
switch identifier {
|
|
case .builtIn(let type):
|
|
return type.tooltipText
|
|
case .user(let id):
|
|
guard let timer = settingsManager.settings.userTimers.first(where: { $0.id == id })
|
|
else {
|
|
return "User 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)"
|
|
}
|
|
}
|
|
|
|
private var userTimer: UserTimer? {
|
|
if case .user(let id) = identifier {
|
|
return settingsManager.settings.userTimers.first(where: { $0.id == id })
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var body: some View {
|
|
HStack {
|
|
HStack {
|
|
// Show color indicator circle for user timers
|
|
if let timer = userTimer {
|
|
Circle()
|
|
.fill(isHoveredBody ? .white : timer.color)
|
|
.frame(width: 8, height: 8)
|
|
}
|
|
|
|
Image(systemName: iconName)
|
|
.foregroundColor(isHoveredBody ? .white : color)
|
|
.frame(width: 20)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(displayName)
|
|
.font(.subheadline)
|
|
.fontWeight(.medium)
|
|
.foregroundColor(isHoveredBody ? .white : .primary)
|
|
.lineLimit(1)
|
|
|
|
if let state = state {
|
|
Text(state.remainingSeconds.asTimerDuration)
|
|
.font(.caption)
|
|
.foregroundColor(isHoveredBody ? .white.opacity(0.8) : .secondary)
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
onTap?()
|
|
}
|
|
|
|
#if DEBUG
|
|
if let onDevTrigger = onDevTrigger {
|
|
Button(action: onDevTrigger) {
|
|
Image(systemName: "bolt.fill")
|
|
.font(.caption)
|
|
.foregroundColor(isHoveredDevTrigger ? .white : .yellow)
|
|
.padding(6)
|
|
.contentShape(Circle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.glassEffectIfAvailable(
|
|
isHoveredDevTrigger
|
|
? GlassStyle.regular.tint(.yellow) : GlassStyle.regular,
|
|
in: .circle,
|
|
colorScheme: colorScheme
|
|
)
|
|
.help("Trigger \(displayName) reminder now (dev)")
|
|
.accessibilityIdentifier(
|
|
"trigger_\(displayName.replacingOccurrences(of: " ", with: "_"))"
|
|
)
|
|
.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,
|
|
colorScheme: colorScheme
|
|
)
|
|
.help(
|
|
isPaused
|
|
? "Resume \(displayName)" : "Pause \(displayName)"
|
|
)
|
|
.onHover { hovering in
|
|
isHoveredPauseButton = hovering
|
|
}
|
|
|
|
Button(action: onSkip) {
|
|
Image(systemName: "forward.fill")
|
|
.font(.caption)
|
|
.foregroundColor(isHoveredSkip ? .white : .accentColor)
|
|
.padding(6)
|
|
.contentShape(Circle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.glassEffectIfAvailable(
|
|
isHoveredSkip
|
|
? GlassStyle.regular.tint(.accentColor) : GlassStyle.regular,
|
|
in: .circle,
|
|
colorScheme: colorScheme
|
|
)
|
|
.help("Skip to next \(displayName) reminder")
|
|
.onHover { hovering in
|
|
isHoveredSkip = hovering
|
|
}
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 6)
|
|
.glassEffectIfAvailable(
|
|
isHoveredBody
|
|
? GlassStyle.regular.tint(.accentColor)
|
|
: GlassStyle.regular,
|
|
in: .rect(cornerRadius: 6),
|
|
colorScheme: colorScheme
|
|
)
|
|
.padding(.horizontal, 8)
|
|
.onHover { hovering in
|
|
isHoveredBody = hovering
|
|
}
|
|
.help(tooltipText)
|
|
}
|
|
|
|
}
|
|
|
|
#Preview("Menu Bar Content") {
|
|
let settingsManager = SettingsManager.shared
|
|
let timerEngine = TimerEngine(settingsManager: settingsManager)
|
|
MenuBarContentView(
|
|
timerEngine: timerEngine,
|
|
settingsManager: settingsManager,
|
|
onQuit: {},
|
|
onOpenSettings: {},
|
|
onOpenSettingsTab: { _ in },
|
|
onOpenOnboarding: {}
|
|
)
|
|
}
|