ui cleanup

This commit is contained in:
Michael Freno
2026-01-28 14:11:57 -05:00
parent 224f6d2a68
commit 8731dc84cf
13 changed files with 226 additions and 109 deletions

View File

@@ -76,8 +76,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
} }
} }
// Note: Smart mode setup is now handled by ServiceContainer
// Keeping this method for settings change observation
private func observeSmartModeSettings() { private func observeSmartModeSettings() {
settingsManager.settingsPublisher settingsManager.settingsPublisher
.map { $0.smartMode } .map { $0.smartMode }

View File

@@ -44,6 +44,26 @@ enum AdaptiveLayout {
static let heroTitleSmall: CGFloat = 24 static let heroTitleSmall: CGFloat = 24
static let cardIcon: CGFloat = 32 static let cardIcon: CGFloat = 32
static let cardIconSmall: CGFloat = 28 static let cardIconSmall: CGFloat = 28
/// Returns a responsive font size based on available space
static func responsiveHeroIcon(for size: CGFloat) -> CGFloat {
size < 600 ? heroIconSmall : heroIcon
}
/// Returns a responsive font size based on available space
static func responsiveHeroTitle(for size: CGFloat) -> CGFloat {
size < 600 ? heroTitleSmall : heroTitle
}
/// Returns a responsive font size based on available space
static func responsiveCardIcon(for size: CGFloat) -> CGFloat {
size < 600 ? cardIconSmall : cardIcon
}
/// Returns a responsive spacing value based on available space
static func responsiveSpacing(for size: CGFloat) -> CGFloat {
size < 600 ? AdaptiveLayout.Spacing.compact : AdaptiveLayout.Spacing.standard
}
} }
/// Spacing values /// Spacing values
@@ -63,6 +83,26 @@ enum AdaptiveLayout {
static let backOffset: CGFloat = 24 static let backOffset: CGFloat = 24
static let backScale: CGFloat = 0.92 static let backScale: CGFloat = 0.92
} }
/// Returns a width that scales based on available screen size
static func responsiveWidth(
baseWidth: CGFloat,
scaleFactor: CGFloat = 1.0,
minScale: CGFloat = 0.6
) -> CGFloat {
let scaleFactor = min(max(scaleFactor, minScale), 1.0)
return baseWidth * scaleFactor
}
/// Returns a height that scales based on available screen size
static func responsiveHeight(
baseHeight: CGFloat,
scaleFactor: CGFloat = 1.0,
minScale: CGFloat = 0.6
) -> CGFloat {
let scaleFactor = min(max(scaleFactor, minScale), 1.0)
return baseHeight * scaleFactor
}
} }
/// Environment key to determine if we're in a compact layout /// Environment key to determine if we're in a compact layout

View File

@@ -14,9 +14,11 @@ class CameraAccessService: ObservableObject {
@Published var isCameraAuthorized = false @Published var isCameraAuthorized = false
@Published var cameraError: Error? @Published var cameraError: Error?
@Published var hasCameraHardware = false
private init() { private init() {
checkCameraAuthorizationStatus() checkCameraAuthorizationStatus()
checkCameraHardware()
} }
func requestCameraAccess() async throws { func requestCameraAccess() async throws {
@@ -68,6 +70,15 @@ class CameraAccessService: ObservableObject {
} }
} }
func checkCameraHardware() {
let devices = AVCaptureDevice.DiscoverySession(
deviceTypes: [.builtInWideAngleCamera],
mediaType: .video,
position: .unspecified
).devices
hasCameraHardware = !devices.isEmpty
}
// New method to check if face detection is supported and available // New method to check if face detection is supported and available
func isFaceDetectionAvailable() -> Bool { func isFaceDetectionAvailable() -> Bool {
// On macOS, face detection requires specific Vision framework support // On macOS, face detection requires specific Vision framework support

View File

@@ -50,13 +50,13 @@ final class SystemSleepManager {
observers.removeAll() observers.removeAll()
} }
private func handleSystemWillSleep() { func handleSystemWillSleep() {
logInfo("System will sleep") logInfo("System will sleep")
timerEngine?.stop() timerEngine?.stop()
settingsManager.saveImmediately() settingsManager.saveImmediately()
} }
private func handleSystemDidWake() { func handleSystemDidWake() {
logInfo("System did wake") logInfo("System did wake")
timerEngine?.start() timerEngine?.start()
} }

View File

@@ -20,8 +20,8 @@ struct SetupHeader: View {
Text(title) Text(title)
.font(.system(size: 28, weight: .bold)) .font(.system(size: 28, weight: .bold))
} }
.padding(.top, 20) .padding(.top, 15)
.padding(.bottom, 30) .padding(.bottom, 20)
} }
} }

View File

@@ -0,0 +1,19 @@
import SwiftUI
struct VisualEffectView: NSViewRepresentable {
let material: NSVisualEffectView.Material
let blendingMode: NSVisualEffectView.BlendingMode
func makeNSView(context: Context) -> NSVisualEffectView {
let view = NSVisualEffectView()
view.material = material
view.blendingMode = blendingMode
view.state = .active
return view
}
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {
nsView.material = material
nsView.blendingMode = blendingMode
}
}

View File

@@ -19,8 +19,8 @@ struct AdditionalModifiersView: View {
var body: some View { var body: some View {
GeometryReader { geometry in GeometryReader { geometry in
let availableWidth = geometry.size.width - 80 // Account for padding let availableWidth = geometry.size.width - 60 // Account for padding
let availableHeight = geometry.size.height - 200 // Account for header and nav let availableHeight = geometry.size.height - 160 // Account for header and nav
let cardWidth = min( let cardWidth = min(
max(availableWidth * 0.85, AdaptiveLayout.Card.minWidth), max(availableWidth * 0.85, AdaptiveLayout.Card.minWidth),
@@ -39,7 +39,6 @@ struct AdditionalModifiersView: View {
.font(isCompact ? .subheadline : .title3) .font(isCompact ? .subheadline : .title3)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.bottom, isCompact ? 12 : 20)
Spacer() Spacer()
@@ -209,6 +208,8 @@ struct AdditionalModifiersView: View {
.frame(width: width, height: height) .frame(width: width, height: height)
} }
@ObservedObject var cameraService = CameraAccessService.shared
private var enforceModeContent: some View { private var enforceModeContent: some View {
VStack(spacing: isCompact ? 10 : 16) { VStack(spacing: isCompact ? 10 : 16) {
Image(systemName: "video.fill") Image(systemName: "video.fill")
@@ -223,10 +224,17 @@ struct AdditionalModifiersView: View {
.font(isCompact ? .headline : .title2) .font(isCompact ? .headline : .title2)
.fontWeight(.bold) .fontWeight(.bold)
Text("Use your camera to ensure you take breaks") if !cameraService.hasCameraHardware {
.font(isCompact ? .caption : .subheadline) Text("Camera hardware not detected")
.foregroundStyle(.secondary) .font(isCompact ? .caption : .subheadline)
.multilineTextAlignment(.center) .foregroundStyle(.orange)
.multilineTextAlignment(.center)
} else {
Text("Use your camera to ensure you take breaks")
.font(isCompact ? .caption : .subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
Spacer() Spacer()
@@ -235,13 +243,20 @@ struct AdditionalModifiersView: View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Enable Enforce Mode") Text("Enable Enforce Mode")
.font(isCompact ? .subheadline : .headline) .font(isCompact ? .subheadline : .headline)
Text("Camera activates before lookaway reminders") if !cameraService.hasCameraHardware {
.font(.caption2) Text("No camera hardware detected")
.foregroundStyle(.secondary) .font(.caption2)
.foregroundStyle(.orange)
} else {
Text("Camera activates before lookaway reminders")
.font(.caption2)
.foregroundStyle(.secondary)
}
} }
Spacer() Spacer()
Toggle("", isOn: $settingsManager.settings.enforcementMode) Toggle("", isOn: $settingsManager.settings.enforcementMode)
.labelsHidden() .labelsHidden()
.disabled(!cameraService.hasCameraHardware)
.controlSize(isCompact ? .small : .regular) .controlSize(isCompact ? .small : .regular)
} }
.padding(isCompact ? 10 : 16) .padding(isCompact ? 10 : 16)
@@ -252,11 +267,15 @@ struct AdditionalModifiersView: View {
Text("Camera Access") Text("Camera Access")
.font(isCompact ? .subheadline : .headline) .font(isCompact ? .subheadline : .headline)
if CameraAccessService.shared.isCameraAuthorized { if !cameraService.hasCameraHardware {
Label("No camera", systemImage: "xmark.circle.fill")
.font(.caption2)
.foregroundStyle(.orange)
} else if cameraService.isCameraAuthorized {
Label("Authorized", systemImage: "checkmark.circle.fill") Label("Authorized", systemImage: "checkmark.circle.fill")
.font(.caption2) .font(.caption2)
.foregroundStyle(.green) .foregroundStyle(.green)
} else if let error = CameraAccessService.shared.cameraError { } else if let error = cameraService.cameraError {
Label( Label(
error.localizedDescription, error.localizedDescription,
systemImage: "exclamationmark.triangle.fill" systemImage: "exclamationmark.triangle.fill"
@@ -272,11 +291,11 @@ struct AdditionalModifiersView: View {
Spacer() Spacer()
if !CameraAccessService.shared.isCameraAuthorized { if !cameraService.isCameraAuthorized {
Button("Request Access") { Button("Request Access") {
Task { @MainActor in Task { @MainActor in
do { do {
try await CameraAccessService.shared.requestCameraAccess() try await cameraService.requestCameraAccess()
} catch { } catch {
print("Camera access failed: \(error.localizedDescription)") print("Camera access failed: \(error.localizedDescription)")
} }

View File

@@ -7,24 +7,6 @@
import SwiftUI import SwiftUI
struct VisualEffectView: NSViewRepresentable {
let material: NSVisualEffectView.Material
let blendingMode: NSVisualEffectView.BlendingMode
func makeNSView(context: Context) -> NSVisualEffectView {
let view = NSVisualEffectView()
view.material = material
view.blendingMode = blendingMode
view.state = .active
return view
}
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {
nsView.material = material
nsView.blendingMode = blendingMode
}
}
@MainActor @MainActor
final class OnboardingWindowPresenter { final class OnboardingWindowPresenter {
static let shared = OnboardingWindowPresenter() static let shared = OnboardingWindowPresenter()
@@ -78,11 +60,16 @@ final class OnboardingWindowPresenter {
} }
private func createWindow(settingsManager: SettingsManager) { private func createWindow(settingsManager: SettingsManager) {
let responsiveWidth = AdaptiveLayout.responsiveWidth(
baseWidth: AdaptiveLayout.Window.defaultWidth)
let responsiveHeight = AdaptiveLayout.responsiveHeight(
baseHeight: AdaptiveLayout.Window.defaultHeight)
let window = NSWindow( let window = NSWindow(
contentRect: NSRect( contentRect: NSRect(
x: 0, y: 0, x: 0, y: 0,
width: AdaptiveLayout.Window.defaultWidth, width: responsiveWidth,
height: AdaptiveLayout.Window.defaultHeight height: responsiveHeight
), ),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, backing: .buffered,
@@ -178,8 +165,10 @@ struct OnboardingContainerView: View {
.environment(\.isCompactLayout, isCompact) .environment(\.isCompactLayout, isCompact)
} }
.frame( .frame(
minWidth: AdaptiveLayout.Window.minWidth, minWidth: AdaptiveLayout.Window.minWidth * 0.6,
minHeight: AdaptiveLayout.Window.minHeight maxWidth: AdaptiveLayout.Window.defaultWidth * 1.1,
minHeight: AdaptiveLayout.Window.minHeight,
maxHeight: AdaptiveLayout.Window.defaultHeight * 1.1
) )
.onAppear { .onAppear {
MenuBarGuideOverlayPresenter.shared.updateVisibility(isVisible: currentPage == 1) MenuBarGuideOverlayPresenter.shared.updateVisibility(isVisible: currentPage == 1)
@@ -239,7 +228,7 @@ struct OnboardingContainerView: View {
) )
} }
.padding(.horizontal, isCompact ? 24 : 40) .padding(.horizontal, isCompact ? 24 : 40)
.padding(.bottom, isCompact ? 12 : 20) .padding(.vertical, isCompact ? 12 : 20)
} }
private func completeOnboarding() { private func completeOnboarding() {

View File

@@ -34,7 +34,12 @@ struct SettingsWindowView: View {
} }
.environment(\.isCompactLayout, isCompact) .environment(\.isCompactLayout, isCompact)
} }
.frame(minWidth: AdaptiveLayout.Window.minWidth, minHeight: AdaptiveLayout.Window.minHeight) .frame(
minWidth: AdaptiveLayout.Window.minWidth * 0.7,
maxWidth: AdaptiveLayout.Window.defaultWidth * 1.2,
minHeight: AdaptiveLayout.Window.minHeight,
maxHeight: AdaptiveLayout.Window.defaultHeight * 1.2
)
.onReceive(tabSwitchPublisher) { notification in .onReceive(tabSwitchPublisher) { notification in
if let tab = notification.object as? Int, if let tab = notification.object as? Int,
let section = SettingsSection(rawValue: tab) { let section = SettingsSection(rawValue: tab) {

View File

@@ -151,47 +151,10 @@ struct MenuBarContentView: View {
Divider() Divider()
} else { } else {
VStack(spacing: 4) { IncompleteOnboardingView(onOpenOnboarding: onOpenOnboarding)
Button(action: {
onOpenOnboarding()
}) {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.accentColor)
Text("Complete Onboarding")
Spacer()
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
}
.buttonStyle(MenuBarHoverButtonStyle())
}
.padding(.vertical, 8)
.padding(.horizontal, 8)
} }
HStack { QuitRow(onQuit: onQuit)
Button(action: onQuit) {
HStack {
Image(systemName: "power")
.foregroundStyle(.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)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
} }
.frame(width: 300) .frame(width: 300)
@@ -226,6 +189,69 @@ struct MenuBarContentView: View {
} }
} }
} }
struct IncompleteOnboardingView: View {
@State private var isHovering = false
let onOpenOnboarding: () -> Void
var body: some View {
VStack(spacing: 4) {
Button(action: {
onOpenOnboarding()
}) {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(isHovering ? .white : .accentColor)
Text("Complete Onboarding")
.foregroundStyle(isHovering ? .white : .primary)
Spacer()
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.onHover { hovering in
isHovering = hovering
}
}
.buttonStyle(MenuBarHoverButtonStyle())
}
.padding(.vertical, 8)
.padding(.horizontal, 8)
}
}
struct QuitRow: View {
@State private var isHovering = false
let onQuit: () -> Void
var body: some View {
HStack {
Button(action: onQuit) {
HStack {
Image(systemName: "power")
.foregroundStyle(isHovering ? .white : .red)
Text("Quit Gaze")
.foregroundStyle(isHovering ? .white : .primary)
Spacer()
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.onHover { hovering in
isHovering = hovering
}
}
.buttonStyle(MenuBarHoverButtonStyle())
.padding(.vertical, 8)
Spacer()
Text(
"v\(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0")"
)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
}
}
struct TimerStatusRowWithIndividualControls: View { struct TimerStatusRowWithIndividualControls: View {
let identifier: TimerIdentifier let identifier: TimerIdentifier

View File

@@ -25,6 +25,10 @@ struct EnforceModeSetupView: View {
@State private var showCalibrationWindow = false @State private var showCalibrationWindow = false
@ObservedObject var calibrationManager = CalibrationManager.shared @ObservedObject var calibrationManager = CalibrationManager.shared
private var cameraHardwareAvailable: Bool {
cameraService.hasCameraHardware
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
SetupHeader(icon: "video.fill", title: "Enforce Mode", color: .accentColor) SetupHeader(icon: "video.fill", title: "Enforce Mode", color: .accentColor)
@@ -42,9 +46,15 @@ struct EnforceModeSetupView: View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Enable Enforce Mode") Text("Enable Enforce Mode")
.font(isCompact ? .subheadline : .headline) .font(isCompact ? .subheadline : .headline)
Text("Camera activates 3 seconds before lookaway reminders") if !cameraHardwareAvailable {
.font(.caption2) Text("No camera hardware detected")
.foregroundStyle(.secondary) .font(.caption2)
.foregroundStyle(.orange)
} else {
Text("Camera activates 3 seconds before lookaway reminders")
.font(.caption2)
.foregroundStyle(.secondary)
}
} }
Spacer() Spacer()
Toggle( Toggle(
@@ -65,7 +75,7 @@ struct EnforceModeSetupView: View {
) )
) )
.labelsHidden() .labelsHidden()
.disabled(isProcessingToggle) .disabled(isProcessingToggle || !cameraHardwareAvailable)
.controlSize(isCompact ? .small : .regular) .controlSize(isCompact ? .small : .regular)
} }
.padding(isCompact ? 10 : 16) .padding(isCompact ? 10 : 16)
@@ -378,6 +388,11 @@ struct EnforceModeSetupView: View {
defer { isProcessingToggle = false } defer { isProcessingToggle = false }
if enabled { if enabled {
guard cameraHardwareAvailable else {
print("⚠️ Cannot enable enforce mode - no camera hardware")
settingsManager.settings.enforcementMode = false
return
}
print("🎛️ Enabling enforce mode...") print("🎛️ Enabling enforce mode...")
await enforceModeService.enableEnforceMode() await enforceModeService.enableEnforceMode()
print("🎛️ Enforce mode enabled: \(enforceModeService.isEnforceModeEnabled)") print("🎛️ Enforce mode enabled: \(enforceModeService.isEnforceModeEnabled)")

View File

@@ -22,7 +22,8 @@ struct LookAwaySetupView: View {
VStack(spacing: 30) { VStack(spacing: 30) {
InfoBox( InfoBox(
text: "Suggested: 20-20-20 rule", text: "Suggested: 20-20-20 rule",
url: "https://journals.co.za/doi/abs/10.4102/aveh.v79i1.554#:~:text=the 20/20/20 rule induces significant changes in dry eye symptoms and tear film and some limited changes for ocular surface integrity." url:
"https://journals.co.za/doi/abs/10.4102/aveh.v79i1.554#:~:text=the 20/20/20 rule induces significant changes in dry eye symptoms and tear film and some limited changes for ocular surface integrity."
) )
SliderSection( SliderSection(
@@ -34,7 +35,8 @@ struct LookAwaySetupView: View {
) )
}, },
set: { newValue in set: { newValue in
settingsManager.settings.lookAwayTimer.intervalSeconds = (newValue.val ?? 20) * 60 settingsManager.settings.lookAwayTimer.intervalSeconds =
(newValue.val ?? 20) * 60
} }
), ),
countdownSettings: Binding( countdownSettings: Binding(
@@ -52,20 +54,6 @@ struct LookAwaySetupView: View {
type: "Look away", type: "Look away",
previewFunc: showPreviewWindow previewFunc: showPreviewWindow
) )
Toggle("Enable enforcement mode", isOn: $settingsManager.settings.enforcementMode)
.onChange(of: settingsManager.settings.enforcementMode) { _, newMode in
if newMode && !cameraAccess.isCameraAuthorized {
Task {
do {
try await cameraAccess.requestCameraAccess()
} catch {
failedCameraAccess = true
settingsManager.settings.enforcementMode = false
}
}
}
}
} }
Spacer() Spacer()

View File

@@ -15,18 +15,25 @@ final class TimerEngineTests: XCTestCase {
var testEnv: TestEnvironment! var testEnv: TestEnvironment!
var timerEngine: TimerEngine! var timerEngine: TimerEngine!
var systemSleepManager: SystemSleepManager!
var cancellables: Set<AnyCancellable>! var cancellables: Set<AnyCancellable>!
override func setUp() async throws { override func setUp() async throws {
testEnv = TestEnvironment(settings: .defaults) testEnv = TestEnvironment(settings: .defaults)
timerEngine = testEnv.container.timerEngine timerEngine = testEnv.container.timerEngine
systemSleepManager = SystemSleepManager(
timerEngine: timerEngine,
settingsManager: testEnv.settingsManager
)
cancellables = [] cancellables = []
} }
override func tearDown() async throws { override func tearDown() async throws {
timerEngine?.stop() timerEngine?.stop()
systemSleepManager?.stopObserving()
cancellables = nil cancellables = nil
timerEngine = nil timerEngine = nil
systemSleepManager = nil
testEnv = nil testEnv = nil
} }
@@ -252,20 +259,20 @@ final class TimerEngineTests: XCTestCase {
// MARK: - System Sleep/Wake Tests // MARK: - System Sleep/Wake Tests
func testHandleSystemSleep() { func testSystemSleepManagerHandlesSleep() {
timerEngine.start() timerEngine.start()
let statesBefore = timerEngine.timerStates.count let statesBefore = timerEngine.timerStates.count
timerEngine.handleSystemSleep() systemSleepManager.handleSystemWillSleep()
// States should still exist // States should still exist
XCTAssertEqual(timerEngine.timerStates.count, statesBefore) XCTAssertEqual(timerEngine.timerStates.count, statesBefore)
} }
func testHandleSystemWake() { func testSystemSleepManagerHandlesWake() {
timerEngine.start() timerEngine.start()
timerEngine.handleSystemSleep() systemSleepManager.handleSystemWillSleep()
timerEngine.handleSystemWake() systemSleepManager.handleSystemDidWake()
// Should handle wake event without crashing // Should handle wake event without crashing
XCTAssertGreaterThan(timerEngine.timerStates.count, 0) XCTAssertGreaterThan(timerEngine.timerStates.count, 0)