consolidation
This commit is contained in:
16
Gaze/Models/SetupPresentation.swift
Normal file
16
Gaze/Models/SetupPresentation.swift
Normal 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 }
|
||||||
|
}
|
||||||
552
Gaze/Views/Components/EnforceModeSetupContent.swift
Normal file
552
Gaze/Views/Components/EnforceModeSetupContent.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
201
Gaze/Views/Components/SmartModeSetupContent.swift
Normal file
201
Gaze/Views/Components/SmartModeSetupContent.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
39
Gaze/Views/Components/ThresholdSlider.swift
Normal file
39
Gaze/Views/Components/ThresholdSlider.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
.padding(isCompact ? 12 : 20)
|
||||||
enforceModeContent
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||||
} else {
|
|
||||||
smartModeContent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(isCompact ? 12 : 20)
|
|
||||||
}
|
}
|
||||||
.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
|
||||||
|
|||||||
@@ -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")
|
guard !isProcessingToggle else {
|
||||||
.font(isCompact ? .subheadline : .headline)
|
print("⚠️ Already processing toggle")
|
||||||
if !cameraHardwareAvailable {
|
return
|
||||||
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 {
|
|
||||||
print("⚠️ Already processing toggle")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
handleEnforceModeToggle(enabled: newValue)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.labelsHidden()
|
|
||||||
.disabled(isProcessingToggle || !cameraHardwareAvailable)
|
|
||||||
.controlSize(isCompact ? .small : .regular)
|
|
||||||
}
|
}
|
||||||
.padding(isCompact ? 10 : 16)
|
handleEnforceModeToggle(enabled: enabled)
|
||||||
.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
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
|
.padding(.top, 20)
|
||||||
|
|
||||||
Spacer()
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user