remake of calibration
This commit is contained in:
@@ -185,7 +185,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
|||||||
private func showReminder(_ event: ReminderEvent) {
|
private func showReminder(_ event: ReminderEvent) {
|
||||||
switch event {
|
switch event {
|
||||||
case .lookAwayTriggered(let countdownSeconds):
|
case .lookAwayTriggered(let countdownSeconds):
|
||||||
let view = LookAwayReminderView(countdownSeconds: countdownSeconds) { [weak self] in
|
let view = LookAwayReminderView(
|
||||||
|
countdownSeconds: countdownSeconds,
|
||||||
|
enforceModeService: EnforceModeService.shared
|
||||||
|
) { [weak self] in
|
||||||
self?.timerEngine?.dismissReminder()
|
self?.timerEngine?.dismissReminder()
|
||||||
}
|
}
|
||||||
windowManager.showReminderWindow(view, windowType: .overlay)
|
windowManager.showReminderWindow(view, windowType: .overlay)
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ struct DefaultSettingsBuilder {
|
|||||||
static let subtleReminderSize: ReminderSize = .medium
|
static let subtleReminderSize: ReminderSize = .medium
|
||||||
static let smartMode: SmartModeSettings = .defaults
|
static let smartMode: SmartModeSettings = .defaults
|
||||||
static let enforceModeStrictness = 0.4
|
static let enforceModeStrictness = 0.4
|
||||||
static let enforceModeEyeBoxWidthFactor = 0.18
|
static let enforceModeEyeBoxWidthFactor = 0.20
|
||||||
static let enforceModeEyeBoxHeightFactor = 0.10
|
static let enforceModeEyeBoxHeightFactor = 0.02
|
||||||
static let enforceModeCalibration: EnforceModeCalibration? = nil
|
static let enforceModeCalibration: EnforceModeCalibration? = nil
|
||||||
static let hasCompletedOnboarding = false
|
static let hasCompletedOnboarding = false
|
||||||
static let launchAtLogin = false
|
static let launchAtLogin = false
|
||||||
|
|||||||
262
Gaze/Services/Calibration/EnforceModeCalibrationService.swift
Normal file
262
Gaze/Services/Calibration/EnforceModeCalibrationService.swift
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
//
|
||||||
|
// EnforceModeCalibrationService.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Created by Mike Freno on 2/1/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class EnforceModeCalibrationService: ObservableObject {
|
||||||
|
static let shared = EnforceModeCalibrationService()
|
||||||
|
|
||||||
|
@Published var isCalibrating = false
|
||||||
|
@Published var isCollectingSamples = false
|
||||||
|
@Published var currentStep: CalibrationStep = .eyeBox
|
||||||
|
@Published var targetIndex = 0
|
||||||
|
@Published var countdownProgress: Double = 1.0
|
||||||
|
@Published var samplesCollected = 0
|
||||||
|
|
||||||
|
private var samples: [CalibrationSample] = []
|
||||||
|
private let targets = CalibrationTarget.defaultTargets
|
||||||
|
let settingsManager = SettingsManager.shared
|
||||||
|
private let eyeTrackingService = EyeTrackingService.shared
|
||||||
|
|
||||||
|
private var countdownTimer: Timer?
|
||||||
|
private var sampleTimer: Timer?
|
||||||
|
private let countdownDuration: TimeInterval = 1.0
|
||||||
|
private let preCountdownPause: TimeInterval = 0.5
|
||||||
|
private let sampleInterval: TimeInterval = 0.1
|
||||||
|
private let samplesPerTarget = 12
|
||||||
|
private var windowController: NSWindowController?
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
samples.removeAll()
|
||||||
|
targetIndex = 0
|
||||||
|
currentStep = .eyeBox
|
||||||
|
isCollectingSamples = false
|
||||||
|
samplesCollected = 0
|
||||||
|
countdownProgress = 1.0
|
||||||
|
isCalibrating = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentOverlay() {
|
||||||
|
guard windowController == nil else { return }
|
||||||
|
guard let screen = NSScreen.main else { return }
|
||||||
|
|
||||||
|
start()
|
||||||
|
|
||||||
|
let window = KeyableWindow(
|
||||||
|
contentRect: screen.frame,
|
||||||
|
styleMask: [.borderless, .fullSizeContentView],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
|
||||||
|
window.level = .screenSaver
|
||||||
|
window.isOpaque = true
|
||||||
|
window.backgroundColor = .black
|
||||||
|
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||||
|
window.acceptsMouseMovedEvents = true
|
||||||
|
window.ignoresMouseEvents = false
|
||||||
|
|
||||||
|
let overlayView = EnforceModeCalibrationOverlayView()
|
||||||
|
window.contentView = NSHostingView(rootView: overlayView)
|
||||||
|
|
||||||
|
windowController = NSWindowController(window: window)
|
||||||
|
windowController?.showWindow(nil)
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismissOverlay() {
|
||||||
|
windowController?.close()
|
||||||
|
windowController = nil
|
||||||
|
isCalibrating = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
stopCountdown()
|
||||||
|
stopSampleCollection()
|
||||||
|
isCalibrating = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func advance() {
|
||||||
|
switch currentStep {
|
||||||
|
case .eyeBox:
|
||||||
|
currentStep = .targets
|
||||||
|
startCountdown()
|
||||||
|
case .targets:
|
||||||
|
if targetIndex < targets.count - 1 {
|
||||||
|
targetIndex += 1
|
||||||
|
startCountdown()
|
||||||
|
} else {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
case .complete:
|
||||||
|
isCalibrating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordSample() {
|
||||||
|
let debugState = eyeTrackingService.currentDebugSnapshot()
|
||||||
|
guard let h = debugState.normalizedHorizontal,
|
||||||
|
let v = debugState.normalizedVertical,
|
||||||
|
let faceWidth = debugState.faceWidthRatio else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = targets[targetIndex]
|
||||||
|
samples.append(
|
||||||
|
CalibrationSample(
|
||||||
|
target: target,
|
||||||
|
horizontal: h,
|
||||||
|
vertical: v,
|
||||||
|
faceWidthRatio: faceWidth
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentTarget() -> CalibrationTarget {
|
||||||
|
targets[targetIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startCountdown() {
|
||||||
|
stopCountdown()
|
||||||
|
stopSampleCollection()
|
||||||
|
|
||||||
|
countdownProgress = 1.0
|
||||||
|
let startTime = Date()
|
||||||
|
countdownTimer = Timer.scheduledTimer(withTimeInterval: 0.02, repeats: true) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
let elapsed = Date().timeIntervalSince(startTime)
|
||||||
|
let countdownElapsed = max(0, elapsed - self.preCountdownPause)
|
||||||
|
if elapsed < self.preCountdownPause {
|
||||||
|
self.countdownProgress = 1.0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let remaining = max(0, self.countdownDuration - countdownElapsed)
|
||||||
|
self.countdownProgress = remaining / self.countdownDuration
|
||||||
|
if remaining <= 0 {
|
||||||
|
self.stopCountdown()
|
||||||
|
self.startSampleCollection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopCountdown() {
|
||||||
|
countdownTimer?.invalidate()
|
||||||
|
countdownTimer = nil
|
||||||
|
countdownProgress = 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startSampleCollection() {
|
||||||
|
stopSampleCollection()
|
||||||
|
samplesCollected = 0
|
||||||
|
isCollectingSamples = true
|
||||||
|
sampleTimer = Timer.scheduledTimer(withTimeInterval: sampleInterval, repeats: true) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
self.recordSample()
|
||||||
|
self.samplesCollected += 1
|
||||||
|
if self.samplesCollected >= self.samplesPerTarget {
|
||||||
|
self.stopSampleCollection()
|
||||||
|
self.advance()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopSampleCollection() {
|
||||||
|
sampleTimer?.invalidate()
|
||||||
|
sampleTimer = nil
|
||||||
|
isCollectingSamples = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finish() {
|
||||||
|
stopCountdown()
|
||||||
|
stopSampleCollection()
|
||||||
|
guard let calibration = CalibrationSample.makeCalibration(samples: samples) else {
|
||||||
|
currentStep = .complete
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsManager.settings.enforceModeCalibration = calibration
|
||||||
|
currentStep = .complete
|
||||||
|
}
|
||||||
|
|
||||||
|
var progress: Double {
|
||||||
|
guard !targets.isEmpty else { return 0 }
|
||||||
|
return Double(targetIndex) / Double(targets.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
var progressText: String {
|
||||||
|
"\(min(targetIndex + 1, targets.count))/\(targets.count)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CalibrationStep: String {
|
||||||
|
case eyeBox
|
||||||
|
case targets
|
||||||
|
case complete
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CalibrationTarget: Identifiable, Sendable {
|
||||||
|
let id = UUID()
|
||||||
|
let x: CGFloat
|
||||||
|
let y: CGFloat
|
||||||
|
let label: String
|
||||||
|
|
||||||
|
static let defaultTargets: [CalibrationTarget] = [
|
||||||
|
CalibrationTarget(x: 0.1, y: 0.1, label: "Top Left"),
|
||||||
|
CalibrationTarget(x: 0.5, y: 0.1, label: "Top"),
|
||||||
|
CalibrationTarget(x: 0.9, y: 0.1, label: "Top Right"),
|
||||||
|
CalibrationTarget(x: 0.9, y: 0.5, label: "Right"),
|
||||||
|
CalibrationTarget(x: 0.9, y: 0.9, label: "Bottom Right"),
|
||||||
|
CalibrationTarget(x: 0.5, y: 0.9, label: "Bottom"),
|
||||||
|
CalibrationTarget(x: 0.1, y: 0.9, label: "Bottom Left"),
|
||||||
|
CalibrationTarget(x: 0.1, y: 0.5, label: "Left"),
|
||||||
|
CalibrationTarget(x: 0.5, y: 0.5, label: "Center")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CalibrationSample: Sendable {
|
||||||
|
let target: CalibrationTarget
|
||||||
|
let horizontal: Double
|
||||||
|
let vertical: Double
|
||||||
|
let faceWidthRatio: Double
|
||||||
|
|
||||||
|
static func makeCalibration(samples: [CalibrationSample]) -> EnforceModeCalibration? {
|
||||||
|
guard !samples.isEmpty else { return nil }
|
||||||
|
|
||||||
|
let horizontalValues = samples.map { $0.horizontal }
|
||||||
|
let verticalValues = samples.map { $0.vertical }
|
||||||
|
let faceWidths = samples.map { $0.faceWidthRatio }
|
||||||
|
|
||||||
|
guard let minH = horizontalValues.min(),
|
||||||
|
let maxH = horizontalValues.max(),
|
||||||
|
let minV = verticalValues.min(),
|
||||||
|
let maxV = verticalValues.max() else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let faceWidthMean = faceWidths.reduce(0, +) / Double(faceWidths.count)
|
||||||
|
|
||||||
|
return EnforceModeCalibration(
|
||||||
|
createdAt: Date(),
|
||||||
|
eyeBoxWidthFactor: SettingsManager.shared.settings.enforceModeEyeBoxWidthFactor,
|
||||||
|
eyeBoxHeightFactor: SettingsManager.shared.settings.enforceModeEyeBoxHeightFactor,
|
||||||
|
faceWidthRatio: faceWidthMean,
|
||||||
|
horizontalMin: minH,
|
||||||
|
horizontalMax: maxH,
|
||||||
|
verticalMin: minV,
|
||||||
|
verticalMax: maxV
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ class EnforceModeService: ObservableObject {
|
|||||||
private var faceDetectionTimer: Timer?
|
private var faceDetectionTimer: Timer?
|
||||||
private var trackingDebugTimer: Timer?
|
private var trackingDebugTimer: Timer?
|
||||||
private var trackingLapStats = TrackingLapStats()
|
private var trackingLapStats = TrackingLapStats()
|
||||||
|
private var lastLookAwayTime: Date = .distantPast
|
||||||
|
|
||||||
// MARK: - Configuration
|
// MARK: - Configuration
|
||||||
|
|
||||||
@@ -183,7 +184,7 @@ class EnforceModeService: ObservableObject {
|
|||||||
gazeState: GazeState,
|
gazeState: GazeState,
|
||||||
faceDetected: Bool
|
faceDetected: Bool
|
||||||
) -> ComplianceResult {
|
) -> ComplianceResult {
|
||||||
guard faceDetected else { return .faceNotDetected }
|
guard faceDetected else { return .compliant }
|
||||||
switch gazeState {
|
switch gazeState {
|
||||||
case .lookingAway:
|
case .lookingAway:
|
||||||
return .compliant
|
return .compliant
|
||||||
@@ -242,11 +243,13 @@ class EnforceModeService: ObservableObject {
|
|||||||
|
|
||||||
switch compliance {
|
switch compliance {
|
||||||
case .compliant:
|
case .compliant:
|
||||||
|
lastLookAwayTime = Date()
|
||||||
userCompliedWithBreak = true
|
userCompliedWithBreak = true
|
||||||
case .notCompliant:
|
case .notCompliant:
|
||||||
userCompliedWithBreak = false
|
userCompliedWithBreak = false
|
||||||
case .faceNotDetected:
|
case .faceNotDetected:
|
||||||
userCompliedWithBreak = false
|
lastLookAwayTime = Date()
|
||||||
|
userCompliedWithBreak = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,11 +324,30 @@ class EnforceModeService: ObservableObject {
|
|||||||
|
|
||||||
let timeSinceLastDetection = Date().timeIntervalSince(lastFaceDetectionTime)
|
let timeSinceLastDetection = Date().timeIntervalSince(lastFaceDetectionTime)
|
||||||
if timeSinceLastDetection > faceDetectionTimeout {
|
if timeSinceLastDetection > faceDetectionTimeout {
|
||||||
logDebug("⏰ Person not detected for \(faceDetectionTimeout)s. Temporarily disabling enforce mode.")
|
logDebug("⏰ Person not detected for \(faceDetectionTimeout)s. Assuming look away.")
|
||||||
disableEnforceMode()
|
lastLookAwayTime = Date()
|
||||||
|
userCompliedWithBreak = true
|
||||||
|
lastFaceDetectionTime = Date()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shouldAdvanceLookAwayCountdown() -> Bool {
|
||||||
|
guard isEnforceModeEnabled else { return true }
|
||||||
|
guard isCameraActive else { return true }
|
||||||
|
|
||||||
|
if !eyeTrackingService.trackingResult.faceDetected {
|
||||||
|
lastLookAwayTime = Date()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if eyeTrackingService.trackingResult.gazeState == .lookingAway {
|
||||||
|
lastLookAwayTime = Date()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return Date().timeIntervalSince(lastLookAwayTime) <= 0.25
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Test Mode
|
// MARK: - Test Mode
|
||||||
|
|
||||||
func startTestMode() async {
|
func startTestMode() async {
|
||||||
|
|||||||
@@ -72,12 +72,14 @@ class EyeTrackingService: NSObject, ObservableObject {
|
|||||||
baselineEnabled = false
|
baselineEnabled = false
|
||||||
centerHorizontal = (calibration.horizontalMin + calibration.horizontalMax) / 2
|
centerHorizontal = (calibration.horizontalMin + calibration.horizontalMax) / 2
|
||||||
centerVertical = (calibration.verticalMin + calibration.verticalMax) / 2
|
centerVertical = (calibration.verticalMin + calibration.verticalMax) / 2
|
||||||
|
processor.setFaceWidthBaseline(calibration.faceWidthRatio)
|
||||||
} else {
|
} else {
|
||||||
horizontalThreshold = TrackingConfig.default.horizontalAwayThreshold * scale
|
horizontalThreshold = TrackingConfig.default.horizontalAwayThreshold * scale
|
||||||
verticalThreshold = TrackingConfig.default.verticalAwayThreshold * scale
|
verticalThreshold = TrackingConfig.default.verticalAwayThreshold * scale
|
||||||
baselineEnabled = TrackingConfig.default.baselineEnabled
|
baselineEnabled = TrackingConfig.default.baselineEnabled
|
||||||
centerHorizontal = TrackingConfig.default.defaultCenterHorizontal
|
centerHorizontal = TrackingConfig.default.defaultCenterHorizontal
|
||||||
centerVertical = TrackingConfig.default.defaultCenterVertical
|
centerVertical = TrackingConfig.default.defaultCenterVertical
|
||||||
|
processor.resetBaseline()
|
||||||
}
|
}
|
||||||
|
|
||||||
let config = TrackingConfig(
|
let config = TrackingConfig(
|
||||||
@@ -126,6 +128,10 @@ class EyeTrackingService: NSObject, ObservableObject {
|
|||||||
debugState = EyeTrackingDebugState.empty
|
debugState = EyeTrackingDebugState.empty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func currentDebugSnapshot() -> EyeTrackingDebugState {
|
||||||
|
debugState
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension EyeTrackingService: CameraSessionDelegate {
|
extension EyeTrackingService: CameraSessionDelegate {
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ final class VisionGazeProcessor: @unchecked Sendable {
|
|||||||
faceWidthSmoothed = nil
|
faceWidthSmoothed = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setFaceWidthBaseline(_ value: Double) {
|
||||||
|
faceWidthBaseline = value
|
||||||
|
faceWidthSmoothed = value
|
||||||
|
}
|
||||||
|
|
||||||
func process(analysis: VisionPipeline.FaceAnalysis) -> ObservationResult {
|
func process(analysis: VisionPipeline.FaceAnalysis) -> ObservationResult {
|
||||||
guard analysis.faceDetected, let face = analysis.face?.value else {
|
guard analysis.faceDetected, let face = analysis.face?.value else {
|
||||||
return ObservationResult(
|
return ObservationResult(
|
||||||
|
|||||||
184
Gaze/Views/Components/EnforceModeCalibrationOverlayView.swift
Normal file
184
Gaze/Views/Components/EnforceModeCalibrationOverlayView.swift
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
//
|
||||||
|
// EnforceModeCalibrationOverlayView.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Created by Mike Freno on 2/1/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct EnforceModeCalibrationOverlayView: View {
|
||||||
|
@ObservedObject private var calibrationService = EnforceModeCalibrationService.shared
|
||||||
|
@ObservedObject private var eyeTrackingService = EyeTrackingService.shared
|
||||||
|
@Bindable private var settingsManager = SettingsManager.shared
|
||||||
|
|
||||||
|
@ObservedObject private var enforceModeService = EnforceModeService.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black.opacity(0.85)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
switch calibrationService.currentStep {
|
||||||
|
case .eyeBox:
|
||||||
|
eyeBoxStep
|
||||||
|
case .targets:
|
||||||
|
targetStep
|
||||||
|
case .complete:
|
||||||
|
completionStep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var eyeBoxStep: some View {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
Text("Adjust Eye Box")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"Use the sliders to fit the boxes around your eyes. When it looks right, continue."
|
||||||
|
)
|
||||||
|
.font(.callout)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
|
|
||||||
|
eyePreview
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Width")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
|
Slider(
|
||||||
|
value: $settingsManager.settings.enforceModeEyeBoxWidthFactor,
|
||||||
|
in: 0.12...0.25
|
||||||
|
)
|
||||||
|
|
||||||
|
Text("Height")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
|
Slider(
|
||||||
|
value: $settingsManager.settings.enforceModeEyeBoxHeightFactor,
|
||||||
|
in: 0.01...0.10
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(.white.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("Cancel") {
|
||||||
|
calibrationService.dismissOverlay()
|
||||||
|
enforceModeService.stopTestMode()
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
|
||||||
|
Button("Continue") {
|
||||||
|
calibrationService.advance()
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var targetStep: some View {
|
||||||
|
ZStack {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Text("Calibrating...")
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Spacer()
|
||||||
|
Text(calibrationService.progressText)
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
|
||||||
|
ProgressView(value: calibrationService.progress)
|
||||||
|
.progressViewStyle(.linear)
|
||||||
|
.tint(.blue)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.black.opacity(0.7))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .top)
|
||||||
|
|
||||||
|
targetDot
|
||||||
|
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("Cancel") {
|
||||||
|
calibrationService.dismissOverlay()
|
||||||
|
enforceModeService.stopTestMode()
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var completionStep: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Text("Calibration Complete")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Text("You can close this window and start testing.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
|
|
||||||
|
Button("Done") {
|
||||||
|
calibrationService.dismissOverlay()
|
||||||
|
enforceModeService.stopTestMode()
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var eyePreview: some View {
|
||||||
|
ZStack {
|
||||||
|
if let layer = eyeTrackingService.previewLayer {
|
||||||
|
CameraPreviewView(previewLayer: layer, borderColor: NSColor.systemBlue)
|
||||||
|
.frame(height: 240)
|
||||||
|
}
|
||||||
|
GeometryReader { geometry in
|
||||||
|
EyeTrackingDebugOverlayView(
|
||||||
|
debugState: eyeTrackingService.debugState,
|
||||||
|
viewSize: geometry.size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 240)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var targetDot: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let target = calibrationService.currentTarget()
|
||||||
|
Circle()
|
||||||
|
.fill(Color.blue)
|
||||||
|
.frame(width: 100, height: 100)
|
||||||
|
.position(
|
||||||
|
x: geometry.size.width * target.x,
|
||||||
|
y: geometry.size.height * target.y
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: CGFloat(calibrationService.countdownProgress))
|
||||||
|
.stroke(Color.blue.opacity(0.8), lineWidth: 6)
|
||||||
|
.frame(width: 140, height: 140)
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.animation(.linear(duration: 0.02), value: calibrationService.countdownProgress)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var countdownRing: some View {
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: CGFloat(calibrationService.countdownProgress))
|
||||||
|
.stroke(Color.blue.opacity(0.8), lineWidth: 6)
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ struct EnforceModeSetupContent: View {
|
|||||||
@ObservedObject var cameraService = CameraAccessService.shared
|
@ObservedObject var cameraService = CameraAccessService.shared
|
||||||
@ObservedObject var eyeTrackingService = EyeTrackingService.shared
|
@ObservedObject var eyeTrackingService = EyeTrackingService.shared
|
||||||
@ObservedObject var enforceModeService = EnforceModeService.shared
|
@ObservedObject var enforceModeService = EnforceModeService.shared
|
||||||
|
@ObservedObject var calibrationService = EnforceModeCalibrationService.shared
|
||||||
@Environment(\.isCompactLayout) private var isCompact
|
@Environment(\.isCompactLayout) private var isCompact
|
||||||
|
|
||||||
let presentation: SetupPresentation
|
let presentation: SetupPresentation
|
||||||
@@ -79,12 +80,7 @@ struct EnforceModeSetupContent: View {
|
|||||||
if enforceModeService.isEnforceModeEnabled {
|
if enforceModeService.isEnforceModeEnabled {
|
||||||
strictnessControlView
|
strictnessControlView
|
||||||
}
|
}
|
||||||
if isTestModeActive && enforceModeService.isCameraActive {
|
calibrationActionView
|
||||||
eyeBoxControlView
|
|
||||||
}
|
|
||||||
if enforceModeService.isCameraActive {
|
|
||||||
trackingLapButton
|
|
||||||
}
|
|
||||||
privacyInfoView
|
privacyInfoView
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,44 +395,16 @@ struct EnforceModeSetupContent: View {
|
|||||||
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius))
|
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var eyeBoxControlView: some View {
|
private var calibrationActionView: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text("Eye Box Size")
|
|
||||||
.font(headerFont)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("Width")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
Slider(
|
|
||||||
value: $settingsManager.settings.enforceModeEyeBoxWidthFactor,
|
|
||||||
in: 0.12...0.25
|
|
||||||
)
|
|
||||||
.controlSize(.small)
|
|
||||||
|
|
||||||
Text("Height")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
Slider(
|
|
||||||
value: $settingsManager.settings.enforceModeEyeBoxHeightFactor,
|
|
||||||
in: 0.02...0.05
|
|
||||||
)
|
|
||||||
.controlSize(.small)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(sectionPadding)
|
|
||||||
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius))
|
|
||||||
}
|
|
||||||
|
|
||||||
private var trackingLapButton: some View {
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
enforceModeService.logTrackingLap()
|
calibrationService.presentOverlay()
|
||||||
|
Task { @MainActor in
|
||||||
|
await enforceModeService.startTestMode()
|
||||||
|
}
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "flag.checkered")
|
Image(systemName: "target")
|
||||||
Text("Lap Marker")
|
Text("Calibrate Eye Tracking")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -445,4 +413,6 @@ struct EnforceModeSetupContent: View {
|
|||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.controlSize(.regular)
|
.controlSize(.regular)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,23 @@ import SwiftUI
|
|||||||
struct LookAwayReminderView: View {
|
struct LookAwayReminderView: View {
|
||||||
let countdownSeconds: Int
|
let countdownSeconds: Int
|
||||||
var onDismiss: () -> Void
|
var onDismiss: () -> Void
|
||||||
|
var enforceModeService: EnforceModeService?
|
||||||
|
|
||||||
@State private var remainingSeconds: Int
|
@State private var remainingSeconds: Int
|
||||||
|
@State private var remainingTime: TimeInterval
|
||||||
@State private var timer: Timer?
|
@State private var timer: Timer?
|
||||||
@State private var keyMonitor: Any?
|
@State private var keyMonitor: Any?
|
||||||
|
|
||||||
init(countdownSeconds: Int, onDismiss: @escaping () -> Void) {
|
init(
|
||||||
|
countdownSeconds: Int,
|
||||||
|
enforceModeService: EnforceModeService? = nil,
|
||||||
|
onDismiss: @escaping () -> Void
|
||||||
|
) {
|
||||||
self.countdownSeconds = countdownSeconds
|
self.countdownSeconds = countdownSeconds
|
||||||
|
self.enforceModeService = enforceModeService
|
||||||
self.onDismiss = onDismiss
|
self.onDismiss = onDismiss
|
||||||
self._remainingSeconds = State(initialValue: countdownSeconds)
|
self._remainingSeconds = State(initialValue: countdownSeconds)
|
||||||
|
self._remainingTime = State(initialValue: TimeInterval(countdownSeconds))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -100,15 +108,21 @@ struct LookAwayReminderView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var progress: CGFloat {
|
private var progress: CGFloat {
|
||||||
CGFloat(remainingSeconds) / CGFloat(countdownSeconds)
|
CGFloat(remainingTime) / CGFloat(countdownSeconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startCountdown() {
|
private func startCountdown() {
|
||||||
let timer = Timer(timeInterval: 1.0, repeats: true) { [self] _ in
|
let tickInterval: TimeInterval = 0.25
|
||||||
if remainingSeconds > 0 {
|
let timer = Timer(timeInterval: tickInterval, repeats: true) { [self] _ in
|
||||||
remainingSeconds -= 1
|
guard remainingTime > 0 else {
|
||||||
} else {
|
|
||||||
dismiss()
|
dismiss()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let shouldAdvance = enforceModeService?.shouldAdvanceLookAwayCountdown() ?? true
|
||||||
|
if shouldAdvance {
|
||||||
|
remainingTime = max(0, remainingTime - tickInterval)
|
||||||
|
remainingSeconds = max(0, Int(ceil(remainingTime)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
RunLoop.current.add(timer, forMode: .common)
|
RunLoop.current.add(timer, forMode: .common)
|
||||||
|
|||||||
Reference in New Issue
Block a user