remake of calibration
This commit is contained in:
@@ -185,7 +185,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
||||
private func showReminder(_ event: ReminderEvent) {
|
||||
switch event {
|
||||
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()
|
||||
}
|
||||
windowManager.showReminderWindow(view, windowType: .overlay)
|
||||
|
||||
@@ -17,8 +17,8 @@ struct DefaultSettingsBuilder {
|
||||
static let subtleReminderSize: ReminderSize = .medium
|
||||
static let smartMode: SmartModeSettings = .defaults
|
||||
static let enforceModeStrictness = 0.4
|
||||
static let enforceModeEyeBoxWidthFactor = 0.18
|
||||
static let enforceModeEyeBoxHeightFactor = 0.10
|
||||
static let enforceModeEyeBoxWidthFactor = 0.20
|
||||
static let enforceModeEyeBoxHeightFactor = 0.02
|
||||
static let enforceModeCalibration: EnforceModeCalibration? = nil
|
||||
static let hasCompletedOnboarding = 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 trackingDebugTimer: Timer?
|
||||
private var trackingLapStats = TrackingLapStats()
|
||||
private var lastLookAwayTime: Date = .distantPast
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
@@ -183,7 +184,7 @@ class EnforceModeService: ObservableObject {
|
||||
gazeState: GazeState,
|
||||
faceDetected: Bool
|
||||
) -> ComplianceResult {
|
||||
guard faceDetected else { return .faceNotDetected }
|
||||
guard faceDetected else { return .compliant }
|
||||
switch gazeState {
|
||||
case .lookingAway:
|
||||
return .compliant
|
||||
@@ -242,11 +243,13 @@ class EnforceModeService: ObservableObject {
|
||||
|
||||
switch compliance {
|
||||
case .compliant:
|
||||
lastLookAwayTime = Date()
|
||||
userCompliedWithBreak = true
|
||||
case .notCompliant:
|
||||
userCompliedWithBreak = false
|
||||
case .faceNotDetected:
|
||||
userCompliedWithBreak = false
|
||||
lastLookAwayTime = Date()
|
||||
userCompliedWithBreak = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,11 +324,30 @@ class EnforceModeService: ObservableObject {
|
||||
|
||||
let timeSinceLastDetection = Date().timeIntervalSince(lastFaceDetectionTime)
|
||||
if timeSinceLastDetection > faceDetectionTimeout {
|
||||
logDebug("⏰ Person not detected for \(faceDetectionTimeout)s. Temporarily disabling enforce mode.")
|
||||
disableEnforceMode()
|
||||
logDebug("⏰ Person not detected for \(faceDetectionTimeout)s. Assuming look away.")
|
||||
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
|
||||
|
||||
func startTestMode() async {
|
||||
|
||||
@@ -72,12 +72,14 @@ class EyeTrackingService: NSObject, ObservableObject {
|
||||
baselineEnabled = false
|
||||
centerHorizontal = (calibration.horizontalMin + calibration.horizontalMax) / 2
|
||||
centerVertical = (calibration.verticalMin + calibration.verticalMax) / 2
|
||||
processor.setFaceWidthBaseline(calibration.faceWidthRatio)
|
||||
} else {
|
||||
horizontalThreshold = TrackingConfig.default.horizontalAwayThreshold * scale
|
||||
verticalThreshold = TrackingConfig.default.verticalAwayThreshold * scale
|
||||
baselineEnabled = TrackingConfig.default.baselineEnabled
|
||||
centerHorizontal = TrackingConfig.default.defaultCenterHorizontal
|
||||
centerVertical = TrackingConfig.default.defaultCenterVertical
|
||||
processor.resetBaseline()
|
||||
}
|
||||
|
||||
let config = TrackingConfig(
|
||||
@@ -126,6 +128,10 @@ class EyeTrackingService: NSObject, ObservableObject {
|
||||
debugState = EyeTrackingDebugState.empty
|
||||
}
|
||||
}
|
||||
|
||||
func currentDebugSnapshot() -> EyeTrackingDebugState {
|
||||
debugState
|
||||
}
|
||||
}
|
||||
|
||||
extension EyeTrackingService: CameraSessionDelegate {
|
||||
|
||||
@@ -48,6 +48,11 @@ final class VisionGazeProcessor: @unchecked Sendable {
|
||||
faceWidthSmoothed = nil
|
||||
}
|
||||
|
||||
func setFaceWidthBaseline(_ value: Double) {
|
||||
faceWidthBaseline = value
|
||||
faceWidthSmoothed = value
|
||||
}
|
||||
|
||||
func process(analysis: VisionPipeline.FaceAnalysis) -> ObservationResult {
|
||||
guard analysis.faceDetected, let face = analysis.face?.value else {
|
||||
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 eyeTrackingService = EyeTrackingService.shared
|
||||
@ObservedObject var enforceModeService = EnforceModeService.shared
|
||||
@ObservedObject var calibrationService = EnforceModeCalibrationService.shared
|
||||
@Environment(\.isCompactLayout) private var isCompact
|
||||
|
||||
let presentation: SetupPresentation
|
||||
@@ -79,12 +80,7 @@ struct EnforceModeSetupContent: View {
|
||||
if enforceModeService.isEnforceModeEnabled {
|
||||
strictnessControlView
|
||||
}
|
||||
if isTestModeActive && enforceModeService.isCameraActive {
|
||||
eyeBoxControlView
|
||||
}
|
||||
if enforceModeService.isCameraActive {
|
||||
trackingLapButton
|
||||
}
|
||||
calibrationActionView
|
||||
privacyInfoView
|
||||
}
|
||||
|
||||
@@ -399,44 +395,16 @@ struct EnforceModeSetupContent: View {
|
||||
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius))
|
||||
}
|
||||
|
||||
private var eyeBoxControlView: 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 {
|
||||
private var calibrationActionView: some View {
|
||||
Button(action: {
|
||||
enforceModeService.logTrackingLap()
|
||||
calibrationService.presentOverlay()
|
||||
Task { @MainActor in
|
||||
await enforceModeService.startTestMode()
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "flag.checkered")
|
||||
Text("Lap Marker")
|
||||
Image(systemName: "target")
|
||||
Text("Calibrate Eye Tracking")
|
||||
.font(.headline)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -445,4 +413,6 @@ struct EnforceModeSetupContent: View {
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.regular)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -12,15 +12,23 @@ import SwiftUI
|
||||
struct LookAwayReminderView: View {
|
||||
let countdownSeconds: Int
|
||||
var onDismiss: () -> Void
|
||||
var enforceModeService: EnforceModeService?
|
||||
|
||||
@State private var remainingSeconds: Int
|
||||
@State private var remainingTime: TimeInterval
|
||||
@State private var timer: Timer?
|
||||
@State private var keyMonitor: Any?
|
||||
|
||||
init(countdownSeconds: Int, onDismiss: @escaping () -> Void) {
|
||||
init(
|
||||
countdownSeconds: Int,
|
||||
enforceModeService: EnforceModeService? = nil,
|
||||
onDismiss: @escaping () -> Void
|
||||
) {
|
||||
self.countdownSeconds = countdownSeconds
|
||||
self.enforceModeService = enforceModeService
|
||||
self.onDismiss = onDismiss
|
||||
self._remainingSeconds = State(initialValue: countdownSeconds)
|
||||
self._remainingTime = State(initialValue: TimeInterval(countdownSeconds))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -100,15 +108,21 @@ struct LookAwayReminderView: View {
|
||||
}
|
||||
|
||||
private var progress: CGFloat {
|
||||
CGFloat(remainingSeconds) / CGFloat(countdownSeconds)
|
||||
CGFloat(remainingTime) / CGFloat(countdownSeconds)
|
||||
}
|
||||
|
||||
private func startCountdown() {
|
||||
let timer = Timer(timeInterval: 1.0, repeats: true) { [self] _ in
|
||||
if remainingSeconds > 0 {
|
||||
remainingSeconds -= 1
|
||||
} else {
|
||||
let tickInterval: TimeInterval = 0.25
|
||||
let timer = Timer(timeInterval: tickInterval, repeats: true) { [self] _ in
|
||||
guard remainingTime > 0 else {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user