Files
Gaze/Gaze/Views/Setup/EnforceModeSetupView.swift
2026-01-19 14:19:30 -05:00

631 lines
25 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// EnforceModeSetupView.swift
// Gaze
//
// Created by Mike Freno on 1/13/26.
//
import AVFoundation
import Foundation
import SwiftUI
struct EnforceModeSetupView: View {
@Bindable var settingsManager: SettingsManager
@ObservedObject var cameraService = CameraAccessService.shared
@ObservedObject var eyeTrackingService = EyeTrackingService.shared
@ObservedObject var enforceModeService = EnforceModeService.shared
@Environment(\.isCompactLayout) private var isCompact
@State private var isProcessingToggle = false
@State private var isTestModeActive = false
@State private var cachedPreviewLayer: AVCaptureVideoPreviewLayer?
@State private var showDebugView = false
@State private var isViewActive = false
@State private var showAdvancedSettings = false
@State private var showCalibrationWindow = false
@ObservedObject var calibrationManager = CalibrationManager.shared
var body: some View {
VStack(spacing: 0) {
SetupHeader(icon: "video.fill", title: "Enforce Mode", color: .accentColor)
Spacer()
VStack(spacing: isCompact ? 16 : 30) {
Text("Use your camera to ensure you take breaks")
.font(isCompact ? .subheadline : .title3)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
VStack(spacing: isCompact ? 12 : 20) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Enable Enforce Mode")
.font(isCompact ? .subheadline : .headline)
Text("Camera activates 3 seconds before lookaway reminders")
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
Toggle(
"",
isOn: Binding(
get: {
settingsManager.settings.enforcementMode
},
set: { newValue in
print("🎛️ Toggle changed to: \(newValue)")
guard !isProcessingToggle else {
print("⚠️ Already processing toggle")
return
}
settingsManager.settings.enforcementMode = newValue
handleEnforceModeToggle(enabled: newValue)
}
)
)
.labelsHidden()
.disabled(isProcessingToggle)
.controlSize(isCompact ? .small : .regular)
}
.padding(isCompact ? 10 : 16)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
cameraStatusView
if enforceModeService.isEnforceModeEnabled {
testModeButton
}
if isTestModeActive && enforceModeService.isCameraActive {
testModePreviewView
trackingConstantsView
} else if enforceModeService.isCameraActive && !isTestModeActive {
eyeTrackingStatusView
trackingConstantsView
}
privacyInfoView
}
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.background(.clear)
.onAppear {
isViewActive = true
}
.onDisappear {
isViewActive = false
// If the view disappeared and camera is still active, stop it
if enforceModeService.isCameraActive {
print("👁️ EnforceModeSetupView disappeared, stopping camera preview")
enforceModeService.stopCamera()
}
}
}
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 calibrationManager.calibrationData.isComplete {
VStack(alignment: .leading, spacing: 8) {
Text(calibrationManager.getCalibrationSummary())
.font(.caption)
.foregroundStyle(.secondary)
if calibrationManager.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(
calibrationManager.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) {
print("🎛️ handleEnforceModeToggle called with enabled: \(enabled)")
isProcessingToggle = true
Task { @MainActor in
defer { isProcessingToggle = false }
if enabled {
print("🎛️ Enabling enforce mode...")
await enforceModeService.enableEnforceMode()
print("🎛️ Enforce mode enabled: \(enforceModeService.isEnforceModeEnabled)")
if !enforceModeService.isEnforceModeEnabled {
print("⚠️ Failed to activate, reverting toggle")
settingsManager.settings.enforcementMode = false
}
} else {
print("🎛️ Disabling enforce mode...")
enforceModeService.disableEnforceMode()
// Clean up camera when disabling enforce mode
if enforceModeService.isCameraActive {
print("👁️ Cleaning up camera on enforce mode disable")
enforceModeService.stopCamera()
}
}
}
}
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 {
EnforceModeSetupView(settingsManager: SettingsManager.shared)
}