general: qol menubar content close on settings open, positional consitencies

This commit is contained in:
Michael Freno
2026-01-09 19:00:13 -05:00
parent 368f0c88cc
commit ec87520ba6
9 changed files with 596 additions and 407 deletions

View File

@@ -188,6 +188,16 @@ private func showReminderWindow(_ content: AnyView) {
// Public method to open settings window
func openSettings(tab: Int = 0) {
// Post notification to close menu bar popover
NotificationCenter.default.post(name: Notification.Name("CloseMenuBarPopover"), object: nil)
// Small delay to allow menu bar to close before opening settings
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.openSettingsWindow(tab: tab)
}
}
private func openSettingsWindow(tab: Int) {
// If window already exists, switch to the tab and bring it to front
if let existingWindow = settingsWindowController?.window {
NotificationCenter.default.post(

View File

@@ -6,29 +6,98 @@
//
import Foundation
import SwiftUI
/// Represents a user-defined timer with customizable properties
struct UserTimer: Codable, Equatable, Identifiable {
let id: String
var title: String
var type: UserTimerType
var timeOnScreenSeconds: Int
var message: String?
var colorHex: String
var enabled: Bool
init(
id: String = UUID().uuidString,
title: String? = nil,
type: UserTimerType = .subtle,
timeOnScreenSeconds: Int = 30,
message: String? = nil
message: String? = nil,
colorHex: String? = nil,
enabled: Bool = true
) {
self.id = id
self.title = title ?? "User Reminder"
self.type = type
self.timeOnScreenSeconds = timeOnScreenSeconds
self.message = message
self.colorHex = colorHex ?? UserTimer.defaultColors[0]
self.enabled = enabled
}
static func == (lhs: UserTimer, rhs: UserTimer) -> Bool {
lhs.id == rhs.id && lhs.type == rhs.type
lhs.id == rhs.id && lhs.title == rhs.title && lhs.type == rhs.type
&& lhs.timeOnScreenSeconds == rhs.timeOnScreenSeconds && lhs.message == rhs.message
&& lhs.colorHex == rhs.colorHex && lhs.enabled == rhs.enabled
}
// Default color palette for user timers
static let defaultColors = [
"9B59B6", // Purple
"3498DB", // Blue
"E74C3C", // Red
"2ECC71", // Green
"F39C12", // Orange
"1ABC9C", // Teal
"E91E63", // Pink
"FF5722" // Deep Orange
]
var color: Color {
Color(hex: colorHex) ?? .purple
}
static func generateTitle(for index: Int) -> String {
"User Reminder \(index + 1)"
}
}
extension Color {
init?(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
guard Scanner(string: hex).scanHexInt64(&int) else { return nil }
let r, g, b: UInt64
switch hex.count {
case 6: // RGB (24-bit)
(r, g, b) = ((int >> 16) & 0xFF, (int >> 8) & 0xFF, int & 0xFF)
default:
return nil
}
self.init(
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255
)
}
var hexString: String? {
guard let components = NSColor(self).cgColor.components, components.count >= 3 else {
return nil
}
let r = Float(components[0])
let g = Float(components[1])
let b = Float(components[2])
return String(format: "%02lX%02lX%02lX",
lroundf(r * 255),
lroundf(g * 255),
lroundf(b * 255))
}
}

View File

@@ -46,6 +46,7 @@ struct MenuBarHoverButtonStyle: ButtonStyle {
struct MenuBarContentView: View {
@ObservedObject var timerEngine: TimerEngine
@ObservedObject var settingsManager: SettingsManager
@Environment(\.dismiss) private var dismiss
var onQuit: () -> Void
var onOpenSettings: () -> Void
var onOpenSettingsTab: (Int) -> Void
@@ -98,8 +99,8 @@ struct MenuBarContentView: View {
}
}
// Show user timers if any exist
ForEach(settingsManager.settings.userTimers, id: \.id) { userTimer in
// Show user timers if any exist and are enabled
ForEach(settingsManager.settings.userTimers.filter { $0.enabled }, id: \.id) { userTimer in
UserTimerStatusRow(
timer: userTimer,
state: nil, // We'll implement proper state tracking later
@@ -166,6 +167,9 @@ struct MenuBarContentView: View {
.padding(.vertical, 8)
}
.frame(width: 300)
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("CloseMenuBarPopover"))) { _ in
dismiss()
}
}
private var isPaused: Bool {
@@ -336,12 +340,16 @@ struct UserTimerStatusRow: View {
var body: some View {
Button(action: onTap) {
HStack {
Circle()
.fill(timer.color)
.frame(width: 8, height: 8)
Image(systemName: "clock.fill")
.foregroundColor(.purple)
.foregroundColor(timer.color)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text(timer.message ?? "Custom Timer")
Text(timer.title)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(1)
@@ -352,7 +360,7 @@ struct UserTimerStatusRow: View {
.foregroundColor(.secondary)
.monospacedDigit()
} else {
Text("Not active")
Text(timer.enabled ? "Not active" : "Disabled")
.font(.caption)
.foregroundColor(.secondary)
}
@@ -370,7 +378,7 @@ struct UserTimerStatusRow: View {
}
.buttonStyle(.plain)
.glassEffect(
isHovered ? .regular.tint(.purple.opacity(0.5)) : .regular,
isHovered ? .regular.tint(timer.color.opacity(0.3)) : .regular,
in: .rect(cornerRadius: 6)
)
.padding(.horizontal, 8)
@@ -383,7 +391,8 @@ struct UserTimerStatusRow: View {
private var tooltipText: String {
let typeText = timer.type == .subtle ? "Subtle" : "Overlay"
let durationText = "\(timer.timeOnScreenSeconds)s on screen"
return "\(typeText) timer - \(durationText)"
let statusText = timer.enabled ? "" : " (Disabled)"
return "\(typeText) timer - \(durationText)\(statusText)"
}
private func timeRemaining(_ state: TimerState) -> String {

View File

@@ -12,14 +12,23 @@ struct BlinkSetupView: View {
@Binding var intervalMinutes: Int
var body: some View {
VStack(spacing: 30) {
VStack(spacing: 0) {
// Fixed header section
VStack(spacing: 16) {
Image(systemName: "eye.circle")
.font(.system(size: 60))
.foregroundColor(.green)
Text("Blink Reminder")
.font(.system(size: 28, weight: .bold))
}
.padding(.top, 20)
.padding(.bottom, 30)
// Vertically centered content
Spacer()
VStack(spacing: 30) {
Text("Keep your eyes hydrated")
.font(.title3)
.foregroundColor(.secondary)
@@ -89,6 +98,7 @@ struct BlinkSetupView: View {
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
}

View File

@@ -19,14 +19,23 @@ struct LookAwaySetupView: View {
@Binding var countdownSeconds: Int
var body: some View {
VStack(spacing: 30) {
VStack(spacing: 0) {
// Fixed header section
VStack(spacing: 16) {
Image(systemName: "eye.fill")
.font(.system(size: 60))
.foregroundColor(.accentColor)
Text("Look Away Reminder")
.font(.system(size: 28, weight: .bold))
}
.padding(.top, 20)
.padding(.bottom, 30)
// Vertically centered content
Spacer()
VStack(spacing: 30) {
// InfoBox with link functionality
HStack(spacing: 12) {
Button(action: {
@@ -107,6 +116,7 @@ struct LookAwaySetupView: View {
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
}

View File

@@ -12,14 +12,23 @@ struct PostureSetupView: View {
@Binding var intervalMinutes: Int
var body: some View {
VStack(spacing: 30) {
VStack(spacing: 0) {
// Fixed header section
VStack(spacing: 16) {
Image(systemName: "figure.stand")
.font(.system(size: 60))
.foregroundColor(.orange)
Text("Posture Reminder")
.font(.system(size: 28, weight: .bold))
}
.padding(.top, 20)
.padding(.bottom, 30)
// Vertically centered content
Spacer()
VStack(spacing: 30) {
Text("Maintain proper ergonomics")
.font(.title3)
.foregroundColor(.secondary)
@@ -89,6 +98,7 @@ struct PostureSetupView: View {
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
}

View File

@@ -13,21 +13,25 @@ struct SettingsOnboardingView: View {
var isOnboarding: Bool = true
var body: some View {
VStack(spacing: 30) {
Spacer()
VStack(spacing: 0) {
// Fixed header section
VStack(spacing: 16) {
Image(systemName: "gearshape.fill")
.font(.system(size: 80))
.font(.system(size: 60))
.foregroundColor(.accentColor)
Text(isOnboarding ? "Final Settings" : "General Settings")
.font(.system(size: 36, weight: .bold))
.font(.system(size: 28, weight: .bold))
}
.padding(.top, 20)
.padding(.bottom, 30)
// Vertically centered content
Spacer()
VStack(spacing: 30) {
Text("Configure app preferences and support the project")
.font(.title3)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
VStack(spacing: 20) {
// Launch at Login Toggle
@@ -137,10 +141,10 @@ struct SettingsOnboardingView: View {
}
.padding()
}
}
Spacer()
}
.frame(minWidth: 650, minHeight: 650)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.background(.clear)
}

View File

@@ -13,14 +13,21 @@ struct UserTimersView: View {
@State private var showingAddTimer = false
var body: some View {
VStack(spacing: 30) {
VStack(spacing: 0) {
// Fixed header section
VStack(spacing: 16) {
Image(systemName: "clock.badge.checkmark")
.font(.system(size: 60))
.foregroundColor(.purple)
Text("Custom Timers")
.font(.system(size: 28, weight: .bold))
}
.padding(.top, 20)
.padding(.bottom, 30)
// Vertically centered content
Spacer()
VStack(spacing: 30) {
Text("Create your own reminder schedules")
.font(.title3)
.foregroundColor(.secondary)
@@ -67,17 +74,17 @@ struct UserTimersView: View {
} else {
ScrollView {
VStack(spacing: 8) {
ForEach(userTimers) { timer in
ForEach(Array(userTimers.enumerated()), id: \.element.id) { index, timer in
UserTimerRow(
timer: timer,
timer: $userTimers[index],
onEdit: {
editingTimer = timer
},
onDelete: {
if let index = userTimers.firstIndex(where: {
if let idx = userTimers.firstIndex(where: {
$0.id == timer.id
}) {
userTimers.remove(at: index)
userTimers.remove(at: idx)
}
}
)
@@ -89,7 +96,7 @@ struct UserTimersView: View {
}
.padding()
.glassEffect(.regular, in: .rect(cornerRadius: 12))
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -98,6 +105,7 @@ struct UserTimersView: View {
.sheet(isPresented: $showingAddTimer) {
UserTimerEditSheet(
timer: nil,
existingTimersCount: userTimers.count,
onSave: { newTimer in
userTimers.append(newTimer)
showingAddTimer = false
@@ -110,6 +118,7 @@ struct UserTimersView: View {
.sheet(item: $editingTimer) { timer in
UserTimerEditSheet(
timer: timer,
existingTimersCount: userTimers.count,
onSave: { updatedTimer in
if let index = userTimers.firstIndex(where: { $0.id == timer.id }) {
userTimers[index] = updatedTimer
@@ -125,19 +134,23 @@ struct UserTimersView: View {
}
struct UserTimerRow: View {
let timer: UserTimer
@Binding var timer: UserTimer
var onEdit: () -> Void
var onDelete: () -> Void
@State private var isHovered = false
var body: some View {
HStack(spacing: 12) {
Circle()
.fill(timer.color)
.frame(width: 12, height: 12)
Image(systemName: timer.type == .subtle ? "eye.circle" : "rectangle.on.rectangle")
.foregroundColor(.purple)
.foregroundColor(timer.color)
.frame(width: 24)
VStack(alignment: .leading, spacing: 4) {
Text(timer.message ?? "Custom Timer")
Text(timer.title)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(1)
@@ -148,16 +161,21 @@ struct UserTimerRow: View {
Spacer()
HStack(spacing: 4) {
HStack(spacing: 8) {
Toggle("", isOn: $timer.enabled)
.labelsHidden()
.toggleStyle(.switch)
.controlSize(.small)
Button(action: onEdit) {
Image(systemName: "pencil.circle")
Image(systemName: "pencil.circle.fill")
.font(.title3)
.foregroundColor(.accentColor)
}
.buttonStyle(.plain)
Button(action: onDelete) {
Image(systemName: "trash.circle")
Image(systemName: "trash.circle.fill")
.font(.title3)
.foregroundColor(.red)
}
@@ -177,25 +195,32 @@ struct UserTimerRow: View {
struct UserTimerEditSheet: View {
let timer: UserTimer?
let existingTimersCount: Int
var onSave: (UserTimer) -> Void
var onCancel: () -> Void
@State private var title: String
@State private var message: String
@State private var type: UserTimerType
@State private var timeOnScreen: Int
@State private var selectedColorHex: String
init(
timer: UserTimer?,
existingTimersCount: Int = 0,
onSave: @escaping (UserTimer) -> Void,
onCancel: @escaping () -> Void
) {
self.timer = timer
self.existingTimersCount = existingTimersCount
self.onSave = onSave
self.onCancel = onCancel
_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)
_selectedColorHex = State(initialValue: timer?.colorHex ?? UserTimer.defaultColors[existingTimersCount % UserTimer.defaultColors.count])
}
var body: some View {
@@ -205,6 +230,39 @@ struct UserTimerEditSheet: View {
.fontWeight(.bold)
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("Title")
.font(.headline)
TextField("Timer title", text: $title)
.textFieldStyle(.roundedBorder)
Text("Example: \"Stretch Break\", \"Eye Rest\", \"Water Break\"")
.font(.caption)
.foregroundColor(.secondary)
}
VStack(alignment: .leading, spacing: 8) {
Text("Color")
.font(.headline)
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 8), spacing: 12) {
ForEach(UserTimer.defaultColors, id: \.self) { colorHex in
Button(action: {
selectedColorHex = colorHex
}) {
Circle()
.fill(Color(hex: colorHex) ?? .purple)
.frame(width: 32, height: 32)
.overlay(
Circle()
.strokeBorder(Color.white, lineWidth: selectedColorHex == colorHex ? 3 : 0)
)
.shadow(color: selectedColorHex == colorHex ? .accentColor : .clear, radius: 4)
}
.buttonStyle(.plain)
}
}
}
VStack(alignment: .leading, spacing: 8) {
Text("Display Type")
.font(.headline)
@@ -263,9 +321,12 @@ struct UserTimerEditSheet: View {
Button(timer == nil ? "Add" : "Save") {
let newTimer = UserTimer(
id: timer?.id ?? UUID().uuidString,
title: title,
type: type,
timeOnScreenSeconds: timeOnScreen,
message: message.isEmpty ? nil : message
message: message.isEmpty ? nil : message,
colorHex: selectedColorHex,
enabled: timer?.enabled ?? true
)
onSave(newTimer)
}
@@ -274,7 +335,7 @@ struct UserTimerEditSheet: View {
}
}
.padding(24)
.frame(width: 400)
.frame(width: 450)
}
}
@@ -286,10 +347,10 @@ struct UserTimerEditSheet: View {
UserTimersView(
userTimers: .constant([
UserTimer(
id: "1", type: .subtle, timeOnScreenSeconds: 30, message: "Take a break"),
id: "1", title: "User Reminder 1", type: .subtle, timeOnScreenSeconds: 30, message: "Take a break", colorHex: "9B59B6"),
UserTimer(
id: "2", type: .overlay, timeOnScreenSeconds: 60,
message: "Stretch your legs"),
id: "2", title: "User Reminder 2", type: .overlay, timeOnScreenSeconds: 60,
message: "Stretch your legs", colorHex: "3498DB"),
])
)
}

View File

@@ -19,6 +19,7 @@ struct SettingsWindowView: View {
@State private var postureIntervalMinutes: Int
@State private var launchAtLogin: Bool
@State private var subtleReminderSizePercentage: Double
@State private var userTimers: [UserTimer]
init(settingsManager: SettingsManager, initialTab: Int = 0) {
self.settingsManager = settingsManager
@@ -38,6 +39,7 @@ struct SettingsWindowView: View {
_launchAtLogin = State(initialValue: settingsManager.settings.launchAtLogin)
_subtleReminderSizePercentage = State(
initialValue: settingsManager.settings.subtleReminderSizePercentage)
_userTimers = State(initialValue: settingsManager.settings.userTimers)
}
var body: some View {
@@ -71,7 +73,7 @@ struct SettingsWindowView: View {
Label("Posture", systemImage: "figure.stand")
}
UserTimersView(userTimers: $settingsManager.settings.userTimers)
UserTimersView(userTimers: $userTimers)
.tag(3)
.tabItem {
Label("User Timers", systemImage: "plus.circle")
@@ -107,7 +109,7 @@ struct SettingsWindowView: View {
}
.padding()
}
.frame(minWidth: 700, minHeight: 800)
.frame(minWidth: 700, minHeight: 750)
.onReceive(
NotificationCenter.default.publisher(for: Notification.Name("SwitchToSettingsTab"))
) { notification in
@@ -136,6 +138,10 @@ struct SettingsWindowView: View {
settingsManager.settings.launchAtLogin = launchAtLogin
settingsManager.settings.subtleReminderSizePercentage = subtleReminderSizePercentage
settingsManager.settings.userTimers = userTimers
// Save settings to persist changes
settingsManager.save()
do {
if launchAtLogin {