general: 14.0 new min

This commit is contained in:
Michael Freno
2026-01-15 22:31:48 -05:00
parent 77bc2f9a92
commit 1ace5fd3f7
24 changed files with 689 additions and 929 deletions

View File

@@ -437,7 +437,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.4.1;
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -473,7 +473,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.4.1;
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze;
PRODUCT_NAME = "$(TARGET_NAME)";

View File

@@ -9,6 +9,15 @@
"revision" : "69faaefa7721fba9e434a52c16adf4329c9084db",
"version" : "4.6.0"
}
},
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"revision" : "5581748cef2bae787496fe6d61139aebe0a451f6",
"version" : "2.8.1"
}
}
],
"version" : 3

View File

@@ -10,9 +10,7 @@ import SwiftUI
@main
struct GazeApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
// Note: SettingsManager.shared is used directly here for SwiftUI view updates
// AppDelegate uses ServiceContainer for dependency injection
@StateObject private var settingsManager = SettingsManager.shared
@State private var settingsManager = SettingsManager.shared
init() {
// Handle test launch arguments

View File

@@ -2,47 +2,26 @@
// SettingsProviding.swift
// Gaze
//
// Protocol abstraction for SettingsManager to enable dependency injection and testing.
//
import Combine
import Foundation
/// Protocol that defines the interface for managing application settings.
/// This abstraction allows for dependency injection and easy mocking in tests.
@MainActor
protocol SettingsProviding: AnyObject, ObservableObject {
/// The current application settings
protocol SettingsProviding: AnyObject, Observable {
var settings: AppSettings { get set }
var settingsPublisher: AnyPublisher<AppSettings, Never> { get }
/// Publisher for observing settings changes
var settingsPublisher: Published<AppSettings>.Publisher { get }
/// Retrieves the timer configuration for a specific timer type
func timerConfiguration(for type: TimerType) -> TimerConfiguration
/// Updates the timer configuration for a specific timer type
func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration)
/// Returns all timer configurations
func allTimerConfigurations() -> [TimerType: TimerConfiguration]
/// Saves settings to persistent storage
func save()
/// Forces immediate save
func saveImmediately()
/// Loads settings from persistent storage
func load()
/// Resets settings to default values
func resetToDefaults()
}
/// Extension to provide the publisher for SettingsManager
extension SettingsManager: SettingsProviding {
var settingsPublisher: Published<AppSettings>.Publisher {
$settings
var settingsPublisher: AnyPublisher<AppSettings, Never> {
_settingsSubject.eraseToAnyPublisher()
}
}

View File

@@ -120,13 +120,18 @@ final class ServiceContainer {
/// A mock settings manager for use in ServiceContainer.forTesting()
/// This is a minimal implementation - use the full MockSettingsManager from tests for more features
@MainActor
final class MockSettingsManager: ObservableObject, SettingsProviding {
@Published var settings: AppSettings
@Observable
final class MockSettingsManager: SettingsProviding {
var settings: AppSettings
var settingsPublisher: Published<AppSettings>.Publisher {
$settings
@ObservationIgnored
private let _settingsSubject: CurrentValueSubject<AppSettings, Never>
var settingsPublisher: AnyPublisher<AppSettings, Never> {
_settingsSubject.eraseToAnyPublisher()
}
@ObservationIgnored
private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] = [
.lookAway: \.lookAwayTimer,
.blink: \.blinkTimer,
@@ -135,6 +140,7 @@ final class MockSettingsManager: ObservableObject, SettingsProviding {
init(settings: AppSettings = .defaults) {
self.settings = settings
self._settingsSubject = CurrentValueSubject(settings)
}
func timerConfiguration(for type: TimerType) -> TimerConfiguration {
@@ -149,6 +155,7 @@ final class MockSettingsManager: ObservableObject, SettingsProviding {
preconditionFailure("Unknown timer type: \(type)")
}
settings[keyPath: keyPath] = configuration
_settingsSubject.send(settings)
}
func allTimerConfigurations() -> [TimerType: TimerConfiguration] {
@@ -159,8 +166,11 @@ final class MockSettingsManager: ObservableObject, SettingsProviding {
return configs
}
func save() {}
func saveImmediately() {}
func save() { _settingsSubject.send(settings) }
func saveImmediately() { _settingsSubject.send(settings) }
func load() {}
func resetToDefaults() { settings = .defaults }
func resetToDefaults() {
settings = .defaults
_settingsSubject.send(settings)
}
}

View File

@@ -7,17 +7,31 @@
import Combine
import Foundation
import Observation
@MainActor
class SettingsManager: ObservableObject {
@Observable
final class SettingsManager {
static let shared = SettingsManager()
@Published var settings: AppSettings
var settings: AppSettings {
didSet { _settingsSubject.send(settings) }
}
@ObservationIgnored
let _settingsSubject = CurrentValueSubject<AppSettings, Never>(.defaults)
@ObservationIgnored
private let userDefaults = UserDefaults.standard
@ObservationIgnored
private let settingsKey = "gazeAppSettings"
@ObservationIgnored
private var saveCancellable: AnyCancellable?
private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] =
[
@ObservationIgnored
private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] = [
.lookAway: \.lookAwayTimer,
.blink: \.blinkTimer,
.posture: \.postureTimer,
@@ -25,17 +39,12 @@ class SettingsManager: ObservableObject {
private init() {
self.settings = Self.loadSettings()
_settingsSubject.send(settings)
setupDebouncedSave()
}
deinit {
saveCancellable?.cancel()
// Final save is called by AppDelegate.applicationWillTerminate
}
private func setupDebouncedSave() {
saveCancellable =
$settings
saveCancellable = _settingsSubject
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
.sink { [weak self] _ in
self?.save()
@@ -46,30 +55,22 @@ class SettingsManager: ObservableObject {
guard let data = UserDefaults.standard.data(forKey: "gazeAppSettings") else {
return .defaults
}
do {
let settings = try JSONDecoder().decode(AppSettings.self, from: data)
return settings
return try JSONDecoder().decode(AppSettings.self, from: data)
} catch {
return .defaults
}
}
/// Saves settings to UserDefaults.
/// Note: Settings are automatically saved via debouncing (500ms delay) when the `settings` property changes.
/// This method is also called explicitly during app termination to ensure final state is persisted.
func save() {
do {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(settings)
userDefaults.set(data, forKey: settingsKey)
} catch {
}
} catch {}
}
/// Forces immediate save and ensures UserDefaults are persisted to disk.
/// Use this for critical save points like app termination or system sleep.
func saveImmediately() {
save()
}
@@ -96,7 +97,6 @@ class SettingsManager: ObservableObject {
settings[keyPath: keyPath] = configuration
}
/// Returns all timer configurations as a dictionary
func allTimerConfigurations() -> [TimerType: TimerConfiguration] {
var configs: [TimerType: TimerConfiguration] = [:]
for (type, keyPath) in timerConfigKeyPaths {
@@ -104,15 +104,4 @@ class SettingsManager: ObservableObject {
}
return configs
}
/// Validates that all timer types have configuration mappings
private func validateTimerConfigMappings() {
let allTypes = Set(TimerType.allCases)
let mappedTypes = Set(timerConfigKeyPaths.keys)
let missing = allTypes.subtracting(mappedTypes)
if !missing.isEmpty {
preconditionFailure("Missing timer configuration mappings for: \(missing)")
}
}
}

View File

@@ -0,0 +1,35 @@
//
// PreviewWindowHelper.swift
// Gaze
//
// Created by Mike Freno on 1/15/26.
//
import AppKit
import SwiftUI
enum PreviewWindowHelper {
static func showPreview<Content: View>(
on screen: NSScreen,
content: Content
) -> NSWindowController {
let panel = NSPanel(
contentRect: screen.frame,
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
panel.level = .screenSaver
panel.backgroundColor = .clear
panel.isOpaque = false
panel.hasShadow = false
panel.ignoresMouseEvents = false
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.contentView = NSHostingView(rootView: content)
panel.setFrame(screen.frame, display: true)
let controller = NSWindowController(window: panel)
controller.showWindow(nil)
return controller
}
}

View File

@@ -0,0 +1,34 @@
//
// SetupHeader.swift
// Gaze
//
// Created by Mike Freno on 1/15/26.
//
import SwiftUI
struct SetupHeader: View {
let icon: String
let title: String
let color: Color
var body: some View {
VStack(spacing: 16) {
Image(systemName: icon)
.font(.system(size: 60))
.foregroundColor(color)
Text(title)
.font(.system(size: 28, weight: .bold))
}
.padding(.top, 20)
.padding(.bottom, 30)
}
}
#Preview("SetupHeader") {
VStack(spacing: 40) {
SetupHeader(icon: "eye.fill", title: "Look Away Reminder", color: .accentColor)
SetupHeader(icon: "eye.circle", title: "Blink Reminder", color: .green)
SetupHeader(icon: "figure.stand", title: "Posture Reminder", color: .orange)
}
}

View File

@@ -1,3 +1,10 @@
//
// OnboardingContainerView.swift
// Gaze
//
// Created by Mike Freno on 1/7/26.
//
import AppKit
import SwiftUI
@@ -27,9 +34,7 @@ final class OnboardingWindowPresenter {
private var closeObserver: NSObjectProtocol?
func show(settingsManager: SettingsManager) {
if activateIfPresent() {
return
}
if activateIfPresent() { return }
createWindow(settingsManager: settingsManager)
}
@@ -39,26 +44,16 @@ final class OnboardingWindowPresenter {
windowController = nil
return false
}
// Ensure the window is brought to front and focused properly for menu bar apps
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
// Additional focus handling for menu bar applications
if let window = windowController?.window {
window.makeMain()
}
return true
}
func close() {
windowController?.close()
windowController = nil
if let closeObserver {
NotificationCenter.default.removeObserver(closeObserver)
self.closeObserver = nil
}
removeCloseObserver()
}
private func createWindow(settingsManager: SettingsManager) {
@@ -85,34 +80,29 @@ final class OnboardingWindowPresenter {
windowController = controller
closeObserver.map(NotificationCenter.default.removeObserver)
removeCloseObserver()
closeObserver = NotificationCenter.default.addObserver(
forName: NSWindow.willCloseNotification,
object: window,
queue: .main
) { [weak self] _ in
self?.windowController = nil
if let closeObserver = self?.closeObserver {
NotificationCenter.default.removeObserver(closeObserver)
}
self?.closeObserver = nil
// Notify AppDelegate that onboarding window closed
self?.removeCloseObserver()
NotificationCenter.default.post(name: Notification.Name("OnboardingWindowDidClose"), object: nil)
}
}
deinit {
if let closeObserver {
NotificationCenter.default.removeObserver(closeObserver)
private func removeCloseObserver() {
if let observer = closeObserver {
NotificationCenter.default.removeObserver(observer)
closeObserver = nil
}
}
}
struct OnboardingContainerView: View {
@ObservedObject var settingsManager: SettingsManager
@Bindable var settingsManager: SettingsManager
@State private var currentPage = 0
@Environment(\.dismiss) private var dismiss
var body: some View {
ZStack {
@@ -122,46 +112,42 @@ struct OnboardingContainerView: View {
TabView(selection: $currentPage) {
WelcomeView()
.tag(0)
.tabItem {
Image(systemName: "hand.wave.fill")
}
.tabItem { Image(systemName: "hand.wave.fill") }
LookAwaySetupView(settingsManager: settingsManager)
.tag(1)
.tabItem {
Image(systemName: "eye.fill")
}
.tabItem { Image(systemName: "eye.fill") }
BlinkSetupView(settingsManager: settingsManager)
.tag(2)
.tabItem {
Image(systemName: "eye.circle.fill")
}
.tabItem { Image(systemName: "eye.circle.fill") }
PostureSetupView(settingsManager: settingsManager)
.tag(3)
.tabItem {
Image(systemName: "figure.stand")
}
.tabItem { Image(systemName: "figure.stand") }
GeneralSetupView(
settingsManager: settingsManager,
isOnboarding: true
)
GeneralSetupView(settingsManager: settingsManager, isOnboarding: true)
.tag(4)
.tabItem {
Image(systemName: "gearshape.fill")
}
.tabItem { Image(systemName: "gearshape.fill") }
CompletionView()
.tag(5)
.tabItem {
Image(systemName: "checkmark.circle.fill")
}
.tabItem { Image(systemName: "checkmark.circle.fill") }
}
.tabViewStyle(.automatic)
if currentPage >= 0 {
navigationButtons
}
}
#if APPSTORE
.frame(minWidth: 1000, minHeight: 700)
#else
.frame(minWidth: 1000, minHeight: 900)
#endif
}
@ViewBuilder
private var navigationButtons: some View {
HStack(spacing: 12) {
if currentPage > 0 {
Button(action: { currentPage -= 1 }) {
@@ -170,16 +156,12 @@ struct OnboardingContainerView: View {
Text("Back")
}
.font(.headline)
.frame(
minWidth: 100, maxWidth: .infinity, minHeight: 44,
maxHeight: 44, alignment: .center
)
.frame(minWidth: 100, maxWidth: .infinity, minHeight: 44, maxHeight: 44)
.foregroundColor(.primary)
.contentShape(RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
.glassEffectIfAvailable(
GlassStyle.regular.interactive(), in: .rect(cornerRadius: 10))
.glassEffectIfAvailable(GlassStyle.regular.interactive(), in: .rect(cornerRadius: 10))
}
Button(action: {
@@ -189,60 +171,28 @@ struct OnboardingContainerView: View {
currentPage += 1
}
}) {
Text(
currentPage == 0
? "Let's Get Started"
: currentPage == 5 ? "Get Started" : "Continue"
)
Text(currentPage == 0 ? "Let's Get Started" : currentPage == 5 ? "Get Started" : "Continue")
.font(.headline)
.frame(
minWidth: 100, maxWidth: .infinity, minHeight: 44, maxHeight: 44,
alignment: .center
)
.frame(minWidth: 100, maxWidth: .infinity, minHeight: 44, maxHeight: 44)
.foregroundColor(.white)
.contentShape(RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
.glassEffectIfAvailable(
GlassStyle.regular.tint(currentPage == 5 ? .green : .accentColor)
.interactive(),
in: .rect(cornerRadius: 10))
GlassStyle.regular.tint(currentPage == 5 ? .green : .accentColor).interactive(),
in: .rect(cornerRadius: 10)
)
}
.padding(.horizontal, 40)
.padding(.bottom, 20)
}
}
}
#if APPSTORE
.frame(
minWidth: 1000,
minHeight: 700
)
#else
.frame(
minWidth: 1000,
minHeight: 900
)
#endif
}
private func completeOnboarding() {
// Mark onboarding as complete - settings are already being updated in real-time
settingsManager.settings.hasCompletedOnboarding = true
OnboardingWindowPresenter.shared.close()
}
}
dismiss()
DispatchQueue.main.asyncAfter(deadline: .now()) {
if let menuBarWindow = NSApp.windows.first(where: {
$0.className.contains("MenuBarExtra") || $0.className.contains("StatusBar")
}),
let statusItem = menuBarWindow.value(forKey: "statusItem") as? NSStatusItem
{
statusItem.button?.performClick(nil)
}
}
}
}
#Preview("Onboarding Container") {
OnboardingContainerView(settingsManager: SettingsManager.shared)
}

View File

@@ -15,9 +15,7 @@ final class SettingsWindowPresenter {
private var closeObserver: NSObjectProtocol?
func show(settingsManager: SettingsManager, initialTab: Int = 0) {
if focusExistingWindow(tab: initialTab) {
return
}
if focusExistingWindow(tab: initialTab) { return }
createWindow(settingsManager: settingsManager, initialTab: initialTab)
}
@@ -67,15 +65,12 @@ final class SettingsWindowPresenter {
window.setFrameAutosaveName("SettingsWindow")
window.isReleasedWhenClosed = false
let contentView = SettingsWindowView(
settingsManager: settingsManager,
initialTab: initialTab
window.contentView = NSHostingView(
rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: initialTab)
)
window.contentView = NSHostingView(rootView: contentView)
let controller = NSWindowController(window: window)
controller.showWindow(nil)
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
@@ -90,32 +85,21 @@ final class SettingsWindowPresenter {
Task { @MainActor [weak self] in
self?.windowController = nil
self?.removeCloseObserver()
// Notify AppDelegate that settings window closed
NotificationCenter.default.post(name: Notification.Name("SettingsWindowDidClose"), object: nil)
}
}
}
@MainActor
private func removeCloseObserver() {
if let closeObserver {
NotificationCenter.default.removeObserver(closeObserver)
self.closeObserver = nil
}
}
deinit {
// Capture observer locally to avoid actor isolation issues
// NotificationCenter.removeObserver is thread-safe
if let observer = closeObserver {
NotificationCenter.default.removeObserver(observer)
closeObserver = nil
}
}
}
struct SettingsWindowView: View {
@ObservedObject var settingsManager: SettingsManager
@Bindable var settingsManager: SettingsManager
@State private var selectedSection: SettingsSection
init(settingsManager: SettingsManager, initialTab: Int = 0) {
@@ -140,26 +124,21 @@ struct SettingsWindowView: View {
ScrollView {
detailView(for: selectedSection)
}
}.onReceive(
NotificationCenter.default.publisher(
for: Notification.Name("SwitchToSettingsTab"))
) { notification in
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("SwitchToSettingsTab"))) { notification in
if let tab = notification.object as? Int,
let section = SettingsSection(rawValue: tab)
{
let section = SettingsSection(rawValue: tab) {
selectedSection = section
}
}
#if DEBUG
Divider()
HStack {
Button("Retrigger Onboarding") {
retriggerOnboarding()
}
.buttonStyle(.bordered)
Spacer()
}
.padding()
@@ -167,15 +146,9 @@ struct SettingsWindowView: View {
}
}
#if APPSTORE
.frame(
minWidth: 1000,
minHeight: 700
)
.frame(minWidth: 1000, minHeight: 700)
#else
.frame(
minWidth: 1000,
minHeight: 900
)
.frame(minWidth: 1000, minHeight: 900)
#endif
}
@@ -183,10 +156,7 @@ struct SettingsWindowView: View {
private func detailView(for section: SettingsSection) -> some View {
switch section {
case .general:
GeneralSetupView(
settingsManager: settingsManager,
isOnboarding: false
)
GeneralSetupView(settingsManager: settingsManager, isOnboarding: false)
case .lookAway:
LookAwaySetupView(settingsManager: settingsManager)
case .blink:
@@ -210,7 +180,6 @@ struct SettingsWindowView: View {
#if DEBUG
private func retriggerOnboarding() {
SettingsWindowPresenter.shared.close()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
settingsManager.settings.hasCompletedOnboarding = false
}

View File

@@ -7,10 +7,9 @@
import SwiftUI
// Wrapper to properly observe AppDelegate changes in MenuBarExtra
struct MenuBarContentWrapper: View {
@ObservedObject var appDelegate: AppDelegate
@ObservedObject var settingsManager: SettingsManager
@Bindable var settingsManager: SettingsManager
var onQuit: () -> Void
var onOpenSettings: () -> Void
var onOpenSettingsTab: (Int) -> Void
@@ -71,7 +70,7 @@ struct MenuBarHoverButtonStyle: ButtonStyle {
struct MenuBarContentView: View {
var timerEngine: TimerEngine?
@ObservedObject var settingsManager: SettingsManager
@Bindable var settingsManager: SettingsManager
@Environment(\.dismiss) private var dismiss
var onQuit: () -> Void
var onOpenSettings: () -> Void
@@ -251,7 +250,7 @@ struct MenuBarContentView: View {
struct TimerStatusRowWithIndividualControls: View {
let identifier: TimerIdentifier
@ObservedObject var timerEngine: TimerEngine
@ObservedObject var settingsManager: SettingsManager
@Bindable var settingsManager: SettingsManager
var onSkip: () -> Void
var onDevTrigger: (() -> Void)? = nil
var onTogglePause: (Bool) -> Void

View File

@@ -9,58 +9,36 @@ import AppKit
import SwiftUI
struct BlinkSetupView: View {
@ObservedObject var settingsManager: SettingsManager
@Bindable var settingsManager: SettingsManager
@State private var previewWindowController: NSWindowController?
var body: some View {
VStack(spacing: 0) {
// Fixed header section
VStack(spacing: 16) {
Image(systemName: "eye.circle")
.font(.system(size: 60))
.foregroundColor(.green)
SetupHeader(icon: "eye.circle", title: "Blink Reminder", color: .green)
Text("Blink Reminder")
.font(.system(size: 28, weight: .bold))
}
.padding(.top, 20)
.padding(.bottom, 30)
// Vertically centered content
Spacer()
VStack(spacing: 30) {
HStack(spacing: 12) {
Button(action: {
if let url = URL(
string:
"https://www.aao.org/eye-health/tips-prevention/computer-usage#:~:text=Humans normally blink about 15 times in one minute. However, studies show that we only blink about 5 to 7 times in a minute while using computers and other digital screen devices."
) {
#if os(iOS)
UIApplication.shared.open(url)
#elseif os(macOS)
if let url = URL(string: "https://www.aao.org/eye-health/tips-prevention/computer-usage#:~:text=Humans normally blink about 15 times in one minute. However, studies show that we only blink about 5 to 7 times in a minute while using computers and other digital screen devices.") {
NSWorkspace.shared.open(url)
#endif
}
}) {
Image(systemName: "info.circle")
.foregroundColor(.white)
}.buttonStyle(.plain)
Text(
"We blink much less when focusing on screens. Regular blink reminders help prevent dry eyes."
)
}
.buttonStyle(.plain)
Text("We blink much less when focusing on screens. Regular blink reminders help prevent dry eyes.")
.font(.headline)
.foregroundColor(.white)
}
.padding()
.glassEffectIfAvailable(
GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8))
.glassEffectIfAvailable(GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8))
VStack(alignment: .leading, spacing: 20) {
Toggle("Enable Blink Reminders", isOn: Binding(
get: { settingsManager.settings.blinkTimer.enabled },
set: { settingsManager.settings.blinkTimer.enabled = $0 }
))
Toggle("Enable Blink Reminders", isOn: $settingsManager.settings.blinkTimer.enabled)
.font(.headline)
if settingsManager.settings.blinkTimer.enabled {
@@ -74,7 +52,10 @@ struct BlinkSetupView: View {
value: Binding(
get: { Double(settingsManager.settings.blinkTimer.intervalSeconds / 60) },
set: { settingsManager.settings.blinkTimer.intervalSeconds = Int($0) * 60 }
), in: 1...20, step: 1)
),
in: 1...20,
step: 1
)
Text("\(settingsManager.settings.blinkTimer.intervalSeconds / 60) min")
.frame(width: 60, alignment: .trailing)
@@ -87,23 +68,16 @@ struct BlinkSetupView: View {
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
if settingsManager.settings.blinkTimer.enabled {
Text(
"You will be subtly reminded every \(settingsManager.settings.blinkTimer.intervalSeconds / 60) minutes to blink"
)
Text("You will be subtly reminded every \(settingsManager.settings.blinkTimer.intervalSeconds / 60) minutes to blink")
.font(.subheadline)
.foregroundColor(.secondary)
} else {
Text(
"Blink reminders are currently disabled."
)
Text("Blink reminders are currently disabled.")
.font(.caption)
.foregroundColor(.secondary)
}
// Preview button
Button(action: {
showPreviewWindow()
}) {
Button(action: showPreviewWindow) {
HStack(spacing: 8) {
Image(systemName: "eye")
.foregroundColor(.white)
@@ -116,9 +90,7 @@ struct BlinkSetupView: View {
.contentShape(RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
.glassEffectIfAvailable(
GlassStyle.regular.tint(.accentColor).interactive(), in: .rect(cornerRadius: 10)
)
.glassEffectIfAvailable(GlassStyle.regular.tint(.accentColor).interactive(), in: .rect(cornerRadius: 10))
}
Spacer()
@@ -130,35 +102,12 @@ struct BlinkSetupView: View {
private func showPreviewWindow() {
guard let screen = NSScreen.main else { return }
let window = NSWindow(
contentRect: screen.frame,
styleMask: [.borderless, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.level = .floating
window.isOpaque = false
window.backgroundColor = .clear
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
window.acceptsMouseMovedEvents = true
let contentView = BlinkReminderView(
sizePercentage: settingsManager.settings.subtleReminderSize.percentage
) {
[weak window] in
window?.close()
previewWindowController = PreviewWindowHelper.showPreview(
on: screen,
content: BlinkReminderView(sizePercentage: settingsManager.settings.subtleReminderSize.percentage) { [weak previewWindowController] in
previewWindowController?.window?.close()
}
window.contentView = NSHostingView(rootView: contentView)
window.makeFirstResponder(window.contentView)
let windowController = NSWindowController(window: window)
windowController.showWindow(nil)
window.makeKeyAndOrderFront(nil)
previewWindowController = windowController
)
}
}

View File

@@ -10,7 +10,7 @@ import SwiftUI
import Foundation
struct EnforceModeSetupView: View {
@ObservedObject var settingsManager: SettingsManager
@Bindable var settingsManager: SettingsManager
@ObservedObject var cameraService = CameraAccessService.shared
@ObservedObject var eyeTrackingService = EyeTrackingService.shared
@ObservedObject var enforceModeService = EnforceModeService.shared
@@ -26,15 +26,7 @@ struct EnforceModeSetupView: View {
var body: some View {
VStack(spacing: 0) {
VStack(spacing: 16) {
Image(systemName: "video.fill")
.font(.system(size: 60))
.foregroundColor(.accentColor)
Text("Enforce Mode")
.font(.system(size: 28, weight: .bold))
}
.padding(.top, 20)
.padding(.bottom, 30)
SetupHeader(icon: "video.fill", title: "Enforce Mode", color: .accentColor)
Spacer()

View File

@@ -8,22 +8,13 @@
import SwiftUI
struct GeneralSetupView: View {
@ObservedObject var settingsManager: SettingsManager
@ObservedObject var updateManager = UpdateManager.shared
@Bindable var settingsManager: SettingsManager
var updateManager = UpdateManager.shared
var isOnboarding: Bool = true
var body: some View {
VStack(spacing: 0) {
// Fixed header section
VStack(spacing: 16) {
Image(systemName: "gearshape.fill")
.font(.system(size: 60))
.foregroundColor(.accentColor)
Text(isOnboarding ? "Final Settings" : "General Settings")
.font(.system(size: 28, weight: .bold))
}
.padding(.top, 20)
.padding(.bottom, 30)
SetupHeader(icon: "gearshape.fill", title: isOnboarding ? "Final Settings" : "General Settings", color: .accentColor)
Spacer()
VStack(spacing: 30) {
@@ -33,6 +24,27 @@ struct GeneralSetupView: View {
.multilineTextAlignment(.center)
VStack(spacing: 20) {
launchAtLoginToggle
#if !APPSTORE
softwareUpdatesSection
#endif
subtleReminderSizeSection
#if !APPSTORE
supportSection
#endif
}
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.background(.clear)
}
private var launchAtLoginToggle: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Launch at Login")
@@ -42,22 +54,18 @@ struct GeneralSetupView: View {
.foregroundColor(.secondary)
}
Spacer()
Toggle(
"",
isOn: Binding(
get: { settingsManager.settings.launchAtLogin },
set: { settingsManager.settings.launchAtLogin = $0 }
)
)
Toggle("", isOn: $settingsManager.settings.launchAtLogin)
.labelsHidden()
.onChange(of: settingsManager.settings.launchAtLogin) { isEnabled in
.onChange(of: settingsManager.settings.launchAtLogin) { _, isEnabled in
applyLaunchAtLoginSetting(enabled: isEnabled)
}
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
#if !APPSTORE
private var softwareUpdatesSection: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Software Updates")
@@ -83,17 +91,19 @@ struct GeneralSetupView: View {
}
.buttonStyle(.bordered)
Toggle(
"Automatically check for updates",
isOn: $updateManager.automaticallyChecksForUpdates
)
Toggle("Automatically check for updates", isOn: Binding(
get: { updateManager.automaticallyChecksForUpdates },
set: { updateManager.automaticallyChecksForUpdates = $0 }
))
.labelsHidden()
.help("Check for new versions of Gaze in the background")
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
#endif
private var subtleReminderSizeSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Subtle Reminder Size")
.font(.headline)
@@ -104,29 +114,16 @@ struct GeneralSetupView: View {
HStack(spacing: 12) {
ForEach(ReminderSize.allCases, id: \.self) { size in
Button(action: {
settingsManager.settings.subtleReminderSize = size
}) {
Button(action: { settingsManager.settings.subtleReminderSize = size }) {
VStack(spacing: 8) {
Circle()
.fill(
settingsManager.settings.subtleReminderSize == size
? Color.accentColor
: Color.secondary.opacity(0.3)
)
.frame(
width: iconSize(for: size),
height: iconSize(for: size))
.fill(settingsManager.settings.subtleReminderSize == size ? Color.accentColor : Color.secondary.opacity(0.3))
.frame(width: iconSize(for: size), height: iconSize(for: size))
Text(size.displayName)
.font(.caption)
.fontWeight(
settingsManager.settings.subtleReminderSize == size
? .semibold : .regular
)
.foregroundColor(
settingsManager.settings.subtleReminderSize == size
? .primary : .secondary)
.fontWeight(settingsManager.settings.subtleReminderSize == size ? .semibold : .regular)
.foregroundColor(settingsManager.settings.subtleReminderSize == size ? .primary : .secondary)
}
.frame(maxWidth: .infinity, minHeight: 60)
.padding(.vertical, 12)
@@ -142,83 +139,35 @@ struct GeneralSetupView: View {
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
#if !APPSTORE
private var supportSection: some View {
VStack(spacing: 12) {
Text("Support & Contribute")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
// GitHub Link
Button(action: {
if let url = URL(string: "https://github.com/mikefreno/Gaze") {
NSWorkspace.shared.open(url)
}
}) {
HStack {
Image(systemName: "chevron.left.forwardslash.chevron.right")
.font(.title3)
VStack(alignment: .leading, spacing: 2) {
Text("View on GitHub")
.font(.subheadline)
.fontWeight(.semibold)
Text("Star the repo, report issues, contribute")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
}
.padding()
.frame(maxWidth: .infinity)
.contentShape(RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
.glassEffectIfAvailable(
GlassStyle.regular.interactive(), in: .rect(cornerRadius: 10))
ExternalLinkButton(
icon: "chevron.left.forwardslash.chevron.right",
title: "View on GitHub",
subtitle: "Star the repo, report issues, contribute",
url: "https://github.com/mikefreno/Gaze",
tint: nil
)
Button(action: {
if let url = URL(string: "https://buymeacoffee.com/mikefreno") {
NSWorkspace.shared.open(url)
}
}) {
HStack {
Image(systemName: "cup.and.saucer.fill")
.font(.title3)
.foregroundColor(.brown)
VStack(alignment: .leading, spacing: 2) {
Text("Buy Me a Coffee")
.font(.subheadline)
.fontWeight(.semibold)
Text("Support development of Gaze")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
ExternalLinkButton(
icon: "cup.and.saucer.fill",
iconColor: .brown,
title: "Buy Me a Coffee",
subtitle: "Support development of Gaze",
url: "https://buymeacoffee.com/mikefreno",
tint: .orange
)
}
.padding()
.frame(maxWidth: .infinity)
.cornerRadius(10)
.contentShape(RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
.glassEffectIfAvailable(
GlassStyle.regular.tint(.orange).interactive(),
in: .rect(cornerRadius: 10))
}
.padding()
#endif
}
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.background(.clear)
}
private func applyLaunchAtLoginSetting(enabled: Bool) {
do {
@@ -227,9 +176,7 @@ struct GeneralSetupView: View {
} else {
try LaunchAtLoginManager.disable()
}
} catch {
//TODO: see what can be done here
}
} catch {}
}
private func iconSize(for size: ReminderSize) -> CGFloat {
@@ -241,9 +188,48 @@ struct GeneralSetupView: View {
}
}
#Preview("Settings Onboarding") {
GeneralSetupView(
settingsManager: SettingsManager.shared,
isOnboarding: true
struct ExternalLinkButton: View {
let icon: String
var iconColor: Color = .primary
let title: String
let subtitle: String
let url: String
let tint: Color?
var body: some View {
Button(action: {
if let url = URL(string: url) {
NSWorkspace.shared.open(url)
}
}) {
HStack {
Image(systemName: icon)
.font(.title3)
.foregroundColor(iconColor)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline)
.fontWeight(.semibold)
Text(subtitle)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
}
.padding()
.frame(maxWidth: .infinity)
.contentShape(RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
.glassEffectIfAvailable(
tint != nil ? GlassStyle.regular.tint(tint!).interactive() : GlassStyle.regular.interactive(),
in: .rect(cornerRadius: 10)
)
}
}
#Preview("Settings Onboarding") {
GeneralSetupView(settingsManager: SettingsManager.shared, isOnboarding: true)
}

View File

@@ -8,38 +8,22 @@
import AppKit
import SwiftUI
#if os(iOS)
import UIKit
#endif
struct LookAwaySetupView: View {
@ObservedObject var settingsManager: SettingsManager
@Bindable var settingsManager: SettingsManager
@State private var previewWindowController: NSWindowController?
@ObservedObject var cameraAccess = CameraAccessService.shared
var cameraAccess = CameraAccessService.shared
@State private var failedCameraAccess = false
var body: some View {
VStack(spacing: 0) {
// Fixed header section
VStack(spacing: 16) {
Image(systemName: "eye.fill")
.font(.system(size: 60))
.foregroundColor(.accentColor)
Text("Look Away Reminder")
.font(.system(size: 28, weight: .bold))
}
.padding(.top, 20)
.padding(.bottom, 30)
SetupHeader(icon: "eye.fill", title: "Look Away Reminder", color: .accentColor)
Spacer()
VStack(spacing: 30) {
InfoBox(
text: "Suggested: 20-20-20 rule",
url:
"https://journals.co.za/doi/abs/10.4102/aveh.v79i1.554#:~:text=the 20/20/20 rule induces significant changes in dry eye symptoms and tear film and some limited changes for ocular surface integrity."
url: "https://journals.co.za/doi/abs/10.4102/aveh.v79i1.554#:~:text=the 20/20/20 rule induces significant changes in dry eye symptoms and tear film and some limited changes for ocular surface integrity."
)
SliderSection(
@@ -51,8 +35,7 @@ struct LookAwaySetupView: View {
)
},
set: { newValue in
settingsManager.settings.lookAwayTimer.intervalSeconds =
(newValue.val ?? 20) * 60
settingsManager.settings.lookAwayTimer.intervalSeconds = (newValue.val ?? 20) * 60
}
),
countdownSettings: Binding(
@@ -66,23 +49,13 @@ struct LookAwaySetupView: View {
settingsManager.settings.lookAwayCountdownSeconds = newValue.val ?? 20
}
),
enabled: Binding(
get: { settingsManager.settings.lookAwayTimer.enabled },
set: { settingsManager.settings.lookAwayTimer.enabled = $0 }
),
enabled: $settingsManager.settings.lookAwayTimer.enabled,
type: "Look away",
previewFunc: showPreviewWindow
)
Toggle(
"Enable enforcement mode",
isOn: Binding(
get: { settingsManager.settings.enforcementMode },
set: { settingsManager.settings.enforcementMode = $0 }
)
)
.onChange(
of: settingsManager.settings.enforcementMode,
) { newMode in
Toggle("Enable enforcement mode", isOn: $settingsManager.settings.enforcementMode)
.onChange(of: settingsManager.settings.enforcementMode) { _, newMode in
if newMode && !cameraAccess.isCameraAuthorized {
Task {
do {
@@ -93,14 +66,9 @@ struct LookAwaySetupView: View {
}
}
}
}
}
}
}
#if failedCameraAccess
Text(
"Camera access denied. Please enable camera access in System Settings if you want to use enforcement mode."
)
#endif
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -110,35 +78,12 @@ struct LookAwaySetupView: View {
private func showPreviewWindow() {
guard let screen = NSScreen.main else { return }
let window = NSWindow(
contentRect: screen.frame,
styleMask: [.borderless, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.level = .floating
window.isOpaque = false
window.backgroundColor = .clear
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
window.acceptsMouseMovedEvents = true
let contentView = LookAwayReminderView(
countdownSeconds: settingsManager.settings.lookAwayCountdownSeconds
) {
[weak window] in
window?.close()
previewWindowController = PreviewWindowHelper.showPreview(
on: screen,
content: LookAwayReminderView(countdownSeconds: settingsManager.settings.lookAwayCountdownSeconds) { [weak previewWindowController] in
previewWindowController?.window?.close()
}
window.contentView = NSHostingView(rootView: contentView)
window.makeFirstResponder(window.contentView)
let windowController = NSWindowController(window: window)
windowController.showWindow(nil)
window.makeKeyAndOrderFront(nil)
previewWindowController = windowController
)
}
}

View File

@@ -9,55 +9,33 @@ import AppKit
import SwiftUI
struct PostureSetupView: View {
@ObservedObject var settingsManager: SettingsManager
@Bindable var settingsManager: SettingsManager
@State private var previewWindowController: NSWindowController?
var body: some View {
VStack(spacing: 0) {
// Fixed header section
VStack(spacing: 16) {
Image(systemName: "figure.stand")
.font(.system(size: 60))
.foregroundColor(.orange)
SetupHeader(icon: "figure.stand", title: "Posture Reminder", color: .orange)
Text("Posture Reminder")
.font(.system(size: 28, weight: .bold))
}
.padding(.top, 20)
.padding(.bottom, 30)
// Vertically centered content
Spacer()
VStack(spacing: 30) {
HStack(spacing: 12) {
Button(action: {
// Using properly URL-encoded text fragment
// Points to key findings about sitting posture and behavior relationship with LBP
if let url = URL(
string:
"https://pubmed.ncbi.nlm.nih.gov/40111906/#:~:text=For%20studies%20exploring%20sitting%20posture%2C%20seven%20found%20a%20relationship%20with%20LBP.%20Regarding%20studies%20on%20sitting%20behavior%2C%20only%20one%20showed%20no%20relationship%20between%20LBP%20prevalence"
) {
#if os(iOS)
UIApplication.shared.open(url)
#elseif os(macOS)
if let url = URL(string: "https://pubmed.ncbi.nlm.nih.gov/40111906/#:~:text=For%20studies%20exploring%20sitting%20posture%2C%20seven%20found%20a%20relationship%20with%20LBP.%20Regarding%20studies%20on%20sitting%20behavior%2C%20only%20one%20showed%20no%20relationship%20between%20LBP%20prevalence") {
NSWorkspace.shared.open(url)
#endif
}
}) {
Image(systemName: "info.circle")
.foregroundColor(.white)
}.buttonStyle(.plain)
Text(
"Regular posture checks help prevent back and neck pain from prolonged sitting"
)
}
.buttonStyle(.plain)
Text("Regular posture checks help prevent back and neck pain from prolonged sitting")
.font(.headline)
.foregroundColor(.white)
}
.padding()
.glassEffectIfAvailable(
GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8))
.glassEffectIfAvailable(GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8))
SliderSection(
intervalSettings: Binding(
@@ -72,10 +50,7 @@ struct PostureSetupView: View {
}
),
countdownSettings: nil,
enabled: Binding(
get: { settingsManager.settings.postureTimer.enabled },
set: { settingsManager.settings.postureTimer.enabled = $0 }
),
enabled: $settingsManager.settings.postureTimer.enabled,
type: "Posture",
previewFunc: showPreviewWindow
)
@@ -90,35 +65,12 @@ struct PostureSetupView: View {
private func showPreviewWindow() {
guard let screen = NSScreen.main else { return }
let window = NSWindow(
contentRect: screen.frame,
styleMask: [.borderless, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.level = .floating
window.isOpaque = false
window.backgroundColor = .clear
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
window.acceptsMouseMovedEvents = true
let contentView = PostureReminderView(
sizePercentage: settingsManager.settings.subtleReminderSize.percentage
) {
[weak window] in
window?.close()
previewWindowController = PreviewWindowHelper.showPreview(
on: screen,
content: PostureReminderView(sizePercentage: settingsManager.settings.subtleReminderSize.percentage) { [weak previewWindowController] in
previewWindowController?.window?.close()
}
window.contentView = NSHostingView(rootView: contentView)
window.makeFirstResponder(window.contentView)
let windowController = NSWindowController(window: window)
windowController.showWindow(nil)
window.makeKeyAndOrderFront(nil)
previewWindowController = windowController
)
}
}

View File

@@ -8,31 +8,35 @@
import SwiftUI
struct SmartModeSetupView: View {
@ObservedObject var settingsManager: SettingsManager
@StateObject private var permissionManager = ScreenCapturePermissionManager.shared
@Bindable var settingsManager: SettingsManager
@State private var permissionManager = ScreenCapturePermissionManager.shared
var body: some View {
VStack(spacing: 0) {
// Fixed header section
VStack(spacing: 16) {
Image(systemName: "brain.fill")
.font(.system(size: 60))
.foregroundColor(.purple)
Text("Smart Mode")
.font(.system(size: 28, weight: .bold))
SetupHeader(icon: "brain.fill", title: "Smart Mode", color: .purple)
Text("Automatically manage timers based on your activity")
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding(.top, 20)
.padding(.bottom, 30)
Spacer()
VStack(spacing: 24) {
// Auto-pause on fullscreen toggle
fullscreenSection
idleSection
usageTrackingSection
}
.frame(maxWidth: 600)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.background(.clear)
}
private var fullscreenSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
@@ -42,33 +46,30 @@ struct SmartModeSetupView: View {
Text("Auto-pause on Fullscreen")
.font(.headline)
}
Text(
"Timers will automatically pause when you enter fullscreen mode (videos, games, presentations)"
)
Text("Timers will automatically pause when you enter fullscreen mode (videos, games, presentations)")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Toggle(
"",
isOn: Binding(
get: { settingsManager.settings.smartMode.autoPauseOnFullscreen },
set: { newValue in
print("🔧 Smart Mode - Auto-pause on fullscreen changed to: \(newValue)")
settingsManager.settings.smartMode.autoPauseOnFullscreen = newValue
Toggle("", isOn: $settingsManager.settings.smartMode.autoPauseOnFullscreen)
.labelsHidden()
.onChange(of: settingsManager.settings.smartMode.autoPauseOnFullscreen) { _, newValue in
if newValue {
permissionManager.requestAuthorizationIfNeeded()
}
}
)
)
.labelsHidden()
}
if settingsManager.settings.smartMode.autoPauseOnFullscreen,
permissionManager.authorizationStatus != .authorized
{
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
@@ -78,9 +79,7 @@ struct SmartModeSetupView: View {
)
.foregroundStyle(.orange)
Text(
"macOS requires Screen Recording permission to detect other apps in fullscreen."
)
Text("macOS requires Screen Recording permission to detect other apps in fullscreen.")
.font(.caption)
.foregroundColor(.secondary)
@@ -101,11 +100,8 @@ struct SmartModeSetupView: View {
}
.padding(.top, 8)
}
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8))
// Auto-pause on idle toggle with threshold slider
private var idleSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
@@ -115,62 +111,29 @@ struct SmartModeSetupView: View {
Text("Auto-pause on Idle")
.font(.headline)
}
Text(
"Timers will pause when you're inactive for more than the threshold below"
)
Text("Timers will pause when you're inactive for more than the threshold below")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Toggle(
"",
isOn: Binding(
get: { settingsManager.settings.smartMode.autoPauseOnIdle },
set: { newValue in
print("🔧 Smart Mode - Auto-pause on idle changed to: \(newValue)")
settingsManager.settings.smartMode.autoPauseOnIdle = newValue
}
)
)
Toggle("", isOn: $settingsManager.settings.smartMode.autoPauseOnIdle)
.labelsHidden()
}
if settingsManager.settings.smartMode.autoPauseOnIdle {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Idle Threshold:")
.font(.subheadline)
Spacer()
Text(
"\(settingsManager.settings.smartMode.idleThresholdMinutes) min"
ThresholdSlider(
label: "Idle Threshold:",
value: $settingsManager.settings.smartMode.idleThresholdMinutes,
range: 1...30,
unit: "min"
)
.font(.subheadline)
.foregroundColor(.secondary)
}
Slider(
value: Binding(
get: {
Double(
settingsManager.settings.smartMode.idleThresholdMinutes)
},
set: { newValue in
print("🔧 Smart Mode - Idle threshold changed to: \(Int(newValue))")
settingsManager.settings.smartMode.idleThresholdMinutes =
Int(newValue)
}
),
in: 1...30,
step: 1
)
}
.padding(.top, 8)
}
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8))
}
// Usage tracking toggle with reset threshold
private var usageTrackingSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
@@ -180,71 +143,60 @@ struct SmartModeSetupView: View {
Text("Track Usage Statistics")
.font(.headline)
}
Text(
"Monitor active and idle time, with automatic reset after the specified duration"
)
Text("Monitor active and idle time, with automatic reset after the specified duration")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Toggle(
"",
isOn: Binding(
get: { settingsManager.settings.smartMode.trackUsage },
set: { newValue in
print("🔧 Smart Mode - Track usage changed to: \(newValue)")
settingsManager.settings.smartMode.trackUsage = newValue
}
)
)
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("Reset After:")
Text(label)
.font(.subheadline)
Spacer()
Text(
"\(settingsManager.settings.smartMode.usageResetAfterMinutes) min"
)
Text("\(value) \(unit)")
.font(.subheadline)
.foregroundColor(.secondary)
}
Slider(
value: Binding(
get: {
Double(
settingsManager.settings.smartMode
.usageResetAfterMinutes)
},
set: { newValue in
print("🔧 Smart Mode - Usage reset after changed to: \(Int(newValue))")
settingsManager.settings.smartMode.usageResetAfterMinutes =
Int(newValue)
}
get: { Double(value) },
set: { value = Int($0) }
),
in: 15...240,
step: 15
in: Double(range.lowerBound)...Double(range.upperBound),
step: Double(step)
)
}
.padding(.top, 8)
}
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8))
}
.frame(maxWidth: 600)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.background(.clear)
}
}
#Preview {
SmartModeSetupView(settingsManager: SettingsManager.shared)

View File

@@ -27,7 +27,8 @@ final class OnboardingNavigationTests: XCTestCase {
// MARK: - Navigation Tests
func testOnboardingStartsAtWelcomePage() {
let onboarding = OnboardingContainerView(settingsManager: testEnv.settingsManager as! SettingsManager)
// Use real SettingsManager for view initialization test since @Bindable requires concrete type
let onboarding = OnboardingContainerView(settingsManager: SettingsManager.shared)
// Verify initial state
XCTAssertFalse(testEnv.settingsManager.settings.hasCompletedOnboarding)

View File

@@ -39,7 +39,7 @@ final class SettingsManagerTests: XCTestCase {
let defaults = AppSettings.defaults
XCTAssertTrue(defaults.lookAwayTimer.enabled)
XCTAssertTrue(defaults.blinkTimer.enabled)
XCTAssertFalse(defaults.blinkTimer.enabled) // Blink timer is disabled by default
XCTAssertTrue(defaults.postureTimer.enabled)
XCTAssertFalse(defaults.hasCompletedOnboarding)
}
@@ -92,7 +92,7 @@ final class SettingsManagerTests: XCTestCase {
let expectation = XCTestExpectation(description: "Settings changed")
var receivedSettings: AppSettings?
settingsManager.$settings
settingsManager.settingsPublisher
.dropFirst() // Skip initial value
.sink { settings in
receivedSettings = settings
@@ -102,6 +102,7 @@ final class SettingsManagerTests: XCTestCase {
// Trigger change
settingsManager.settings.playSounds = !settingsManager.settings.playSounds
settingsManager.save()
await fulfillment(of: [expectation], timeout: 1.0)
XCTAssertNotNil(receivedSettings)

View File

@@ -13,13 +13,18 @@ import XCTest
/// Enhanced mock settings manager with full control over state
@MainActor
final class EnhancedMockSettingsManager: ObservableObject, SettingsProviding {
@Published var settings: AppSettings
@Observable
final class EnhancedMockSettingsManager: SettingsProviding {
var settings: AppSettings
var settingsPublisher: Published<AppSettings>.Publisher {
$settings
@ObservationIgnored
private let _settingsSubject: CurrentValueSubject<AppSettings, Never>
var settingsPublisher: AnyPublisher<AppSettings, Never> {
_settingsSubject.eraseToAnyPublisher()
}
@ObservationIgnored
private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] = [
.lookAway: \.lookAwayTimer,
.blink: \.blinkTimer,
@@ -27,13 +32,18 @@ final class EnhancedMockSettingsManager: ObservableObject, SettingsProviding {
]
// Track method calls for verification
@ObservationIgnored
private(set) var saveCallCount = 0
@ObservationIgnored
private(set) var saveImmediatelyCallCount = 0
@ObservationIgnored
private(set) var loadCallCount = 0
@ObservationIgnored
private(set) var resetToDefaultsCallCount = 0
init(settings: AppSettings = .defaults) {
self.settings = settings
self._settingsSubject = CurrentValueSubject(settings)
}
func timerConfiguration(for type: TimerType) -> TimerConfiguration {
@@ -48,6 +58,7 @@ final class EnhancedMockSettingsManager: ObservableObject, SettingsProviding {
preconditionFailure("Unknown timer type: \(type)")
}
settings[keyPath: keyPath] = configuration
_settingsSubject.send(settings)
}
func allTimerConfigurations() -> [TimerType: TimerConfiguration] {
@@ -60,10 +71,12 @@ final class EnhancedMockSettingsManager: ObservableObject, SettingsProviding {
func save() {
saveCallCount += 1
_settingsSubject.send(settings)
}
func saveImmediately() {
saveImmediatelyCallCount += 1
_settingsSubject.send(settings)
}
func load() {
@@ -73,6 +86,7 @@ final class EnhancedMockSettingsManager: ObservableObject, SettingsProviding {
func resetToDefaults() {
resetToDefaultsCallCount += 1
settings = .defaults
_settingsSubject.send(settings)
}
// Test helpers
@@ -82,6 +96,7 @@ final class EnhancedMockSettingsManager: ObservableObject, SettingsProviding {
loadCallCount = 0
resetToDefaultsCallCount = 0
settings = .defaults
_settingsSubject.send(settings)
}
}

View File

@@ -23,9 +23,8 @@ final class BlinkSetupViewTests: XCTestCase {
}
func testBlinkSetupInitialization() {
let view = BlinkSetupView(
settingsManager: testEnv.settingsManager as! SettingsManager
)
// Use real SettingsManager for view initialization test since @Bindable requires concrete type
let view = BlinkSetupView(settingsManager: SettingsManager.shared)
XCTAssertNotNil(view)
}

View File

@@ -23,10 +23,8 @@ final class GeneralSetupViewTests: XCTestCase {
}
func testGeneralSetupInitialization() {
let view = GeneralSetupView(
settingsManager: testEnv.settingsManager as! SettingsManager,
isOnboarding: true
)
// Use real SettingsManager for view initialization test since @Bindable requires concrete type
let view = GeneralSetupView(settingsManager: SettingsManager.shared, isOnboarding: true)
XCTAssertNotNil(view)
}

View File

@@ -23,9 +23,8 @@ final class LookAwaySetupViewTests: XCTestCase {
}
func testLookAwaySetupInitialization() {
let view = LookAwaySetupView(
settingsManager: testEnv.settingsManager as! SettingsManager
)
// Use real SettingsManager for view initialization test since @Bindable requires concrete type
let view = LookAwaySetupView(settingsManager: SettingsManager.shared)
XCTAssertNotNil(view)
}

View File

@@ -23,9 +23,8 @@ final class PostureSetupViewTests: XCTestCase {
}
func testPostureSetupInitialization() {
let view = PostureSetupView(
settingsManager: testEnv.settingsManager as! SettingsManager
)
// Use real SettingsManager for view initialization test since @Bindable requires concrete type
let view = PostureSetupView(settingsManager: SettingsManager.shared)
XCTAssertNotNil(view)
}