consolidation

This commit is contained in:
Michael Freno
2026-01-30 13:52:25 -05:00
parent b725f9cfd7
commit 1e20283afc
7 changed files with 886 additions and 964 deletions

View File

@@ -0,0 +1,16 @@
//
// SetupPresentation.swift
// Gaze
//
// Created by Mike Freno on 1/30/26.
//
import Foundation
enum SetupPresentation {
case window
case card
var isWindow: Bool { self == .window }
var isCard: Bool { self == .card }
}

View File

@@ -0,0 +1,552 @@
//
// EnforceModeSetupContent.swift
// Gaze
//
// Created by Mike Freno on 1/30/26.
//
import AVFoundation
import SwiftUI
struct EnforceModeSetupContent: View {
@Bindable var settingsManager: SettingsManager
@ObservedObject var cameraService = CameraAccessService.shared
@ObservedObject var eyeTrackingService = EyeTrackingService.shared
@ObservedObject var enforceModeService = EnforceModeService.shared
@ObservedObject var calibratorService = CalibratorService.shared
@Environment(\.isCompactLayout) private var isCompact
let presentation: SetupPresentation
@Binding var isTestModeActive: Bool
@Binding var cachedPreviewLayer: AVCaptureVideoPreviewLayer?
@Binding var showAdvancedSettings: Bool
@Binding var showCalibrationWindow: Bool
@Binding var isViewActive: Bool
let isProcessingToggle: Bool
let handleEnforceModeToggle: (Bool) -> Void
private var cameraHardwareAvailable: Bool {
cameraService.hasCameraHardware
}
private var sectionCornerRadius: CGFloat {
presentation.isCard ? 10 : 12
}
private var sectionPadding: CGFloat {
presentation.isCard ? 10 : 16
}
private var headerFont: Font {
presentation.isCard ? .subheadline : .headline
}
private var iconSize: CGFloat {
presentation.isCard ? AdaptiveLayout.Font.cardIconSmall : AdaptiveLayout.Font.cardIcon
}
var body: some View {
VStack(spacing: presentation.isCard ? 10 : 24) {
if presentation.isCard {
Image(systemName: "video.fill")
.font(.system(size: iconSize))
.foregroundStyle(Color.accentColor)
Text("Enforce Mode")
.font(.title2)
.fontWeight(.bold)
}
Text("Use your camera to ensure you take breaks")
.font(presentation.isCard ? .subheadline : (isCompact ? .subheadline : .title3))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if presentation.isCard {
Spacer(minLength: 0)
}
VStack(spacing: presentation.isCard ? 10 : 20) {
enforceModeToggleView
cameraStatusView
if enforceModeService.isEnforceModeEnabled {
testModeButton
}
if isTestModeActive && enforceModeService.isCameraActive {
testModePreviewView
trackingConstantsView
} else if enforceModeService.isCameraActive && !isTestModeActive {
eyeTrackingStatusView
trackingConstantsView
}
privacyInfoView
}
if presentation.isCard {
Spacer(minLength: 0)
}
}
.sheet(isPresented: $showCalibrationWindow) {
EyeTrackingCalibrationView()
}
}
private var testModeButton: some View {
Button(action: {
Task { @MainActor in
if isTestModeActive {
enforceModeService.stopTestMode()
isTestModeActive = false
cachedPreviewLayer = nil
} else {
await enforceModeService.startTestMode()
isTestModeActive = enforceModeService.isCameraActive
if isTestModeActive {
cachedPreviewLayer = eyeTrackingService.previewLayer
}
}
}
}) {
HStack {
Image(systemName: isTestModeActive ? "stop.circle.fill" : "play.circle.fill")
.font(.title3)
Text(isTestModeActive ? "Stop Test" : "Test Tracking")
.font(.headline)
}
.frame(maxWidth: .infinity)
.padding()
}
.buttonStyle(.borderedProminent)
.controlSize(presentation.isCard ? .regular : .large)
}
private var calibrationSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "target")
.font(.title3)
.foregroundStyle(.blue)
Text("Eye Tracking Calibration")
.font(.headline)
}
if calibratorService.calibrationData.isComplete {
VStack(alignment: .leading, spacing: 8) {
Text(calibratorService.getCalibrationSummary())
.font(.caption)
.foregroundStyle(.secondary)
if calibratorService.needsRecalibration() {
Label(
"Calibration expired - recalibration recommended",
systemImage: "exclamationmark.triangle.fill"
)
.font(.caption)
.foregroundStyle(.orange)
} else {
Label("Calibration active and valid", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
}
} else {
Text("Not calibrated - using default thresholds")
.font(.caption)
.foregroundStyle(.secondary)
}
Button(action: {
showCalibrationWindow = true
}) {
HStack {
Image(systemName: "target")
Text(
calibratorService.calibrationData.isComplete
? "Recalibrate" : "Run Calibration")
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
.buttonStyle(.bordered)
.controlSize(.regular)
}
.padding(sectionPadding)
.glassEffectIfAvailable(
GlassStyle.regular.tint(.blue.opacity(0.1)), in: .rect(cornerRadius: sectionCornerRadius)
)
}
private var testModePreviewView: some View {
VStack(spacing: 16) {
let lookingAway = !eyeTrackingService.userLookingAtScreen
let borderColor: NSColor = lookingAway ? .systemGreen : .systemRed
let previewLayer = eyeTrackingService.previewLayer ?? cachedPreviewLayer
if let layer = previewLayer {
ZStack {
CameraPreviewView(previewLayer: layer, borderColor: borderColor)
PupilOverlayView(eyeTrackingService: eyeTrackingService)
VStack {
HStack {
Spacer()
GazeOverlayView(eyeTrackingService: eyeTrackingService)
}
Spacer()
}
}
.frame(height: presentation.isCard ? 180 : (isCompact ? 200 : 300))
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius))
.onAppear {
if cachedPreviewLayer == nil {
cachedPreviewLayer = eyeTrackingService.previewLayer
}
}
}
}
}
private var cameraStatusView: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Camera Access")
.font(headerFont)
if cameraService.isCameraAuthorized {
Label("Authorized", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
} else if let error = cameraService.cameraError {
Label(error.localizedDescription, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
} else {
Label("Not authorized", systemImage: "xmark.circle.fill")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if !cameraService.isCameraAuthorized {
Button("Request Access") {
Task { @MainActor in
do {
try await cameraService.requestCameraAccess()
} catch {
print("⚠️ Camera access failed: \(error.localizedDescription)")
}
}
}
.buttonStyle(.bordered)
.controlSize(presentation.isCard ? .small : .regular)
}
}
.padding(sectionPadding)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius))
}
private var eyeTrackingStatusView: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Eye Tracking Status")
.font(headerFont)
HStack(spacing: 20) {
statusIndicator(
title: "Face Detected",
isActive: eyeTrackingService.faceDetected,
icon: "person.fill"
)
statusIndicator(
title: "Looking Away",
isActive: !eyeTrackingService.userLookingAtScreen,
icon: "arrow.turn.up.right"
)
}
}
.padding(sectionPadding)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius))
}
private func statusIndicator(title: String, isActive: Bool, icon: String) -> some View {
VStack(spacing: 8) {
Image(systemName: icon)
.font(.title2)
.foregroundStyle(isActive ? .green : .secondary)
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
}
private var privacyInfoView: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "lock.shield.fill")
.font(.title3)
.foregroundStyle(.blue)
Text("Privacy Information")
.font(headerFont)
}
VStack(alignment: .leading, spacing: 8) {
privacyBullet("All processing happens on-device")
privacyBullet("No images are stored or transmitted")
privacyBullet("Camera only active during lookaway reminders (3 second window)")
privacyBullet("You can always force quit with cmd+q")
}
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(sectionPadding)
.glassEffectIfAvailable(
GlassStyle.regular.tint(.blue.opacity(0.1)), in: .rect(cornerRadius: sectionCornerRadius)
)
}
private func privacyBullet(_ text: String) -> some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "checkmark")
.font(.caption2)
.foregroundStyle(.blue)
Text(text)
}
}
private var enforceModeToggleView: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Enable Enforce Mode")
.font(headerFont)
if !cameraHardwareAvailable {
Text("No camera hardware detected")
.font(.caption2)
.foregroundStyle(.orange)
} else {
Text("Camera activates 3 seconds before lookaway reminders")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer()
Toggle(
"",
isOn: Binding(
get: {
settingsManager.isTimerEnabled(for: .lookAway)
|| settingsManager.isTimerEnabled(for: .blink)
|| settingsManager.isTimerEnabled(for: .posture)
},
set: { newValue in
guard !isProcessingToggle else { return }
handleEnforceModeToggle(newValue)
}
)
)
.labelsHidden()
.disabled(isProcessingToggle || !cameraHardwareAvailable)
.controlSize(presentation.isCard ? .small : (isCompact ? .small : .regular))
}
.padding(sectionPadding)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius))
}
private var trackingConstantsView: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Tracking Sensitivity")
.font(headerFont)
Spacer()
Button(action: {
eyeTrackingService.enableDebugLogging.toggle()
}) {
Image(
systemName: eyeTrackingService.enableDebugLogging
? "ant.circle.fill" : "ant.circle"
)
.foregroundStyle(eyeTrackingService.enableDebugLogging ? .orange : .secondary)
}
.buttonStyle(.plain)
.help("Toggle console debug logging")
Button(showAdvancedSettings ? "Hide Settings" : "Show Settings") {
withAnimation {
showAdvancedSettings.toggle()
}
}
.buttonStyle(.bordered)
.controlSize(.small)
}
VStack(alignment: .leading, spacing: 8) {
Text("Live Values:")
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
if let leftRatio = eyeTrackingService.debugLeftPupilRatio,
let rightRatio = eyeTrackingService.debugRightPupilRatio
{
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 2) {
Text("Left Pupil: \(String(format: "%.3f", leftRatio))")
.font(.caption2)
.foregroundStyle(
!EyeTrackingConstants.minPupilEnabled
&& !EyeTrackingConstants.maxPupilEnabled
? .secondary
: (leftRatio < EyeTrackingConstants.minPupilRatio
|| leftRatio > EyeTrackingConstants.maxPupilRatio)
? Color.orange : Color.green
)
Text("Right Pupil: \(String(format: "%.3f", rightRatio))")
.font(.caption2)
.foregroundStyle(
!EyeTrackingConstants.minPupilEnabled
&& !EyeTrackingConstants.maxPupilEnabled
? .secondary
: (rightRatio < EyeTrackingConstants.minPupilRatio
|| rightRatio > EyeTrackingConstants.maxPupilRatio)
? Color.orange : Color.green
)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(
"Range: \(String(format: "%.2f", EyeTrackingConstants.minPupilRatio)) - \(String(format: "%.2f", EyeTrackingConstants.maxPupilRatio))"
)
.font(.caption2)
.foregroundStyle(.secondary)
let bothEyesOut =
(leftRatio < EyeTrackingConstants.minPupilRatio
|| leftRatio > EyeTrackingConstants.maxPupilRatio)
&& (rightRatio < EyeTrackingConstants.minPupilRatio
|| rightRatio > EyeTrackingConstants.maxPupilRatio)
Text(bothEyesOut ? "Both Out ⚠️" : "In Range ✓")
.font(.caption2)
.foregroundStyle(bothEyesOut ? .orange : .green)
}
}
} else {
Text("Pupil data unavailable")
.font(.caption2)
.foregroundStyle(.secondary)
}
if let yaw = eyeTrackingService.debugYaw,
let pitch = eyeTrackingService.debugPitch
{
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 2) {
Text("Yaw: \(String(format: "%.3f", yaw))")
.font(.caption2)
.foregroundStyle(
!EyeTrackingConstants.yawEnabled
? .secondary
: abs(yaw) > EyeTrackingConstants.yawThreshold
? Color.orange : Color.green
)
Text("Pitch: \(String(format: "%.3f", pitch))")
.font(.caption2)
.foregroundStyle(
!EyeTrackingConstants.pitchUpEnabled
&& !EyeTrackingConstants.pitchDownEnabled
? .secondary
: (pitch > EyeTrackingConstants.pitchUpThreshold
|| pitch < EyeTrackingConstants.pitchDownThreshold)
? Color.orange : Color.green
)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(
"Yaw Max: \(String(format: "%.2f", EyeTrackingConstants.yawThreshold))"
)
.font(.caption2)
.foregroundStyle(.secondary)
Text(
"Pitch: \(String(format: "%.2f", EyeTrackingConstants.pitchDownThreshold)) to \(String(format: "%.2f", EyeTrackingConstants.pitchUpThreshold))"
)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
.padding(.top, 4)
if showAdvancedSettings {
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("Current Threshold Values:")
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
HStack {
Text("Yaw Threshold:")
Spacer()
Text("\(String(format: "%.2f", EyeTrackingConstants.yawThreshold)) rad")
.foregroundStyle(.secondary)
}
HStack {
Text("Pitch Up Threshold:")
Spacer()
Text(
"\(String(format: "%.2f", EyeTrackingConstants.pitchUpThreshold)) rad"
)
.foregroundStyle(.secondary)
}
HStack {
Text("Pitch Down Threshold:")
Spacer()
Text(
"\(String(format: "%.2f", EyeTrackingConstants.pitchDownThreshold)) rad"
)
.foregroundStyle(.secondary)
}
HStack {
Text("Min Pupil Ratio:")
Spacer()
Text("\(String(format: "%.2f", EyeTrackingConstants.minPupilRatio))")
.foregroundStyle(.secondary)
}
HStack {
Text("Max Pupil Ratio:")
Spacer()
Text("\(String(format: "%.2f", EyeTrackingConstants.maxPupilRatio))")
.foregroundStyle(.secondary)
}
HStack {
Text("Eye Closed Threshold:")
Spacer()
Text(
"\(String(format: "%.3f", EyeTrackingConstants.eyeClosedThreshold))"
)
.foregroundStyle(.secondary)
}
}
.padding(.top, 8)
}
.padding(.top, 8)
}
}
.padding(sectionPadding)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius))
}
}

View File

@@ -0,0 +1,201 @@
//
// SmartModeSetupContent.swift
// Gaze
//
// Created by Mike Freno on 1/30/26.
//
import SwiftUI
struct SmartModeSetupContent: View {
@Bindable var settingsManager: SettingsManager
@State private var permissionManager = ScreenCapturePermissionManager.shared
let presentation: SetupPresentation
private var iconSize: CGFloat {
presentation.isCard ? AdaptiveLayout.Font.cardIconSmall : AdaptiveLayout.Font.cardIcon
}
private var sectionCornerRadius: CGFloat {
presentation.isCard ? 10 : 12
}
private var sectionPadding: CGFloat {
presentation.isCard ? 10 : 16
}
private var sectionSpacing: CGFloat {
presentation.isCard ? 8 : 12
}
var body: some View {
VStack(spacing: presentation.isCard ? 10 : 24) {
if presentation.isCard {
Image(systemName: "brain.fill")
.font(.system(size: iconSize))
.foregroundStyle(.purple)
Text("Smart Mode")
.font(.title2)
.fontWeight(.bold)
}
Text("Automatically manage timers based on your activity")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if presentation.isCard {
Spacer(minLength: 0)
}
VStack(spacing: sectionSpacing) {
fullscreenSection
idleSection
#if DEBUG
usageTrackingSection
#endif
}
.frame(maxWidth: presentation.isCard ? .infinity : 600)
if presentation.isCard {
Spacer(minLength: 0)
}
}
}
private var fullscreenSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "arrow.up.left.and.arrow.down.right")
.foregroundStyle(.blue)
Text("Auto-pause on Fullscreen")
.font(presentation.isCard ? .subheadline : .headline)
}
Text("Timers will automatically pause when you enter fullscreen mode (videos, games, presentations)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Toggle("", isOn: $settingsManager.settings.smartMode.autoPauseOnFullscreen)
.labelsHidden()
.controlSize(presentation.isCard ? .small : .regular)
.onChange(of: settingsManager.settings.smartMode.autoPauseOnFullscreen) { _, newValue in
if newValue {
permissionManager.requestAuthorizationIfNeeded()
}
}
}
if settingsManager.settings.smartMode.autoPauseOnFullscreen,
permissionManager.authorizationStatus != .authorized
{
permissionWarningView
}
}
.padding(sectionPadding)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius))
}
private var permissionWarningView: some View {
VStack(alignment: .leading, spacing: 8) {
Label(
permissionManager.authorizationStatus == .denied
? "Screen Recording permission required"
: "Grant Screen Recording access",
systemImage: "exclamationmark.shield"
)
.foregroundStyle(.orange)
Text("macOS requires Screen Recording permission to detect other apps in fullscreen.")
.font(.caption)
.foregroundStyle(.secondary)
HStack {
Button("Grant Access") {
permissionManager.requestAuthorizationIfNeeded()
permissionManager.openSystemSettings()
}
.buttonStyle(.bordered)
.controlSize(presentation.isCard ? .small : .regular)
Button("Open Settings") {
permissionManager.openSystemSettings()
}
.buttonStyle(.borderless)
}
.font(.caption)
.padding(.top, 4)
}
.padding(.top, 8)
}
private var idleSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "moon.zzz.fill")
.foregroundStyle(.indigo)
Text("Auto-pause on Idle")
.font(presentation.isCard ? .subheadline : .headline)
}
Text("Timers will pause when you're inactive for more than the threshold below")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Toggle("", isOn: $settingsManager.settings.smartMode.autoPauseOnIdle)
.labelsHidden()
.controlSize(presentation.isCard ? .small : .regular)
}
if settingsManager.settings.smartMode.autoPauseOnIdle {
ThresholdSlider(
label: "Idle Threshold:",
value: $settingsManager.settings.smartMode.idleThresholdMinutes,
range: 1...30,
unit: "min"
)
}
}
.padding(sectionPadding)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius))
}
private var usageTrackingSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "chart.line.uptrend.xyaxis")
.foregroundStyle(.green)
Text("Track Usage Statistics")
.font(presentation.isCard ? .subheadline : .headline)
}
Text("Monitor active and idle time, with automatic reset after the specified duration")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Toggle("", isOn: $settingsManager.settings.smartMode.trackUsage)
.labelsHidden()
.controlSize(presentation.isCard ? .small : .regular)
}
if settingsManager.settings.smartMode.trackUsage {
ThresholdSlider(
label: "Reset After:",
value: $settingsManager.settings.smartMode.usageResetAfterMinutes,
range: 15...240,
step: 15,
unit: "min"
)
}
}
.padding(sectionPadding)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius))
}
}

View File

@@ -0,0 +1,39 @@
//
// ThresholdSlider.swift
// Gaze
//
// Created by Mike Freno on 1/30/26.
//
import SwiftUI
struct ThresholdSlider: View {
let label: String
@Binding var value: Int
let range: ClosedRange<Int>
var step: Int = 1
let unit: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(label)
.font(.subheadline)
Spacer()
Text("\(value) \(unit)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Slider(
value: Binding(
get: { Double(value) },
set: { value = Int($0) }
),
in: Double(range.lowerBound)...Double(range.upperBound),
step: Double(step)
)
}
.padding(.top, 8)
}
}

View File

@@ -5,6 +5,7 @@
// Created by Mike Freno on 1/18/26. // Created by Mike Freno on 1/18/26.
// //
import AVFoundation
import SwiftUI import SwiftUI
struct AdditionalModifiersView: View { struct AdditionalModifiersView: View {
@@ -12,6 +13,13 @@ struct AdditionalModifiersView: View {
@State private var frontCardIndex: Int = 0 @State private var frontCardIndex: Int = 0
@State private var dragOffset: CGFloat = 0 @State private var dragOffset: CGFloat = 0
@State private var isDragging: Bool = false @State private var isDragging: Bool = false
@State private var isTestModeActive = false
@State private var cachedPreviewLayer: AVCaptureVideoPreviewLayer?
@State private var showAdvancedSettings = false
@State private var showCalibrationWindow = false
@State private var isViewActive = false
@State private var isProcessingToggle = false
@ObservedObject var cameraService = CameraAccessService.shared
@Environment(\.isCompactLayout) private var isCompact @Environment(\.isCompactLayout) private var isCompact
private var backCardOffset: CGFloat { isCompact ? 20 : AdaptiveLayout.Card.backOffset } private var backCardOffset: CGFloat { isCompact ? 20 : AdaptiveLayout.Card.backOffset }
@@ -50,15 +58,40 @@ struct AdditionalModifiersView: View {
ZStack { ZStack {
#if DEBUG #if DEBUG
cardView(for: 0, width: cardWidth, height: cardHeight) setupCard(
.zIndex(zIndex(for: 0)) presentation: .card,
.scaleEffect(scale(for: 0)) content: EnforceModeSetupContent(
.offset(x: xOffset(for: 0), y: yOffset(for: 0)) settingsManager: settingsManager,
presentation: .card,
isTestModeActive: $isTestModeActive,
cachedPreviewLayer: $cachedPreviewLayer,
showAdvancedSettings: $showAdvancedSettings,
showCalibrationWindow: $showCalibrationWindow,
isViewActive: $isViewActive,
isProcessingToggle: isProcessingToggle,
handleEnforceModeToggle: { enabled in
if enabled {
Task { @MainActor in
try await cameraService.requestCameraAccess()
}
}
}
),
width: cardWidth,
height: cardHeight,
index: 0
)
#endif #endif
cardView(for: 1, width: cardWidth, height: cardHeight) setupCard(
.zIndex(zIndex(for: 1)) presentation: .card,
.scaleEffect(scale(for: 1)) content: SmartModeSetupContent(
.offset(x: xOffset(for: 1), y: yOffset(for: 1)) settingsManager: settingsManager,
presentation: .card
),
width: cardWidth,
height: cardHeight,
index: 1
)
} }
.padding(isCompact ? 12 : 20) .padding(isCompact ? 12 : 20)
.gesture(dragGesture) .gesture(dragGesture)
@@ -198,226 +231,26 @@ struct AdditionalModifiersView: View {
// MARK: - Card Views // MARK: - Card Views
@ViewBuilder @ViewBuilder
private func cardView(for index: Int, width: CGFloat, height: CGFloat) -> some View { private func setupCard(
presentation: SetupPresentation,
content: some View,
width: CGFloat,
height: CGFloat,
index: Int
) -> some View {
ZStack { ZStack {
RoundedRectangle(cornerRadius: 16) RoundedRectangle(cornerRadius: 16)
.fill(Color(NSColor.windowBackgroundColor)) .fill(Color(NSColor.windowBackgroundColor))
.shadow(color: Color.black.opacity(0.2), radius: 10, x: 0, y: 4) .shadow(color: Color.black.opacity(0.2), radius: 10, x: 0, y: 4)
Group { content
if index == 0 {
enforceModeContent
} else {
smartModeContent
}
}
.padding(isCompact ? 12 : 20) .padding(isCompact ? 12 : 20)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
} }
.frame(width: width, height: height) .frame(width: width, height: height)
} .zIndex(zIndex(for: index))
.scaleEffect(scale(for: index))
@ObservedObject var cameraService = CameraAccessService.shared .offset(x: xOffset(for: index), y: yOffset(for: index))
private var enforceModeContent: some View {
VStack(spacing: isCompact ? 10 : 16) {
Image(systemName: "video.fill")
.font(
.system(
size: isCompact
? AdaptiveLayout.Font.cardIconSmall : AdaptiveLayout.Font.cardIcon)
)
.foregroundStyle(Color.accentColor)
Text("Enforce Mode")
.font(isCompact ? .headline : .title2)
.fontWeight(.bold)
if !cameraService.hasCameraHardware {
Text("Camera hardware not detected")
.font(isCompact ? .caption : .subheadline)
.foregroundStyle(.orange)
.multilineTextAlignment(.center)
} else {
Text("Use your camera to ensure you take breaks")
.font(isCompact ? .caption : .subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
Spacer()
VStack(spacing: isCompact ? 10 : 16) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Enable Enforce Mode")
.font(isCompact ? .subheadline : .headline)
if !cameraService.hasCameraHardware {
Text("No camera hardware detected")
.font(.caption2)
.foregroundStyle(.orange)
} else {
Text("Camera activates before lookaway reminders")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer()
Toggle(
"",
isOn: Binding(
get: {
settingsManager.isTimerEnabled(for: .lookAway)
|| settingsManager.isTimerEnabled(for: .blink)
|| settingsManager.isTimerEnabled(for: .posture)
},
set: { newValue in
if newValue {
Task { @MainActor in
try await cameraService.requestCameraAccess()
}
}
}
)
)
.labelsHidden()
.disabled(!cameraService.hasCameraHardware)
.controlSize(isCompact ? .small : .regular)
}
.padding(isCompact ? 10 : 16)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Camera Access")
.font(isCompact ? .subheadline : .headline)
if !cameraService.hasCameraHardware {
Label("No camera", systemImage: "xmark.circle.fill")
.font(.caption2)
.foregroundStyle(.orange)
} else if cameraService.isCameraAuthorized {
Label("Authorized", systemImage: "checkmark.circle.fill")
.font(.caption2)
.foregroundStyle(.green)
} else if let error = cameraService.cameraError {
Label(
error.localizedDescription,
systemImage: "exclamationmark.triangle.fill"
)
.font(.caption2)
.foregroundStyle(.orange)
} else {
Label("Not authorized", systemImage: "xmark.circle.fill")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer()
if !cameraService.isCameraAuthorized {
Button("Request Access") {
Task { @MainActor in
do {
try await cameraService.requestCameraAccess()
} catch {
print("Camera access failed: \(error.localizedDescription)")
}
}
}
.buttonStyle(.bordered)
.controlSize(isCompact ? .small : .regular)
}
}
.padding(isCompact ? 10 : 16)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
Spacer()
}
}
private var smartModeContent: some View {
VStack(spacing: isCompact ? 10 : 16) {
Image(systemName: "brain.fill")
.font(
.system(
size: isCompact
? AdaptiveLayout.Font.cardIconSmall : AdaptiveLayout.Font.cardIcon)
)
.foregroundStyle(.purple)
Text("Smart Mode")
.font(isCompact ? .headline : .title2)
.fontWeight(.bold)
Text("Automatically manage timers based on activity")
.font(isCompact ? .caption : .subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Spacer()
VStack(spacing: isCompact ? 8 : 12) {
smartModeToggle(
icon: "arrow.up.left.and.arrow.down.right",
iconColor: .blue,
title: "Auto-pause on Fullscreen",
subtitle: "Pause during videos, games, presentations",
isOn: $settingsManager.settings.smartMode.autoPauseOnFullscreen
)
smartModeToggle(
icon: "moon.zzz.fill",
iconColor: .indigo,
title: "Auto-pause on Idle",
subtitle: "Pause when you're inactive",
isOn: $settingsManager.settings.smartMode.autoPauseOnIdle
)
#if DEBUG
smartModeToggle(
icon: "chart.line.uptrend.xyaxis",
iconColor: .green,
title: "Track Usage Statistics",
subtitle: "Monitor active and idle time",
isOn: $settingsManager.settings.smartMode.trackUsage
)
#endif
}
Spacer()
}
}
@ViewBuilder
private func smartModeToggle(
icon: String, iconColor: Color, title: String, subtitle: String, isOn: Binding<Bool>
) -> some View {
HStack {
Image(systemName: icon)
.foregroundStyle(iconColor)
.frame(width: isCompact ? 20 : 24)
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(isCompact ? .caption : .subheadline)
.fontWeight(.medium)
Text(subtitle)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
Toggle("", isOn: isOn)
.labelsHidden()
.controlSize(.small)
}
.padding(.horizontal, isCompact ? 8 : 12)
.padding(.vertical, isCompact ? 6 : 10)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 10))
} }
// MARK: - Gestures & Navigation // MARK: - Gestures & Navigation

View File

@@ -6,15 +6,12 @@
// //
import AVFoundation import AVFoundation
import Foundation
import SwiftUI import SwiftUI
struct EnforceModeSetupView: View { struct EnforceModeSetupView: View {
@Bindable var settingsManager: SettingsManager @Bindable var settingsManager: SettingsManager
@ObservedObject var cameraService = CameraAccessService.shared @ObservedObject var cameraService = CameraAccessService.shared
@ObservedObject var eyeTrackingService = EyeTrackingService.shared
@ObservedObject var enforceModeService = EnforceModeService.shared @ObservedObject var enforceModeService = EnforceModeService.shared
@Environment(\.isCompactLayout) private var isCompact
@State private var isProcessingToggle = false @State private var isProcessingToggle = false
@State private var isTestModeActive = false @State private var isTestModeActive = false
@@ -23,7 +20,6 @@ struct EnforceModeSetupView: View {
@State private var isViewActive = false @State private var isViewActive = false
@State private var showAdvancedSettings = false @State private var showAdvancedSettings = false
@State private var showCalibrationWindow = false @State private var showCalibrationWindow = false
@ObservedObject var calibratorService = CalibratorService.shared
private var cameraHardwareAvailable: Bool { private var cameraHardwareAvailable: Bool {
cameraService.hasCameraHardware cameraService.hasCameraHardware
@@ -33,72 +29,27 @@ struct EnforceModeSetupView: 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)
Spacer() EnforceModeSetupContent(
settingsManager: settingsManager,
VStack(spacing: isCompact ? 16 : 30) { presentation: .window,
Text("Use your camera to ensure you take breaks") isTestModeActive: $isTestModeActive,
.font(isCompact ? .subheadline : .title3) cachedPreviewLayer: $cachedPreviewLayer,
.foregroundStyle(.secondary) showAdvancedSettings: $showAdvancedSettings,
.multilineTextAlignment(.center) showCalibrationWindow: $showCalibrationWindow,
isViewActive: $isViewActive,
VStack(spacing: isCompact ? 12 : 20) { isProcessingToggle: isProcessingToggle,
HStack { handleEnforceModeToggle: { enabled in
VStack(alignment: .leading, spacing: 2) { print("🎛️ Toggle changed to: \(enabled)")
Text("Enable Enforce Mode")
.font(isCompact ? .subheadline : .headline)
if !cameraHardwareAvailable {
Text("No camera hardware detected")
.font(.caption2)
.foregroundStyle(.orange)
} else {
Text("Camera activates 3 seconds before lookaway reminders")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer()
Toggle(
"",
isOn: Binding(
get: {
settingsManager.isTimerEnabled(for: .lookAway) ||
settingsManager.isTimerEnabled(for: .blink) ||
settingsManager.isTimerEnabled(for: .posture)
},
set: { newValue in
print("🎛️ Toggle changed to: \(newValue)")
guard !isProcessingToggle else { guard !isProcessingToggle else {
print("⚠️ Already processing toggle") print("⚠️ Already processing toggle")
return return
} }
handleEnforceModeToggle(enabled: newValue) handleEnforceModeToggle(enabled: enabled)
} }
) )
) .padding(.top, 20)
.labelsHidden()
.disabled(isProcessingToggle || !cameraHardwareAvailable)
.controlSize(isCompact ? .small : .regular)
}
.padding(isCompact ? 10 : 16)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
cameraStatusView Spacer(minLength: 0)
if enforceModeService.isEnforceModeEnabled {
testModeButton
}
if isTestModeActive && enforceModeService.isCameraActive {
testModePreviewView
trackingConstantsView
} else if enforceModeService.isCameraActive && !isTestModeActive {
eyeTrackingStatusView
trackingConstantsView
}
privacyInfoView
}
}
Spacer()
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.padding() .padding()
@@ -115,272 +66,6 @@ struct EnforceModeSetupView: View {
} }
} }
} }
private var testModeButton: some View {
Button(action: {
Task { @MainActor in
if isTestModeActive {
enforceModeService.stopTestMode()
isTestModeActive = false
cachedPreviewLayer = nil
} else {
await enforceModeService.startTestMode()
isTestModeActive = enforceModeService.isCameraActive
if isTestModeActive {
cachedPreviewLayer = eyeTrackingService.previewLayer
}
}
}
}) {
HStack {
Image(systemName: isTestModeActive ? "stop.circle.fill" : "play.circle.fill")
.font(.title3)
Text(isTestModeActive ? "Stop Test" : "Test Tracking")
.font(.headline)
}
.frame(maxWidth: .infinity)
.padding()
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
private var calibrationSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "target")
.font(.title3)
.foregroundStyle(.blue)
Text("Eye Tracking Calibration")
.font(.headline)
}
if calibratorService.calibrationData.isComplete {
VStack(alignment: .leading, spacing: 8) {
Text(calibratorService.getCalibrationSummary())
.font(.caption)
.foregroundStyle(.secondary)
if calibratorService.needsRecalibration() {
Label(
"Calibration expired - recalibration recommended",
systemImage: "exclamationmark.triangle.fill"
)
.font(.caption)
.foregroundStyle(.orange)
} else {
Label("Calibration active and valid", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
}
} else {
Text("Not calibrated - using default thresholds")
.font(.caption)
.foregroundStyle(.secondary)
}
Button(action: {
showCalibrationWindow = true
}) {
HStack {
Image(systemName: "target")
Text(
calibratorService.calibrationData.isComplete
? "Recalibrate" : "Run Calibration")
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
.buttonStyle(.bordered)
.controlSize(.regular)
}
.padding()
.glassEffectIfAvailable(
GlassStyle.regular.tint(.blue.opacity(0.1)), in: .rect(cornerRadius: 12)
)
.sheet(isPresented: $showCalibrationWindow) {
EyeTrackingCalibrationView()
}
}
private var testModePreviewView: some View {
VStack(spacing: 16) {
let lookingAway = !eyeTrackingService.userLookingAtScreen
let borderColor: NSColor = lookingAway ? .systemGreen : .systemRed
// Cache the preview layer to avoid recreating it
let previewLayer = eyeTrackingService.previewLayer ?? cachedPreviewLayer
if let layer = previewLayer {
ZStack {
CameraPreviewView(previewLayer: layer, borderColor: borderColor)
// Pupil detection overlay (drawn on video)
PupilOverlayView(eyeTrackingService: eyeTrackingService)
// Debug info overlay (top-right corner)
VStack {
HStack {
Spacer()
GazeOverlayView(eyeTrackingService: eyeTrackingService)
}
Spacer()
}
}
.frame(height: isCompact ? 200 : 300)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
.onAppear {
if cachedPreviewLayer == nil {
cachedPreviewLayer = eyeTrackingService.previewLayer
}
}
/*VStack(alignment: .leading, spacing: 12) {*/
/*Text("Live Tracking Status")*/
/*.font(.headline)*/
/*HStack(spacing: 20) {*/
/*statusIndicator(*/
/*title: "Face Detected",*/
/*isActive: eyeTrackingService.faceDetected,*/
/*icon: "person.fill"*/
/*)*/
/*statusIndicator(*/
/*title: "Looking Away",*/
/*isActive: !eyeTrackingService.userLookingAtScreen,*/
/*icon: "arrow.turn.up.right"*/
/*)*/
/*}*/
/*Text(*/
/*lookingAway*/
/*? " Break compliance detected" : " Please look away from screen"*/
/*)*/
/*.font(.caption)*/
/*.foregroundStyle(lookingAway ? .green : .orange)*/
/*.frame(maxWidth: .infinity, alignment: .center)*/
/*.padding(.top, 4)*/
/*}*/
/*.padding()*/
/*.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))*/
}
}
}
private var cameraStatusView: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Camera Access")
.font(.headline)
if cameraService.isCameraAuthorized {
Label("Authorized", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
} else if let error = cameraService.cameraError {
Label(error.localizedDescription, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
} else {
Label("Not authorized", systemImage: "xmark.circle.fill")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if !cameraService.isCameraAuthorized {
Button("Request Access") {
print("📷 Request Access button clicked")
Task { @MainActor in
do {
try await cameraService.requestCameraAccess()
print("✓ Camera access granted via button")
} catch {
print("⚠️ Camera access failed: \(error.localizedDescription)")
}
}
}
.buttonStyle(.bordered)
}
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
private var eyeTrackingStatusView: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Eye Tracking Status")
.font(.headline)
HStack(spacing: 20) {
statusIndicator(
title: "Face Detected",
isActive: eyeTrackingService.faceDetected,
icon: "person.fill"
)
statusIndicator(
title: "Looking Away",
isActive: !eyeTrackingService.userLookingAtScreen,
icon: "arrow.turn.up.right"
)
}
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
private func statusIndicator(title: String, isActive: Bool, icon: String) -> some View {
VStack(spacing: 8) {
Image(systemName: icon)
.font(.title2)
.foregroundStyle(isActive ? .green : .secondary)
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
}
private var privacyInfoView: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "lock.shield.fill")
.font(.title3)
.foregroundStyle(.blue)
Text("Privacy Information")
.font(.headline)
}
VStack(alignment: .leading, spacing: 8) {
privacyBullet("All processing happens on-device")
privacyBullet("No images are stored or transmitted")
privacyBullet("Camera only active during lookaway reminders (3 second window)")
privacyBullet("You can always force quit with cmd+q")
}
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.glassEffectIfAvailable(
GlassStyle.regular.tint(.blue.opacity(0.1)), in: .rect(cornerRadius: 12))
}
private func privacyBullet(_ text: String) -> some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "checkmark")
.font(.caption2)
.foregroundStyle(.blue)
Text(text)
}
}
private func handleEnforceModeToggle(enabled: Bool) { private func handleEnforceModeToggle(enabled: Bool) {
print("🎛️ handleEnforceModeToggle called with enabled: \(enabled)") print("🎛️ handleEnforceModeToggle called with enabled: \(enabled)")
isProcessingToggle = true isProcessingToggle = true
@@ -411,232 +96,6 @@ struct EnforceModeSetupView: View {
} }
} }
} }
private var trackingConstantsView: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Tracking Sensitivity")
.font(.headline)
Spacer()
Button(action: {
eyeTrackingService.enableDebugLogging.toggle()
}) {
Image(
systemName: eyeTrackingService.enableDebugLogging
? "ant.circle.fill" : "ant.circle"
)
.foregroundStyle(eyeTrackingService.enableDebugLogging ? .orange : .secondary)
}
.buttonStyle(.plain)
.help("Toggle console debug logging")
Button(showAdvancedSettings ? "Hide Settings" : "Show Settings") {
withAnimation {
showAdvancedSettings.toggle()
}
}
.buttonStyle(.bordered)
.controlSize(.small)
}
// Debug info always visible when tracking
VStack(alignment: .leading, spacing: 8) {
Text("Live Values:")
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
if let leftRatio = eyeTrackingService.debugLeftPupilRatio,
let rightRatio = eyeTrackingService.debugRightPupilRatio
{
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 2) {
Text("Left Pupil: \(String(format: "%.3f", leftRatio))")
.font(.caption2)
.foregroundStyle(
!EyeTrackingConstants.minPupilEnabled
&& !EyeTrackingConstants.maxPupilEnabled
? .secondary
: (leftRatio < EyeTrackingConstants.minPupilRatio
|| leftRatio > EyeTrackingConstants.maxPupilRatio)
? Color.orange : Color.green
)
Text("Right Pupil: \(String(format: "%.3f", rightRatio))")
.font(.caption2)
.foregroundStyle(
!EyeTrackingConstants.minPupilEnabled
&& !EyeTrackingConstants.maxPupilEnabled
? .secondary
: (rightRatio < EyeTrackingConstants.minPupilRatio
|| rightRatio > EyeTrackingConstants.maxPupilRatio)
? Color.orange : Color.green
)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(
"Range: \(String(format: "%.2f", EyeTrackingConstants.minPupilRatio)) - \(String(format: "%.2f", EyeTrackingConstants.maxPupilRatio))"
)
.font(.caption2)
.foregroundStyle(.secondary)
let bothEyesOut =
(leftRatio < EyeTrackingConstants.minPupilRatio
|| leftRatio > EyeTrackingConstants.maxPupilRatio)
&& (rightRatio < EyeTrackingConstants.minPupilRatio
|| rightRatio > EyeTrackingConstants.maxPupilRatio)
Text(bothEyesOut ? "Both Out ⚠️" : "In Range ✓")
.font(.caption2)
.foregroundStyle(bothEyesOut ? .orange : .green)
}
}
} else {
Text("Pupil data unavailable")
.font(.caption2)
.foregroundStyle(.secondary)
}
if let yaw = eyeTrackingService.debugYaw,
let pitch = eyeTrackingService.debugPitch
{
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 2) {
Text("Yaw: \(String(format: "%.3f", yaw))")
.font(.caption2)
.foregroundStyle(
!EyeTrackingConstants.yawEnabled
? .secondary
: abs(yaw) > EyeTrackingConstants.yawThreshold
? Color.orange : Color.green
)
Text("Pitch: \(String(format: "%.3f", pitch))")
.font(.caption2)
.foregroundStyle(
!EyeTrackingConstants.pitchUpEnabled
&& !EyeTrackingConstants.pitchDownEnabled
? .secondary
: (pitch > EyeTrackingConstants.pitchUpThreshold
|| pitch < EyeTrackingConstants.pitchDownThreshold)
? Color.orange : Color.green
)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(
"Yaw Max: \(String(format: "%.2f", EyeTrackingConstants.yawThreshold))"
)
.font(.caption2)
.foregroundStyle(.secondary)
Text(
"Pitch: \(String(format: "%.2f", EyeTrackingConstants.pitchDownThreshold)) to \(String(format: "%.2f", EyeTrackingConstants.pitchUpThreshold))"
)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
.padding(.top, 4)
if showAdvancedSettings {
VStack(spacing: 16) {
// Display the current constant values
VStack(alignment: .leading, spacing: 8) {
Text("Current Threshold Values:")
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
HStack {
Text("Yaw Threshold:")
Spacer()
Text("\(String(format: "%.2f", EyeTrackingConstants.yawThreshold)) rad")
.foregroundStyle(.secondary)
}
HStack {
Text("Pitch Up Threshold:")
Spacer()
Text(
"\(String(format: "%.2f", EyeTrackingConstants.pitchUpThreshold)) rad"
)
.foregroundStyle(.secondary)
}
HStack {
Text("Pitch Down Threshold:")
Spacer()
Text(
"\(String(format: "%.2f", EyeTrackingConstants.pitchDownThreshold)) rad"
)
.foregroundStyle(.secondary)
}
HStack {
Text("Min Pupil Ratio:")
Spacer()
Text("\(String(format: "%.2f", EyeTrackingConstants.minPupilRatio))")
.foregroundStyle(.secondary)
}
HStack {
Text("Max Pupil Ratio:")
Spacer()
Text("\(String(format: "%.2f", EyeTrackingConstants.maxPupilRatio))")
.foregroundStyle(.secondary)
}
HStack {
Text("Eye Closed Threshold:")
Spacer()
Text(
"\(String(format: "%.3f", EyeTrackingConstants.eyeClosedThreshold))"
)
.foregroundStyle(.secondary)
}
}
.padding(.top, 8)
}
.padding(.top, 8)
}
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
private var debugEyeTrackingView: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Debug Eye Tracking Data")
.font(.headline)
.foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 8) {
Text("Face Detected: \(eyeTrackingService.faceDetected ? "Yes" : "No")")
.font(.caption)
Text("Looking at Screen: \(eyeTrackingService.userLookingAtScreen ? "Yes" : "No")")
.font(.caption)
Text("Eyes Closed: \(eyeTrackingService.isEyesClosed ? "Yes" : "No")")
.font(.caption)
if eyeTrackingService.faceDetected {
Text("Yaw: 0.0")
.font(.caption)
Text("Roll: 0.0")
.font(.caption)
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
} }
#Preview { #Preview {

View File

@@ -9,201 +9,23 @@ import SwiftUI
struct SmartModeSetupView: View { struct SmartModeSetupView: View {
@Bindable var settingsManager: SettingsManager @Bindable var settingsManager: SettingsManager
@State private var permissionManager = ScreenCapturePermissionManager.shared
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
SetupHeader(icon: "brain.fill", title: "Smart Mode", color: .purple) SetupHeader(icon: "brain.fill", title: "Smart Mode", color: .purple)
Text("Automatically manage timers based on your activity") SmartModeSetupContent(
.font(.subheadline) settingsManager: settingsManager,
.foregroundStyle(.secondary) presentation: .window
.padding(.bottom, 30) )
.padding(.top, 24)
Spacer() Spacer(minLength: 0)
VStack(spacing: 24) {
fullscreenSection
idleSection
#if DEBUG
usageTrackingSection
#endif
}
.frame(maxWidth: 600)
Spacer()
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.padding() .padding()
.background(.clear) .background(.clear)
} }
private var fullscreenSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "arrow.up.left.and.arrow.down.right")
.foregroundStyle(.blue)
Text("Auto-pause on Fullscreen")
.font(.headline)
}
Text(
"Timers will automatically pause when you enter fullscreen mode (videos, games, presentations)"
)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Toggle("", isOn: $settingsManager.settings.smartMode.autoPauseOnFullscreen)
.labelsHidden()
.onChange(of: settingsManager.settings.smartMode.autoPauseOnFullscreen) {
_, newValue in
if newValue {
permissionManager.requestAuthorizationIfNeeded()
}
}
}
if settingsManager.settings.smartMode.autoPauseOnFullscreen,
permissionManager.authorizationStatus != .authorized
{
permissionWarningView
}
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8))
}
private var permissionWarningView: some View {
VStack(alignment: .leading, spacing: 8) {
Label(
permissionManager.authorizationStatus == .denied
? "Screen Recording permission required"
: "Grant Screen Recording access",
systemImage: "exclamationmark.shield"
)
.foregroundStyle(.orange)
Text("macOS requires Screen Recording permission to detect other apps in fullscreen.")
.font(.caption)
.foregroundStyle(.secondary)
HStack {
Button("Grant Access") {
permissionManager.requestAuthorizationIfNeeded()
permissionManager.openSystemSettings()
}
.buttonStyle(.bordered)
Button("Open Settings") {
permissionManager.openSystemSettings()
}
.buttonStyle(.borderless)
}
.font(.caption)
.padding(.top, 4)
}
.padding(.top, 8)
}
private var idleSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "moon.zzz.fill")
.foregroundStyle(.indigo)
Text("Auto-pause on Idle")
.font(.headline)
}
Text("Timers will pause when you're inactive for more than the threshold below")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Toggle("", isOn: $settingsManager.settings.smartMode.autoPauseOnIdle)
.labelsHidden()
}
if settingsManager.settings.smartMode.autoPauseOnIdle {
ThresholdSlider(
label: "Idle Threshold:",
value: $settingsManager.settings.smartMode.idleThresholdMinutes,
range: 1...30,
unit: "min"
)
}
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8))
}
private var usageTrackingSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "chart.line.uptrend.xyaxis")
.foregroundStyle(.green)
Text("Track Usage Statistics")
.font(.headline)
}
Text(
"Monitor active and idle time, with automatic reset after the specified duration"
)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Toggle("", isOn: $settingsManager.settings.smartMode.trackUsage)
.labelsHidden()
}
if settingsManager.settings.smartMode.trackUsage {
ThresholdSlider(
label: "Reset After:",
value: $settingsManager.settings.smartMode.usageResetAfterMinutes,
range: 15...240,
step: 15,
unit: "min"
)
}
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8))
}
}
struct ThresholdSlider: View {
let label: String
@Binding var value: Int
let range: ClosedRange<Int>
var step: Int = 1
let unit: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(label)
.font(.subheadline)
Spacer()
Text("\(value) \(unit)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Slider(
value: Binding(
get: { Double(value) },
set: { value = Int($0) }
),
in: Double(range.lowerBound)...Double(range.upperBound),
step: Double(step)
)
}
.padding(.top, 8)
}
} }
#Preview { #Preview {