631 lines
25 KiB
Swift
631 lines
25 KiB
Swift
//
|
||
// 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)
|
||
}
|