This commit is contained in:
Michael Freno
2026-01-31 23:34:07 -05:00
parent 2966dd7d5e
commit a20b3701a6
7 changed files with 350 additions and 11 deletions

View File

@@ -42,6 +42,7 @@ struct AppSettings: Codable, Equatable, Hashable, Sendable {
var subtleReminderSize: ReminderSize var subtleReminderSize: ReminderSize
var smartMode: SmartModeSettings var smartMode: SmartModeSettings
var enforceModeStrictness: Double
var hasCompletedOnboarding: Bool var hasCompletedOnboarding: Bool
var launchAtLogin: Bool var launchAtLogin: Bool
@@ -57,6 +58,7 @@ struct AppSettings: Codable, Equatable, Hashable, Sendable {
userTimers: [UserTimer] = [], userTimers: [UserTimer] = [],
subtleReminderSize: ReminderSize = DefaultSettingsBuilder.subtleReminderSize, subtleReminderSize: ReminderSize = DefaultSettingsBuilder.subtleReminderSize,
smartMode: SmartModeSettings = DefaultSettingsBuilder.smartMode, smartMode: SmartModeSettings = DefaultSettingsBuilder.smartMode,
enforceModeStrictness: Double = DefaultSettingsBuilder.enforceModeStrictness,
hasCompletedOnboarding: Bool = DefaultSettingsBuilder.hasCompletedOnboarding, hasCompletedOnboarding: Bool = DefaultSettingsBuilder.hasCompletedOnboarding,
launchAtLogin: Bool = DefaultSettingsBuilder.launchAtLogin, launchAtLogin: Bool = DefaultSettingsBuilder.launchAtLogin,
playSounds: Bool = DefaultSettingsBuilder.playSounds playSounds: Bool = DefaultSettingsBuilder.playSounds
@@ -70,6 +72,7 @@ struct AppSettings: Codable, Equatable, Hashable, Sendable {
self.userTimers = userTimers self.userTimers = userTimers
self.subtleReminderSize = subtleReminderSize self.subtleReminderSize = subtleReminderSize
self.smartMode = smartMode self.smartMode = smartMode
self.enforceModeStrictness = enforceModeStrictness
self.hasCompletedOnboarding = hasCompletedOnboarding self.hasCompletedOnboarding = hasCompletedOnboarding
self.launchAtLogin = launchAtLogin self.launchAtLogin = launchAtLogin
self.playSounds = playSounds self.playSounds = playSounds

View File

@@ -16,6 +16,7 @@ struct DefaultSettingsBuilder {
static let postureIntervalMinutes = 30 static let postureIntervalMinutes = 30
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 hasCompletedOnboarding = false static let hasCompletedOnboarding = false
static let launchAtLogin = false static let launchAtLogin = false
static let playSounds = true static let playSounds = true
@@ -31,6 +32,7 @@ struct DefaultSettingsBuilder {
userTimers: [], userTimers: [],
subtleReminderSize: subtleReminderSize, subtleReminderSize: subtleReminderSize,
smartMode: smartMode, smartMode: smartMode,
enforceModeStrictness: enforceModeStrictness,
hasCompletedOnboarding: hasCompletedOnboarding, hasCompletedOnboarding: hasCompletedOnboarding,
launchAtLogin: launchAtLogin, launchAtLogin: launchAtLogin,
playSounds: playSounds playSounds: playSounds

View File

@@ -31,6 +31,8 @@ class EnforceModeService: ObservableObject {
private var timerEngine: TimerEngine? private var timerEngine: TimerEngine?
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var faceDetectionTimer: Timer? private var faceDetectionTimer: Timer?
private var trackingDebugTimer: Timer?
private var trackingLapStats = TrackingLapStats()
// MARK: - Configuration // MARK: - Configuration
@@ -83,6 +85,17 @@ class EnforceModeService: ObservableObject {
self?.refreshEnforceModeState() self?.refreshEnforceModeState()
} }
.store(in: &cancellables) .store(in: &cancellables)
$isCameraActive
.removeDuplicates()
.sink { [weak self] isActive in
if isActive {
self?.startTrackingDebugTimer()
} else {
self?.stopTrackingDebugTimer()
}
}
.store(in: &cancellables)
} }
private func refreshEnforceModeState() { private func refreshEnforceModeState() {
@@ -211,6 +224,7 @@ class EnforceModeService: ObservableObject {
eyeTrackingService.stopEyeTracking() eyeTrackingService.stopEyeTracking()
isCameraActive = false isCameraActive = false
stopFaceDetectionTimer() stopFaceDetectionTimer()
stopTrackingDebugTimer()
userCompliedWithBreak = false userCompliedWithBreak = false
} }
@@ -257,6 +271,48 @@ class EnforceModeService: ObservableObject {
faceDetectionTimer = nil faceDetectionTimer = nil
} }
private func startTrackingDebugTimer() {
stopTrackingDebugTimer()
trackingDebugTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] _ in
self?.logTrackingDebugSnapshot()
}
}
private func stopTrackingDebugTimer() {
trackingDebugTimer?.invalidate()
trackingDebugTimer = nil
}
private func logTrackingDebugSnapshot() {
guard isCameraActive else { return }
let debugState = eyeTrackingService.debugState
let faceWidth = debugState.faceWidthRatio.map { String(format: "%.3f", $0) } ?? "-"
let horizontal = debugState.normalizedHorizontal.map { String(format: "%.3f", $0) } ?? "-"
let vertical = debugState.normalizedVertical.map { String(format: "%.3f", $0) } ?? "-"
trackingLapStats.ingest(
faceWidth: debugState.faceWidthRatio,
horizontal: debugState.normalizedHorizontal,
vertical: debugState.normalizedVertical
)
logDebug(
"📊 Tracking | faceWidth=\(faceWidth) | h=\(horizontal) | v=\(vertical)",
category: "EyeTracking"
)
}
func logTrackingLap() {
logDebug("🏁 Tracking Lap", category: "EyeTracking")
logTrackingDebugSnapshot()
if let summary = trackingLapStats.summaryString() {
logDebug("📈 Lap Stats | \(summary)", category: "EyeTracking")
}
trackingLapStats.reset()
}
private func checkFaceDetectionTimeout() { private func checkFaceDetectionTimeout() {
guard isCameraActive else { guard isCameraActive else {
stopFaceDetectionTimer() stopFaceDetectionTimer()
@@ -295,3 +351,45 @@ class EnforceModeService: ObservableObject {
isTestMode = false isTestMode = false
} }
} }
private struct TrackingLapStats {
private var faceWidthValues: [Double] = []
private var horizontalValues: [Double] = []
private var verticalValues: [Double] = []
mutating func ingest(faceWidth: Double?, horizontal: Double?, vertical: Double?) {
if let faceWidth { faceWidthValues.append(faceWidth) }
if let horizontal { horizontalValues.append(horizontal) }
if let vertical { verticalValues.append(vertical) }
}
mutating func reset() {
faceWidthValues.removeAll(keepingCapacity: true)
horizontalValues.removeAll(keepingCapacity: true)
verticalValues.removeAll(keepingCapacity: true)
}
func summaryString() -> String? {
guard !faceWidthValues.isEmpty || !horizontalValues.isEmpty || !verticalValues.isEmpty else {
return nil
}
let faceWidth = stats(for: faceWidthValues)
let horizontal = stats(for: horizontalValues)
let vertical = stats(for: verticalValues)
return "faceWidth[\(faceWidth)] h[\(horizontal)] v[\(vertical)]"
}
private func stats(for values: [Double]) -> String {
guard let minValue = values.min(), let maxValue = values.max(), !values.isEmpty else {
return "-"
}
let mean = values.reduce(0, +) / Double(values.count)
return "min=\(format(minValue)) max=\(format(maxValue)) mean=\(format(mean))"
}
private func format(_ value: Double) -> String {
String(format: "%.3f", value)
}
}

View File

@@ -19,6 +19,7 @@ class EyeTrackingService: NSObject, ObservableObject {
private let cameraManager = CameraSessionManager() private let cameraManager = CameraSessionManager()
private let visionPipeline = VisionPipeline() private let visionPipeline = VisionPipeline()
private let processor: VisionGazeProcessor private let processor: VisionGazeProcessor
private var cancellables = Set<AnyCancellable>()
var previewLayer: AVCaptureVideoPreviewLayer? { var previewLayer: AVCaptureVideoPreviewLayer? {
cameraManager.previewLayer cameraManager.previewLayer
@@ -33,6 +34,41 @@ class EyeTrackingService: NSObject, ObservableObject {
self.processor = VisionGazeProcessor(config: config) self.processor = VisionGazeProcessor(config: config)
super.init() super.init()
cameraManager.delegate = self cameraManager.delegate = self
setupSettingsObserver()
}
private func setupSettingsObserver() {
SettingsManager.shared._settingsSubject
.receive(on: RunLoop.main)
.sink { [weak self] settings in
self?.applyStrictness(settings.enforceModeStrictness)
}
.store(in: &cancellables)
applyStrictness(SettingsManager.shared.settings.enforceModeStrictness)
}
private func applyStrictness(_ strictness: Double) {
let config = TrackingConfig(
horizontalAwayThreshold: 0.08,
verticalAwayThreshold: 0.12,
minBaselineSamples: TrackingConfig.default.minBaselineSamples,
baselineSmoothing: TrackingConfig.default.baselineSmoothing,
baselineUpdateThreshold: TrackingConfig.default.baselineUpdateThreshold,
minConfidence: TrackingConfig.default.minConfidence,
eyeClosedThreshold: TrackingConfig.default.eyeClosedThreshold,
baselineEnabled: TrackingConfig.default.baselineEnabled,
defaultCenterHorizontal: TrackingConfig.default.defaultCenterHorizontal,
defaultCenterVertical: TrackingConfig.default.defaultCenterVertical,
faceWidthSmoothing: TrackingConfig.default.faceWidthSmoothing,
faceWidthScaleMin: TrackingConfig.default.faceWidthScaleMin,
faceWidthScaleMax: 1.4,
eyeBoundsHorizontalPadding: TrackingConfig.default.eyeBoundsHorizontalPadding,
eyeBoundsVerticalPaddingUp: TrackingConfig.default.eyeBoundsVerticalPaddingUp,
eyeBoundsVerticalPaddingDown: TrackingConfig.default.eyeBoundsVerticalPaddingDown
)
processor.updateConfig(config)
} }
func startEyeTracking() async throws { func startEyeTracking() async throws {

View File

@@ -35,17 +35,59 @@ public struct EyeTrackingDebugState: Sendable {
public let leftPupil: CGPoint? public let leftPupil: CGPoint?
public let rightPupil: CGPoint? public let rightPupil: CGPoint?
public let imageSize: CGSize? public let imageSize: CGSize?
public let faceWidthRatio: Double?
public let normalizedHorizontal: Double?
public let normalizedVertical: Double?
public static let empty = EyeTrackingDebugState( public static let empty = EyeTrackingDebugState(
leftEyeRect: nil, leftEyeRect: nil,
rightEyeRect: nil, rightEyeRect: nil,
leftPupil: nil, leftPupil: nil,
rightPupil: nil, rightPupil: nil,
imageSize: nil imageSize: nil,
faceWidthRatio: nil,
normalizedHorizontal: nil,
normalizedVertical: nil
) )
} }
public struct TrackingConfig: Sendable { public struct TrackingConfig: Sendable {
public init(
horizontalAwayThreshold: Double,
verticalAwayThreshold: Double,
minBaselineSamples: Int,
baselineSmoothing: Double,
baselineUpdateThreshold: Double,
minConfidence: Double,
eyeClosedThreshold: Double,
baselineEnabled: Bool,
defaultCenterHorizontal: Double,
defaultCenterVertical: Double,
faceWidthSmoothing: Double,
faceWidthScaleMin: Double,
faceWidthScaleMax: Double,
eyeBoundsHorizontalPadding: Double,
eyeBoundsVerticalPaddingUp: Double,
eyeBoundsVerticalPaddingDown: Double
) {
self.horizontalAwayThreshold = horizontalAwayThreshold
self.verticalAwayThreshold = verticalAwayThreshold
self.minBaselineSamples = minBaselineSamples
self.baselineSmoothing = baselineSmoothing
self.baselineUpdateThreshold = baselineUpdateThreshold
self.minConfidence = minConfidence
self.eyeClosedThreshold = eyeClosedThreshold
self.baselineEnabled = baselineEnabled
self.defaultCenterHorizontal = defaultCenterHorizontal
self.defaultCenterVertical = defaultCenterVertical
self.faceWidthSmoothing = faceWidthSmoothing
self.faceWidthScaleMin = faceWidthScaleMin
self.faceWidthScaleMax = faceWidthScaleMax
self.eyeBoundsHorizontalPadding = eyeBoundsHorizontalPadding
self.eyeBoundsVerticalPaddingUp = eyeBoundsVerticalPaddingUp
self.eyeBoundsVerticalPaddingDown = eyeBoundsVerticalPaddingDown
}
public let horizontalAwayThreshold: Double public let horizontalAwayThreshold: Double
public let verticalAwayThreshold: Double public let verticalAwayThreshold: Double
public let minBaselineSamples: Int public let minBaselineSamples: Int
@@ -56,10 +98,16 @@ public struct TrackingConfig: Sendable {
public let baselineEnabled: Bool public let baselineEnabled: Bool
public let defaultCenterHorizontal: Double public let defaultCenterHorizontal: Double
public let defaultCenterVertical: Double public let defaultCenterVertical: Double
public let faceWidthSmoothing: Double
public let faceWidthScaleMin: Double
public let faceWidthScaleMax: Double
public let eyeBoundsHorizontalPadding: Double
public let eyeBoundsVerticalPaddingUp: Double
public let eyeBoundsVerticalPaddingDown: Double
public static let `default` = TrackingConfig( public static let `default` = TrackingConfig(
horizontalAwayThreshold: 0.12, horizontalAwayThreshold: 0.08,
verticalAwayThreshold: 0.18, verticalAwayThreshold: 0.12,
minBaselineSamples: 8, minBaselineSamples: 8,
baselineSmoothing: 0.15, baselineSmoothing: 0.15,
baselineUpdateThreshold: 0.08, baselineUpdateThreshold: 0.08,
@@ -67,6 +115,12 @@ public struct TrackingConfig: Sendable {
eyeClosedThreshold: 0.18, eyeClosedThreshold: 0.18,
baselineEnabled: true, baselineEnabled: true,
defaultCenterHorizontal: 0.5, defaultCenterHorizontal: 0.5,
defaultCenterVertical: 0.5 defaultCenterVertical: 0.5,
faceWidthSmoothing: 0.12,
faceWidthScaleMin: 0.85,
faceWidthScaleMax: 1.4,
eyeBoundsHorizontalPadding: 0.1,
eyeBoundsVerticalPaddingUp: 0.9,
eyeBoundsVerticalPaddingDown: 0.4
) )
} }

View File

@@ -30,6 +30,8 @@ final class VisionGazeProcessor: @unchecked Sendable {
} }
private let baselineModel = GazeBaselineModel() private let baselineModel = GazeBaselineModel()
private var faceWidthBaseline: Double?
private var faceWidthSmoothed: Double?
private var config: TrackingConfig private var config: TrackingConfig
init(config: TrackingConfig) { init(config: TrackingConfig) {
@@ -42,6 +44,8 @@ final class VisionGazeProcessor: @unchecked Sendable {
func resetBaseline() { func resetBaseline() {
baselineModel.reset() baselineModel.reset()
faceWidthBaseline = nil
faceWidthSmoothed = nil
} }
func process(analysis: VisionPipeline.FaceAnalysis) -> ObservationResult { func process(analysis: VisionPipeline.FaceAnalysis) -> ObservationResult {
@@ -84,13 +88,16 @@ final class VisionGazeProcessor: @unchecked Sendable {
let eyesClosed = detectEyesClosed(left: leftEye, right: rightEye) let eyesClosed = detectEyesClosed(left: leftEye, right: rightEye)
let (horizontal, vertical) = normalizePupilPosition(left: leftEye, right: rightEye) let (horizontal, vertical) = normalizePupilPosition(left: leftEye, right: rightEye)
let faceWidthRatio = Double(face.boundingBox.size.width)
let distanceScale = updateDistanceScale(faceWidthRatio: faceWidthRatio)
let confidence = calculateConfidence(leftEye: leftEye, rightEye: rightEye) let confidence = calculateConfidence(leftEye: leftEye, rightEye: rightEye)
let gazeState = decideGazeState( let gazeState = decideGazeState(
horizontal: horizontal, horizontal: horizontal,
vertical: vertical, vertical: vertical,
confidence: confidence, confidence: confidence,
eyesClosed: eyesClosed eyesClosed: eyesClosed,
distanceScale: distanceScale
) )
let debugState = EyeTrackingDebugState( let debugState = EyeTrackingDebugState(
@@ -98,7 +105,10 @@ final class VisionGazeProcessor: @unchecked Sendable {
rightEyeRect: rightEye?.frame, rightEyeRect: rightEye?.frame,
leftPupil: leftEye?.pupil, leftPupil: leftEye?.pupil,
rightPupil: rightEye?.pupil, rightPupil: rightEye?.pupil,
imageSize: analysis.imageSize imageSize: analysis.imageSize,
faceWidthRatio: faceWidthRatio,
normalizedHorizontal: horizontal,
normalizedVertical: vertical
) )
return ObservationResult( return ObservationResult(
@@ -132,10 +142,17 @@ final class VisionGazeProcessor: @unchecked Sendable {
pupilPoint = bounds.center pupilPoint = bounds.center
} }
let paddedFrame = expandRect(
CGRect(x: bounds.minX, y: bounds.minY, width: bounds.size.width, height: bounds.size.height),
horizontalPadding: config.eyeBoundsHorizontalPadding,
verticalPaddingUp: config.eyeBoundsVerticalPaddingUp,
verticalPaddingDown: config.eyeBoundsVerticalPaddingDown
)
let normalizedPupil: CGPoint? let normalizedPupil: CGPoint?
if let pupilPoint { if let pupilPoint {
let nx = clamp((pupilPoint.x - bounds.minX) / bounds.size.width) let nx = clamp((pupilPoint.x - paddedFrame.minX) / paddedFrame.size.width)
let ny = clamp((pupilPoint.y - bounds.minY) / bounds.size.height) let ny = clamp((pupilPoint.y - paddedFrame.minY) / paddedFrame.size.height)
normalizedPupil = CGPoint(x: nx, y: ny) normalizedPupil = CGPoint(x: nx, y: ny)
} else { } else {
normalizedPupil = nil normalizedPupil = nil
@@ -146,7 +163,7 @@ final class VisionGazeProcessor: @unchecked Sendable {
width: bounds.size.width, width: bounds.size.width,
height: bounds.size.height, height: bounds.size.height,
pupil: pupilPoint, pupil: pupilPoint,
frame: CGRect(x: bounds.minX, y: bounds.minY, width: bounds.size.width, height: bounds.size.height), frame: paddedFrame,
normalizedPupil: normalizedPupil, normalizedPupil: normalizedPupil,
hasPupilLandmarks: hasPupilLandmarks hasPupilLandmarks: hasPupilLandmarks
) )
@@ -204,6 +221,23 @@ final class VisionGazeProcessor: @unchecked Sendable {
min(1, max(0, value)) min(1, max(0, value))
} }
private func expandRect(
_ rect: CGRect,
horizontalPadding: Double,
verticalPaddingUp: Double,
verticalPaddingDown: Double
) -> CGRect {
let dx = rect.width * CGFloat(horizontalPadding)
let up = rect.height * CGFloat(verticalPaddingUp)
let down = rect.height * CGFloat(verticalPaddingDown)
return CGRect(
x: rect.origin.x - dx,
y: rect.origin.y - up,
width: rect.width + (dx * 2),
height: rect.height + up + down
)
}
private func averageCoordinate(left: CGFloat?, right: CGFloat?, fallback: Double?) -> Double? { private func averageCoordinate(left: CGFloat?, right: CGFloat?, fallback: Double?) -> Double? {
switch (left, right) { switch (left, right) {
case let (left?, right?): case let (left?, right?):
@@ -258,7 +292,8 @@ final class VisionGazeProcessor: @unchecked Sendable {
horizontal: Double?, horizontal: Double?,
vertical: Double?, vertical: Double?,
confidence: Double, confidence: Double,
eyesClosed: Bool eyesClosed: Bool,
distanceScale: Double
) -> GazeState { ) -> GazeState {
guard confidence >= config.minConfidence else { return .unknown } guard confidence >= config.minConfidence else { return .unknown }
guard let horizontal, let vertical else { return .unknown } guard let horizontal, let vertical else { return .unknown }
@@ -271,7 +306,21 @@ final class VisionGazeProcessor: @unchecked Sendable {
let deltaH = abs(horizontal - baseline.horizontal) let deltaH = abs(horizontal - baseline.horizontal)
let deltaV = abs(vertical - baseline.vertical) let deltaV = abs(vertical - baseline.vertical)
let away = deltaH > config.horizontalAwayThreshold || deltaV > config.verticalAwayThreshold let thresholdH = config.horizontalAwayThreshold * distanceScale
let thresholdV = config.verticalAwayThreshold * distanceScale
let lookingDown = vertical > baseline.vertical
let lookingUp = vertical < baseline.vertical
let verticalMultiplier: Double
if lookingDown {
verticalMultiplier = 1.2
} else if lookingUp {
verticalMultiplier = 1.8
} else {
verticalMultiplier = 1.0
}
let verticalAway = deltaV > (thresholdV * verticalMultiplier)
let away = deltaH > thresholdH || verticalAway
if config.baselineEnabled { if config.baselineEnabled {
if baseline.sampleCount < config.minBaselineSamples { if baseline.sampleCount < config.minBaselineSamples {
@@ -294,4 +343,28 @@ final class VisionGazeProcessor: @unchecked Sendable {
if !stable { return .unknown } if !stable { return .unknown }
return away ? .lookingAway : .lookingAtScreen return away ? .lookingAway : .lookingAtScreen
} }
private func updateDistanceScale(faceWidthRatio: Double) -> Double {
let smoothed: Double
if let existing = faceWidthSmoothed {
smoothed = existing + (faceWidthRatio - existing) * config.faceWidthSmoothing
} else {
smoothed = faceWidthRatio
}
faceWidthSmoothed = smoothed
if faceWidthBaseline == nil {
faceWidthBaseline = smoothed
return 1.0
}
let baseline = faceWidthBaseline ?? smoothed
guard baseline > 0 else { return 1.0 }
let ratio = baseline / max(0.0001, smoothed)
return clampDouble(ratio, min: config.faceWidthScaleMin, max: config.faceWidthScaleMax)
}
private func clampDouble(_ value: Double, min: Double, max: Double) -> Double {
Swift.min(max, Swift.max(min, value))
}
} }

View File

@@ -76,6 +76,12 @@ struct EnforceModeSetupContent: View {
eyeTrackingStatusView eyeTrackingStatusView
trackingConstantsView trackingConstantsView
} }
if enforceModeService.isEnforceModeEnabled {
strictnessControlView
}
if enforceModeService.isCameraActive {
trackingLapButton
}
privacyInfoView privacyInfoView
} }
@@ -330,9 +336,76 @@ struct EnforceModeSetupContent: View {
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
if let faceWidth = eyeTrackingService.debugState.faceWidthRatio {
HStack(spacing: 12) {
Text("Face Width:")
.font(.caption2)
.foregroundStyle(.secondary)
Text(String(format: "%.3f", faceWidth))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
if let horizontal = eyeTrackingService.debugState.normalizedHorizontal,
let vertical = eyeTrackingService.debugState.normalizedVertical {
HStack(spacing: 12) {
Text("Ratios:")
.font(.caption2)
.foregroundStyle(.secondary)
Text("H \(String(format: "%.3f", horizontal))")
.font(.caption2)
.foregroundStyle(.secondary)
Text("V \(String(format: "%.3f", vertical))")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
} }
} }
.padding(sectionPadding) .padding(sectionPadding)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius)) .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius))
} }
private var strictnessControlView: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Tracking Strictness")
.font(headerFont)
Slider(
value: $settingsManager.settings.enforceModeStrictness,
in: 0...1
)
.controlSize(.small)
HStack {
Text("Lenient")
.font(.caption2)
.foregroundStyle(.secondary)
Spacer()
Text("Strict")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding(sectionPadding)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: sectionCornerRadius))
}
private var trackingLapButton: some View {
Button(action: {
enforceModeService.logTrackingLap()
}) {
HStack {
Image(systemName: "flag.checkered")
Text("Lap Marker")
.font(.headline)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
.buttonStyle(.bordered)
.controlSize(.regular)
}
} }