feat: switch to size preset for more consistent control

This commit is contained in:
Michael Freno
2026-01-09 23:01:57 -05:00
parent 56521833e1
commit bbf1cbb3b5
11 changed files with 96 additions and 59 deletions

View File

@@ -140,14 +140,14 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
) )
case .blinkTriggered: case .blinkTriggered:
let sizePercentage = settingsManager?.settings.subtleReminderSizePercentage ?? 15.0 let sizePercentage = settingsManager?.settings.subtleReminderSize.percentage ?? 5.0
contentView = AnyView( contentView = AnyView(
BlinkReminderView(sizePercentage: sizePercentage) { [weak self] in BlinkReminderView(sizePercentage: sizePercentage) { [weak self] in
self?.timerEngine?.dismissReminder() self?.timerEngine?.dismissReminder()
} }
) )
case .postureTriggered: case .postureTriggered:
let sizePercentage = settingsManager?.settings.subtleReminderSizePercentage ?? 10.0 let sizePercentage = settingsManager?.settings.subtleReminderSize.percentage ?? 5.0
contentView = AnyView( contentView = AnyView(
PostureReminderView(sizePercentage: sizePercentage) { [weak self] in PostureReminderView(sizePercentage: sizePercentage) { [weak self] in
self?.timerEngine?.dismissReminder() self?.timerEngine?.dismissReminder()

View File

@@ -11,7 +11,6 @@ import SwiftUI
struct GazeApp: App { struct GazeApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var settingsManager = SettingsManager.shared @StateObject private var settingsManager = SettingsManager.shared
@State private var menuBarRefreshID = 0
var body: some Scene { var body: some Scene {
// Onboarding window (only shown when not completed) // Onboarding window (only shown when not completed)
@@ -48,13 +47,9 @@ struct GazeApp: App {
onOpenSettings: { appDelegate.openSettings() }, onOpenSettings: { appDelegate.openSettings() },
onOpenSettingsTab: { tab in appDelegate.openSettings(tab: tab) } onOpenSettingsTab: { tab in appDelegate.openSettings(tab: tab) }
) )
.id(menuBarRefreshID)
} }
} }
.menuBarExtraStyle(.window) .menuBarExtraStyle(.window)
.onChange(of: settingsManager.settings) { _ in
menuBarRefreshID += 1
}
} }
private func closeAllWindows() { private func closeAllWindows() {

View File

@@ -7,6 +7,30 @@
import Foundation import Foundation
// MARK: - Reminder Size
enum ReminderSize: String, Codable, CaseIterable {
case small
case medium
case large
var percentage: Double {
switch self {
case .small: return 1.5
case .medium: return 2.5
case .large: return 5.0
}
}
var displayName: String {
switch self {
case .small: return "Small"
case .medium: return "Medium"
case .large: return "Large"
}
}
}
// MARK: - Centralized Configuration System // MARK: - Centralized Configuration System
/// Unified configuration class that manages all app settings in a centralized way /// Unified configuration class that manages all app settings in a centralized way
@@ -17,11 +41,9 @@ struct AppSettings: Codable, Equatable, Hashable {
var blinkTimer: TimerConfiguration var blinkTimer: TimerConfiguration
var postureTimer: TimerConfiguration var postureTimer: TimerConfiguration
// User-defined timers (up to 3)
var userTimers: [UserTimer] var userTimers: [UserTimer]
// UI and display settings var subtleReminderSize: ReminderSize
var subtleReminderSizePercentage: Double // 0.5-25% of screen width
// App state and behavior // App state and behavior
var hasCompletedOnboarding: Bool var hasCompletedOnboarding: Bool
@@ -37,7 +59,7 @@ struct AppSettings: Codable, Equatable, Hashable {
postureTimer: TimerConfiguration = TimerConfiguration( postureTimer: TimerConfiguration = TimerConfiguration(
enabled: true, intervalSeconds: 30 * 60), enabled: true, intervalSeconds: 30 * 60),
userTimers: [UserTimer] = [], userTimers: [UserTimer] = [],
subtleReminderSizePercentage: Double = 5.0, subtleReminderSize: ReminderSize = .large,
hasCompletedOnboarding: Bool = false, hasCompletedOnboarding: Bool = false,
launchAtLogin: Bool = false, launchAtLogin: Bool = false,
playSounds: Bool = true playSounds: Bool = true
@@ -47,8 +69,7 @@ struct AppSettings: Codable, Equatable, Hashable {
self.blinkTimer = blinkTimer self.blinkTimer = blinkTimer
self.postureTimer = postureTimer self.postureTimer = postureTimer
self.userTimers = userTimers self.userTimers = userTimers
// Clamp the subtle reminder size to valid range (2-35%) self.subtleReminderSize = subtleReminderSize
self.subtleReminderSizePercentage = max(2.0, min(35.0, subtleReminderSizePercentage))
self.hasCompletedOnboarding = hasCompletedOnboarding self.hasCompletedOnboarding = hasCompletedOnboarding
self.launchAtLogin = launchAtLogin self.launchAtLogin = launchAtLogin
self.playSounds = playSounds self.playSounds = playSounds
@@ -61,7 +82,7 @@ struct AppSettings: Codable, Equatable, Hashable {
blinkTimer: TimerConfiguration(enabled: false, intervalSeconds: 7 * 60), blinkTimer: TimerConfiguration(enabled: false, intervalSeconds: 7 * 60),
postureTimer: TimerConfiguration(enabled: true, intervalSeconds: 30 * 60), postureTimer: TimerConfiguration(enabled: true, intervalSeconds: 30 * 60),
userTimers: [], userTimers: [],
subtleReminderSizePercentage: 5.0, subtleReminderSize: .large,
hasCompletedOnboarding: false, hasCompletedOnboarding: false,
launchAtLogin: false, launchAtLogin: false,
playSounds: true playSounds: true
@@ -73,7 +94,7 @@ struct AppSettings: Codable, Equatable, Hashable {
&& lhs.lookAwayCountdownSeconds == rhs.lookAwayCountdownSeconds && lhs.lookAwayCountdownSeconds == rhs.lookAwayCountdownSeconds
&& lhs.blinkTimer == rhs.blinkTimer && lhs.postureTimer == rhs.postureTimer && lhs.blinkTimer == rhs.blinkTimer && lhs.postureTimer == rhs.postureTimer
&& lhs.userTimers == rhs.userTimers && lhs.userTimers == rhs.userTimers
&& lhs.subtleReminderSizePercentage == rhs.subtleReminderSizePercentage && lhs.subtleReminderSize == rhs.subtleReminderSize
&& lhs.hasCompletedOnboarding == rhs.hasCompletedOnboarding && lhs.hasCompletedOnboarding == rhs.hasCompletedOnboarding
&& lhs.launchAtLogin == rhs.launchAtLogin && lhs.playSounds == rhs.playSounds && lhs.launchAtLogin == rhs.launchAtLogin && lhs.playSounds == rhs.playSounds
} }

View File

@@ -83,6 +83,7 @@ class MigrationManager {
private func setupMigrations() { private func setupMigrations() {
migrations.append(Version101Migration()) migrations.append(Version101Migration())
migrations.append(Version102Migration())
} }
private func getTargetVersion() -> String { private func getTargetVersion() -> String {
@@ -165,6 +166,35 @@ class Version101Migration: Migration {
// Add any new fields with default values if they don't exist // Add any new fields with default values if they don't exist
// Transform data structures as needed // Transform data structures as needed
return migratedData
}
}
class Version102Migration: Migration {
var targetVersion: String = "1.0.2"
func migrate(_ data: [String: Any]) throws -> [String: Any] {
var migratedData = data
// Migrate subtleReminderSizePercentage (Double) to subtleReminderSize (ReminderSize enum)
if let oldPercentage = migratedData["subtleReminderSizePercentage"] as? Double {
// Map old percentage values to new enum cases
let reminderSize: String
if oldPercentage <= 2.0 {
reminderSize = "small"
} else if oldPercentage <= 3.5 {
reminderSize = "medium"
} else {
reminderSize = "large"
}
migratedData["subtleReminderSize"] = reminderSize
migratedData.removeValue(forKey: "subtleReminderSizePercentage")
} else if migratedData["subtleReminderSize"] == nil {
// If neither old nor new key exists, set default
migratedData["subtleReminderSize"] = "large"
}
return migratedData return migratedData
} }
} }

View File

@@ -5,38 +5,39 @@
// Created by Mike Freno on 1/7/26. // Created by Mike Freno on 1/7/26.
// //
import Foundation
import Combine import Combine
import Foundation
@MainActor @MainActor
class SettingsManager: ObservableObject { class SettingsManager: ObservableObject {
static let shared = SettingsManager() static let shared = SettingsManager()
@Published var settings: AppSettings { @Published var settings: AppSettings {
didSet { didSet {
save() save()
} }
} }
private let userDefaults = UserDefaults.standard private let userDefaults = UserDefaults.standard
private let settingsKey = "gazeAppSettings" private let settingsKey = "gazeAppSettings"
private init() { private init() {
#if DEBUG #if DEBUG
// Clear settings on every development build // Clear settings on every development build
UserDefaults.standard.removeObject(forKey: "gazeAppSettings") UserDefaults.standard.removeObject(forKey: "gazeAppSettings")
#endif #endif
self.settings = Self.loadSettings() self.settings = Self.loadSettings()
} }
private static func loadSettings() -> AppSettings { private static func loadSettings() -> AppSettings {
guard let data = UserDefaults.standard.data(forKey: "gazeAppSettings"), guard let data = UserDefaults.standard.data(forKey: "gazeAppSettings"),
let settings = try? JSONDecoder().decode(AppSettings.self, from: data) else { let settings = try? JSONDecoder().decode(AppSettings.self, from: data)
else {
return .defaults return .defaults
} }
return settings return settings
} }
func save() { func save() {
guard let data = try? JSONEncoder().encode(settings) else { guard let data = try? JSONEncoder().encode(settings) else {
print("Failed to encode settings") print("Failed to encode settings")
@@ -44,15 +45,15 @@ class SettingsManager: ObservableObject {
} }
userDefaults.set(data, forKey: settingsKey) userDefaults.set(data, forKey: settingsKey)
} }
func load() { func load() {
settings = Self.loadSettings() settings = Self.loadSettings()
} }
func resetToDefaults() { func resetToDefaults() {
settings = .defaults settings = .defaults
} }
func timerConfiguration(for type: TimerType) -> TimerConfiguration { func timerConfiguration(for type: TimerType) -> TimerConfiguration {
switch type { switch type {
case .lookAway: case .lookAway:
@@ -63,7 +64,7 @@ class SettingsManager: ObservableObject {
return settings.postureTimer return settings.postureTimer
} }
} }
func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration) { func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration) {
switch type { switch type {
case .lookAway: case .lookAway:

View File

@@ -50,9 +50,6 @@ struct MenuBarContentView: View {
var onQuit: () -> Void var onQuit: () -> Void
var onOpenSettings: () -> Void var onOpenSettings: () -> Void
var onOpenSettingsTab: (Int) -> Void var onOpenSettingsTab: (Int) -> Void
// Force view refresh when timer states change
@State private var refreshID = UUID()
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
@@ -170,13 +167,9 @@ struct MenuBarContentView: View {
.padding(.vertical, 8) .padding(.vertical, 8)
} }
.frame(width: 300) .frame(width: 300)
.id(refreshID)
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("CloseMenuBarPopover"))) { _ in .onReceive(NotificationCenter.default.publisher(for: Notification.Name("CloseMenuBarPopover"))) { _ in
dismiss() dismiss()
} }
.onReceive(timerEngine.$timerStates) { _ in
refreshID = UUID()
}
} }
private var isPaused: Bool { private var isPaused: Bool {

View File

@@ -30,7 +30,7 @@ struct OnboardingContainerView: View {
@State private var postureEnabled = true @State private var postureEnabled = true
@State private var postureIntervalMinutes = 30 @State private var postureIntervalMinutes = 30
@State private var launchAtLogin = false @State private var launchAtLogin = false
@State private var subtleReminderSizePercentage = 5.0 @State private var subtleReminderSize: ReminderSize = .large
@State private var isAnimatingOut = false @State private var isAnimatingOut = false
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -76,7 +76,7 @@ struct OnboardingContainerView: View {
SettingsOnboardingView( SettingsOnboardingView(
launchAtLogin: $launchAtLogin, launchAtLogin: $launchAtLogin,
subtleReminderSizePercentage: $subtleReminderSizePercentage, subtleReminderSize: $subtleReminderSize,
isOnboarding: true isOnboarding: true
) )
.tag(4) .tag(4)
@@ -162,7 +162,7 @@ struct OnboardingContainerView: View {
) )
settingsManager.settings.launchAtLogin = launchAtLogin settingsManager.settings.launchAtLogin = launchAtLogin
settingsManager.settings.subtleReminderSizePercentage = subtleReminderSizePercentage settingsManager.settings.subtleReminderSize = subtleReminderSize
settingsManager.settings.hasCompletedOnboarding = true settingsManager.settings.hasCompletedOnboarding = true
// Apply launch at login setting // Apply launch at login setting

View File

@@ -9,7 +9,7 @@ import SwiftUI
struct SettingsOnboardingView: View { struct SettingsOnboardingView: View {
@Binding var launchAtLogin: Bool @Binding var launchAtLogin: Bool
@Binding var subtleReminderSizePercentage: Double @Binding var subtleReminderSize: ReminderSize
var isOnboarding: Bool = true var isOnboarding: Bool = true
var body: some View { var body: some View {
@@ -62,16 +62,13 @@ struct SettingsOnboardingView: View {
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
HStack { Picker("Size", selection: $subtleReminderSize) {
Slider( ForEach(ReminderSize.allCases, id: \.self) { size in
value: $subtleReminderSizePercentage, Text(size.displayName).tag(size)
in: 0.5...25, }
step: 0.5
)
Text("\(String(format: "%.1f", subtleReminderSizePercentage))%")
.frame(width: 50, alignment: .trailing)
.monospacedDigit()
} }
.pickerStyle(.segmented)
.labelsHidden()
} }
.padding() .padding()
.glassEffect(.regular, in: .rect(cornerRadius: 12)) .glassEffect(.regular, in: .rect(cornerRadius: 12))
@@ -166,7 +163,7 @@ struct SettingsOnboardingView: View {
#Preview("Settings Onboarding - Launch Disabled") { #Preview("Settings Onboarding - Launch Disabled") {
SettingsOnboardingView( SettingsOnboardingView(
launchAtLogin: .constant(false), launchAtLogin: .constant(false),
subtleReminderSizePercentage: .constant(5.0), subtleReminderSize: .constant(.large),
isOnboarding: true isOnboarding: true
) )
} }
@@ -174,7 +171,7 @@ struct SettingsOnboardingView: View {
#Preview("Settings Onboarding - Launch Enabled") { #Preview("Settings Onboarding - Launch Enabled") {
SettingsOnboardingView( SettingsOnboardingView(
launchAtLogin: .constant(true), launchAtLogin: .constant(true),
subtleReminderSizePercentage: .constant(10.0), subtleReminderSize: .constant(.medium),
isOnboarding: true isOnboarding: true
) )
} }

View File

@@ -20,7 +20,7 @@ struct BlinkReminderView: View {
private let screenWidth = NSScreen.main?.frame.width ?? 1200 private let screenWidth = NSScreen.main?.frame.width ?? 1200
private var baseSize: CGFloat { private var baseSize: CGFloat {
screenWidth * (sizePercentage / 100.0) screenWidth * (sizePercentage / 100.0) * 2.5
} }
var body: some View { var body: some View {
@@ -45,7 +45,7 @@ struct BlinkReminderView: View {
} }
.opacity(opacity) .opacity(opacity)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.padding(.top, screenHeight * 0.1) .padding(.top, screenHeight * 0.05)
.onAppear { .onAppear {
startAnimation() startAnimation()
} }
@@ -56,7 +56,7 @@ struct BlinkReminderView: View {
opacity = 1.0 opacity = 1.0
scale = 1.0 scale = 1.0
} }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
shouldShowAnimation = true shouldShowAnimation = true
} }

View File

@@ -28,7 +28,7 @@ struct PostureReminderView: View {
.opacity(opacity) .opacity(opacity)
.offset(y: yOffset) .offset(y: yOffset)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.padding(.top, screenHeight * 0.1) .padding(.top, screenHeight * 0.075)
.onAppear { .onAppear {
startAnimation() startAnimation()
} }

View File

@@ -18,7 +18,7 @@ struct SettingsWindowView: View {
@State private var postureEnabled: Bool @State private var postureEnabled: Bool
@State private var postureIntervalMinutes: Int @State private var postureIntervalMinutes: Int
@State private var launchAtLogin: Bool @State private var launchAtLogin: Bool
@State private var subtleReminderSizePercentage: Double @State private var subtleReminderSize: ReminderSize
@State private var userTimers: [UserTimer] @State private var userTimers: [UserTimer]
init(settingsManager: SettingsManager, initialTab: Int = 0) { init(settingsManager: SettingsManager, initialTab: Int = 0) {
@@ -37,8 +37,8 @@ struct SettingsWindowView: View {
_postureIntervalMinutes = State( _postureIntervalMinutes = State(
initialValue: settingsManager.settings.postureTimer.intervalSeconds / 60) initialValue: settingsManager.settings.postureTimer.intervalSeconds / 60)
_launchAtLogin = State(initialValue: settingsManager.settings.launchAtLogin) _launchAtLogin = State(initialValue: settingsManager.settings.launchAtLogin)
_subtleReminderSizePercentage = State( _subtleReminderSize = State(
initialValue: settingsManager.settings.subtleReminderSizePercentage) initialValue: settingsManager.settings.subtleReminderSize)
_userTimers = State(initialValue: settingsManager.settings.userTimers) _userTimers = State(initialValue: settingsManager.settings.userTimers)
} }
@@ -81,7 +81,7 @@ struct SettingsWindowView: View {
SettingsOnboardingView( SettingsOnboardingView(
launchAtLogin: $launchAtLogin, launchAtLogin: $launchAtLogin,
subtleReminderSizePercentage: $subtleReminderSizePercentage, subtleReminderSize: $subtleReminderSize,
isOnboarding: false isOnboarding: false
) )
.tag(4) .tag(4)
@@ -137,7 +137,7 @@ struct SettingsWindowView: View {
intervalSeconds: postureIntervalMinutes * 60 intervalSeconds: postureIntervalMinutes * 60
), ),
userTimers: userTimers, userTimers: userTimers,
subtleReminderSizePercentage: subtleReminderSizePercentage, subtleReminderSize: subtleReminderSize,
hasCompletedOnboarding: settingsManager.settings.hasCompletedOnboarding, hasCompletedOnboarding: settingsManager.settings.hasCompletedOnboarding,
launchAtLogin: launchAtLogin, launchAtLogin: launchAtLogin,
playSounds: settingsManager.settings.playSounds playSounds: settingsManager.settings.playSounds