general: 14.0 new min
This commit is contained in:
@@ -437,7 +437,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 0.4.1;
|
MARKETING_VERSION = 0.4.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze;
|
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -473,7 +473,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 0.4.1;
|
MARKETING_VERSION = 0.4.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze;
|
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
{
|
{
|
||||||
"originHash": "513d974fbede884a919977d3446360023f6e3239ac314f4fbd9657e80aca7560",
|
"originHash" : "513d974fbede884a919977d3446360023f6e3239ac314f4fbd9657e80aca7560",
|
||||||
"pins": [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity": "lottie-spm",
|
"identity" : "lottie-spm",
|
||||||
"kind": "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location": "https://github.com/airbnb/lottie-spm.git",
|
"location" : "https://github.com/airbnb/lottie-spm.git",
|
||||||
"state": {
|
"state" : {
|
||||||
"revision": "69faaefa7721fba9e434a52c16adf4329c9084db",
|
"revision" : "69faaefa7721fba9e434a52c16adf4329c9084db",
|
||||||
"version": "4.6.0"
|
"version" : "4.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "sparkle",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/sparkle-project/Sparkle",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "5581748cef2bae787496fe6d61139aebe0a451f6",
|
||||||
|
"version" : "2.8.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version": 3
|
"version" : 3
|
||||||
}
|
}
|
||||||
@@ -10,9 +10,7 @@ import SwiftUI
|
|||||||
@main
|
@main
|
||||||
struct GazeApp: App {
|
struct GazeApp: App {
|
||||||
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
// Note: SettingsManager.shared is used directly here for SwiftUI view updates
|
@State private var settingsManager = SettingsManager.shared
|
||||||
// AppDelegate uses ServiceContainer for dependency injection
|
|
||||||
@StateObject private var settingsManager = SettingsManager.shared
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Handle test launch arguments
|
// Handle test launch arguments
|
||||||
|
|||||||
@@ -2,47 +2,26 @@
|
|||||||
// SettingsProviding.swift
|
// SettingsProviding.swift
|
||||||
// Gaze
|
// Gaze
|
||||||
//
|
//
|
||||||
// Protocol abstraction for SettingsManager to enable dependency injection and testing.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Protocol that defines the interface for managing application settings.
|
|
||||||
/// This abstraction allows for dependency injection and easy mocking in tests.
|
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol SettingsProviding: AnyObject, ObservableObject {
|
protocol SettingsProviding: AnyObject, Observable {
|
||||||
/// The current application settings
|
|
||||||
var settings: AppSettings { get set }
|
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
|
func timerConfiguration(for type: TimerType) -> TimerConfiguration
|
||||||
|
|
||||||
/// Updates the timer configuration for a specific timer type
|
|
||||||
func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration)
|
func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration)
|
||||||
|
|
||||||
/// Returns all timer configurations
|
|
||||||
func allTimerConfigurations() -> [TimerType: TimerConfiguration]
|
func allTimerConfigurations() -> [TimerType: TimerConfiguration]
|
||||||
|
|
||||||
/// Saves settings to persistent storage
|
|
||||||
func save()
|
func save()
|
||||||
|
|
||||||
/// Forces immediate save
|
|
||||||
func saveImmediately()
|
func saveImmediately()
|
||||||
|
|
||||||
/// Loads settings from persistent storage
|
|
||||||
func load()
|
func load()
|
||||||
|
|
||||||
/// Resets settings to default values
|
|
||||||
func resetToDefaults()
|
func resetToDefaults()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extension to provide the publisher for SettingsManager
|
|
||||||
extension SettingsManager: SettingsProviding {
|
extension SettingsManager: SettingsProviding {
|
||||||
var settingsPublisher: Published<AppSettings>.Publisher {
|
var settingsPublisher: AnyPublisher<AppSettings, Never> {
|
||||||
$settings
|
_settingsSubject.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,13 +120,18 @@ final class ServiceContainer {
|
|||||||
/// A mock settings manager for use in ServiceContainer.forTesting()
|
/// A mock settings manager for use in ServiceContainer.forTesting()
|
||||||
/// This is a minimal implementation - use the full MockSettingsManager from tests for more features
|
/// This is a minimal implementation - use the full MockSettingsManager from tests for more features
|
||||||
@MainActor
|
@MainActor
|
||||||
final class MockSettingsManager: ObservableObject, SettingsProviding {
|
@Observable
|
||||||
@Published var settings: AppSettings
|
final class MockSettingsManager: SettingsProviding {
|
||||||
|
var settings: AppSettings
|
||||||
|
|
||||||
var settingsPublisher: Published<AppSettings>.Publisher {
|
@ObservationIgnored
|
||||||
$settings
|
private let _settingsSubject: CurrentValueSubject<AppSettings, Never>
|
||||||
|
|
||||||
|
var settingsPublisher: AnyPublisher<AppSettings, Never> {
|
||||||
|
_settingsSubject.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ObservationIgnored
|
||||||
private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] = [
|
private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] = [
|
||||||
.lookAway: \.lookAwayTimer,
|
.lookAway: \.lookAwayTimer,
|
||||||
.blink: \.blinkTimer,
|
.blink: \.blinkTimer,
|
||||||
@@ -135,6 +140,7 @@ final class MockSettingsManager: ObservableObject, SettingsProviding {
|
|||||||
|
|
||||||
init(settings: AppSettings = .defaults) {
|
init(settings: AppSettings = .defaults) {
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
|
self._settingsSubject = CurrentValueSubject(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func timerConfiguration(for type: TimerType) -> TimerConfiguration {
|
func timerConfiguration(for type: TimerType) -> TimerConfiguration {
|
||||||
@@ -149,6 +155,7 @@ final class MockSettingsManager: ObservableObject, SettingsProviding {
|
|||||||
preconditionFailure("Unknown timer type: \(type)")
|
preconditionFailure("Unknown timer type: \(type)")
|
||||||
}
|
}
|
||||||
settings[keyPath: keyPath] = configuration
|
settings[keyPath: keyPath] = configuration
|
||||||
|
_settingsSubject.send(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func allTimerConfigurations() -> [TimerType: TimerConfiguration] {
|
func allTimerConfigurations() -> [TimerType: TimerConfiguration] {
|
||||||
@@ -159,8 +166,11 @@ final class MockSettingsManager: ObservableObject, SettingsProviding {
|
|||||||
return configs
|
return configs
|
||||||
}
|
}
|
||||||
|
|
||||||
func save() {}
|
func save() { _settingsSubject.send(settings) }
|
||||||
func saveImmediately() {}
|
func saveImmediately() { _settingsSubject.send(settings) }
|
||||||
func load() {}
|
func load() {}
|
||||||
func resetToDefaults() { settings = .defaults }
|
func resetToDefaults() {
|
||||||
|
settings = .defaults
|
||||||
|
_settingsSubject.send(settings)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,31 @@
|
|||||||
|
|
||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class SettingsManager: ObservableObject {
|
@Observable
|
||||||
|
final class SettingsManager {
|
||||||
static let shared = 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
|
private let userDefaults = UserDefaults.standard
|
||||||
|
|
||||||
|
@ObservationIgnored
|
||||||
private let settingsKey = "gazeAppSettings"
|
private let settingsKey = "gazeAppSettings"
|
||||||
|
|
||||||
|
@ObservationIgnored
|
||||||
private var saveCancellable: AnyCancellable?
|
private var saveCancellable: AnyCancellable?
|
||||||
|
|
||||||
private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] =
|
@ObservationIgnored
|
||||||
[
|
private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] = [
|
||||||
.lookAway: \.lookAwayTimer,
|
.lookAway: \.lookAwayTimer,
|
||||||
.blink: \.blinkTimer,
|
.blink: \.blinkTimer,
|
||||||
.posture: \.postureTimer,
|
.posture: \.postureTimer,
|
||||||
@@ -25,17 +39,12 @@ class SettingsManager: ObservableObject {
|
|||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
self.settings = Self.loadSettings()
|
self.settings = Self.loadSettings()
|
||||||
|
_settingsSubject.send(settings)
|
||||||
setupDebouncedSave()
|
setupDebouncedSave()
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
|
||||||
saveCancellable?.cancel()
|
|
||||||
// Final save is called by AppDelegate.applicationWillTerminate
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupDebouncedSave() {
|
private func setupDebouncedSave() {
|
||||||
saveCancellable =
|
saveCancellable = _settingsSubject
|
||||||
$settings
|
|
||||||
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
|
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
self?.save()
|
self?.save()
|
||||||
@@ -46,30 +55,22 @@ class SettingsManager: ObservableObject {
|
|||||||
guard let data = UserDefaults.standard.data(forKey: "gazeAppSettings") else {
|
guard let data = UserDefaults.standard.data(forKey: "gazeAppSettings") else {
|
||||||
return .defaults
|
return .defaults
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let settings = try JSONDecoder().decode(AppSettings.self, from: data)
|
return try JSONDecoder().decode(AppSettings.self, from: data)
|
||||||
return settings
|
|
||||||
} catch {
|
} catch {
|
||||||
return .defaults
|
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() {
|
func save() {
|
||||||
do {
|
do {
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
encoder.outputFormatting = .prettyPrinted
|
encoder.outputFormatting = .prettyPrinted
|
||||||
let data = try encoder.encode(settings)
|
let data = try encoder.encode(settings)
|
||||||
userDefaults.set(data, forKey: settingsKey)
|
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() {
|
func saveImmediately() {
|
||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
@@ -96,7 +97,6 @@ class SettingsManager: ObservableObject {
|
|||||||
settings[keyPath: keyPath] = configuration
|
settings[keyPath: keyPath] = configuration
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns all timer configurations as a dictionary
|
|
||||||
func allTimerConfigurations() -> [TimerType: TimerConfiguration] {
|
func allTimerConfigurations() -> [TimerType: TimerConfiguration] {
|
||||||
var configs: [TimerType: TimerConfiguration] = [:]
|
var configs: [TimerType: TimerConfiguration] = [:]
|
||||||
for (type, keyPath) in timerConfigKeyPaths {
|
for (type, keyPath) in timerConfigKeyPaths {
|
||||||
@@ -104,15 +104,4 @@ class SettingsManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
return configs
|
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)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
35
Gaze/Views/Components/PreviewWindowHelper.swift
Normal file
35
Gaze/Views/Components/PreviewWindowHelper.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
34
Gaze/Views/Components/SetupHeader.swift
Normal file
34
Gaze/Views/Components/SetupHeader.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,10 @@
|
|||||||
|
//
|
||||||
|
// OnboardingContainerView.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Created by Mike Freno on 1/7/26.
|
||||||
|
//
|
||||||
|
|
||||||
import AppKit
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -27,9 +34,7 @@ final class OnboardingWindowPresenter {
|
|||||||
private var closeObserver: NSObjectProtocol?
|
private var closeObserver: NSObjectProtocol?
|
||||||
|
|
||||||
func show(settingsManager: SettingsManager) {
|
func show(settingsManager: SettingsManager) {
|
||||||
if activateIfPresent() {
|
if activateIfPresent() { return }
|
||||||
return
|
|
||||||
}
|
|
||||||
createWindow(settingsManager: settingsManager)
|
createWindow(settingsManager: settingsManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,26 +44,16 @@ final class OnboardingWindowPresenter {
|
|||||||
windowController = nil
|
windowController = nil
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure the window is brought to front and focused properly for menu bar apps
|
|
||||||
window.makeKeyAndOrderFront(nil)
|
window.makeKeyAndOrderFront(nil)
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
|
||||||
// Additional focus handling for menu bar applications
|
|
||||||
if let window = windowController?.window {
|
|
||||||
window.makeMain()
|
window.makeMain()
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func close() {
|
func close() {
|
||||||
windowController?.close()
|
windowController?.close()
|
||||||
windowController = nil
|
windowController = nil
|
||||||
if let closeObserver {
|
removeCloseObserver()
|
||||||
NotificationCenter.default.removeObserver(closeObserver)
|
|
||||||
self.closeObserver = nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createWindow(settingsManager: SettingsManager) {
|
private func createWindow(settingsManager: SettingsManager) {
|
||||||
@@ -85,34 +80,29 @@ final class OnboardingWindowPresenter {
|
|||||||
|
|
||||||
windowController = controller
|
windowController = controller
|
||||||
|
|
||||||
closeObserver.map(NotificationCenter.default.removeObserver)
|
removeCloseObserver()
|
||||||
closeObserver = NotificationCenter.default.addObserver(
|
closeObserver = NotificationCenter.default.addObserver(
|
||||||
forName: NSWindow.willCloseNotification,
|
forName: NSWindow.willCloseNotification,
|
||||||
object: window,
|
object: window,
|
||||||
queue: .main
|
queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
self?.windowController = nil
|
self?.windowController = nil
|
||||||
if let closeObserver = self?.closeObserver {
|
self?.removeCloseObserver()
|
||||||
NotificationCenter.default.removeObserver(closeObserver)
|
|
||||||
}
|
|
||||||
self?.closeObserver = nil
|
|
||||||
|
|
||||||
// Notify AppDelegate that onboarding window closed
|
|
||||||
NotificationCenter.default.post(name: Notification.Name("OnboardingWindowDidClose"), object: nil)
|
NotificationCenter.default.post(name: Notification.Name("OnboardingWindowDidClose"), object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
private func removeCloseObserver() {
|
||||||
if let closeObserver {
|
if let observer = closeObserver {
|
||||||
NotificationCenter.default.removeObserver(closeObserver)
|
NotificationCenter.default.removeObserver(observer)
|
||||||
|
closeObserver = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct OnboardingContainerView: View {
|
struct OnboardingContainerView: View {
|
||||||
@ObservedObject var settingsManager: SettingsManager
|
@Bindable var settingsManager: SettingsManager
|
||||||
@State private var currentPage = 0
|
@State private var currentPage = 0
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -122,46 +112,42 @@ struct OnboardingContainerView: View {
|
|||||||
TabView(selection: $currentPage) {
|
TabView(selection: $currentPage) {
|
||||||
WelcomeView()
|
WelcomeView()
|
||||||
.tag(0)
|
.tag(0)
|
||||||
.tabItem {
|
.tabItem { Image(systemName: "hand.wave.fill") }
|
||||||
Image(systemName: "hand.wave.fill")
|
|
||||||
}
|
|
||||||
|
|
||||||
LookAwaySetupView(settingsManager: settingsManager)
|
LookAwaySetupView(settingsManager: settingsManager)
|
||||||
.tag(1)
|
.tag(1)
|
||||||
.tabItem {
|
.tabItem { Image(systemName: "eye.fill") }
|
||||||
Image(systemName: "eye.fill")
|
|
||||||
}
|
|
||||||
|
|
||||||
BlinkSetupView(settingsManager: settingsManager)
|
BlinkSetupView(settingsManager: settingsManager)
|
||||||
.tag(2)
|
.tag(2)
|
||||||
.tabItem {
|
.tabItem { Image(systemName: "eye.circle.fill") }
|
||||||
Image(systemName: "eye.circle.fill")
|
|
||||||
}
|
|
||||||
|
|
||||||
PostureSetupView(settingsManager: settingsManager)
|
PostureSetupView(settingsManager: settingsManager)
|
||||||
.tag(3)
|
.tag(3)
|
||||||
.tabItem {
|
.tabItem { Image(systemName: "figure.stand") }
|
||||||
Image(systemName: "figure.stand")
|
|
||||||
}
|
|
||||||
|
|
||||||
GeneralSetupView(
|
GeneralSetupView(settingsManager: settingsManager, isOnboarding: true)
|
||||||
settingsManager: settingsManager,
|
|
||||||
isOnboarding: true
|
|
||||||
)
|
|
||||||
.tag(4)
|
.tag(4)
|
||||||
.tabItem {
|
.tabItem { Image(systemName: "gearshape.fill") }
|
||||||
Image(systemName: "gearshape.fill")
|
|
||||||
}
|
|
||||||
|
|
||||||
CompletionView()
|
CompletionView()
|
||||||
.tag(5)
|
.tag(5)
|
||||||
.tabItem {
|
.tabItem { Image(systemName: "checkmark.circle.fill") }
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.tabViewStyle(.automatic)
|
.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) {
|
HStack(spacing: 12) {
|
||||||
if currentPage > 0 {
|
if currentPage > 0 {
|
||||||
Button(action: { currentPage -= 1 }) {
|
Button(action: { currentPage -= 1 }) {
|
||||||
@@ -170,16 +156,12 @@ struct OnboardingContainerView: View {
|
|||||||
Text("Back")
|
Text("Back")
|
||||||
}
|
}
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.frame(
|
.frame(minWidth: 100, maxWidth: .infinity, minHeight: 44, maxHeight: 44)
|
||||||
minWidth: 100, maxWidth: .infinity, minHeight: 44,
|
|
||||||
maxHeight: 44, alignment: .center
|
|
||||||
)
|
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
.contentShape(RoundedRectangle(cornerRadius: 10))
|
.contentShape(RoundedRectangle(cornerRadius: 10))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.glassEffectIfAvailable(
|
.glassEffectIfAvailable(GlassStyle.regular.interactive(), in: .rect(cornerRadius: 10))
|
||||||
GlassStyle.regular.interactive(), in: .rect(cornerRadius: 10))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
@@ -189,60 +171,28 @@ struct OnboardingContainerView: View {
|
|||||||
currentPage += 1
|
currentPage += 1
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
Text(
|
Text(currentPage == 0 ? "Let's Get Started" : currentPage == 5 ? "Get Started" : "Continue")
|
||||||
currentPage == 0
|
|
||||||
? "Let's Get Started"
|
|
||||||
: currentPage == 5 ? "Get Started" : "Continue"
|
|
||||||
)
|
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.frame(
|
.frame(minWidth: 100, maxWidth: .infinity, minHeight: 44, maxHeight: 44)
|
||||||
minWidth: 100, maxWidth: .infinity, minHeight: 44, maxHeight: 44,
|
|
||||||
alignment: .center
|
|
||||||
)
|
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.contentShape(RoundedRectangle(cornerRadius: 10))
|
.contentShape(RoundedRectangle(cornerRadius: 10))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.glassEffectIfAvailable(
|
.glassEffectIfAvailable(
|
||||||
GlassStyle.regular.tint(currentPage == 5 ? .green : .accentColor)
|
GlassStyle.regular.tint(currentPage == 5 ? .green : .accentColor).interactive(),
|
||||||
.interactive(),
|
in: .rect(cornerRadius: 10)
|
||||||
in: .rect(cornerRadius: 10))
|
)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 40)
|
.padding(.horizontal, 40)
|
||||||
.padding(.bottom, 20)
|
.padding(.bottom, 20)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
#if APPSTORE
|
|
||||||
.frame(
|
|
||||||
minWidth: 1000,
|
|
||||||
minHeight: 700
|
|
||||||
)
|
|
||||||
#else
|
|
||||||
.frame(
|
|
||||||
minWidth: 1000,
|
|
||||||
minHeight: 900
|
|
||||||
)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private func completeOnboarding() {
|
private func completeOnboarding() {
|
||||||
// Mark onboarding as complete - settings are already being updated in real-time
|
|
||||||
settingsManager.settings.hasCompletedOnboarding = true
|
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") {
|
#Preview("Onboarding Container") {
|
||||||
OnboardingContainerView(settingsManager: SettingsManager.shared)
|
OnboardingContainerView(settingsManager: SettingsManager.shared)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ final class SettingsWindowPresenter {
|
|||||||
private var closeObserver: NSObjectProtocol?
|
private var closeObserver: NSObjectProtocol?
|
||||||
|
|
||||||
func show(settingsManager: SettingsManager, initialTab: Int = 0) {
|
func show(settingsManager: SettingsManager, initialTab: Int = 0) {
|
||||||
if focusExistingWindow(tab: initialTab) {
|
if focusExistingWindow(tab: initialTab) { return }
|
||||||
return
|
|
||||||
}
|
|
||||||
createWindow(settingsManager: settingsManager, initialTab: initialTab)
|
createWindow(settingsManager: settingsManager, initialTab: initialTab)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,15 +65,12 @@ final class SettingsWindowPresenter {
|
|||||||
window.setFrameAutosaveName("SettingsWindow")
|
window.setFrameAutosaveName("SettingsWindow")
|
||||||
window.isReleasedWhenClosed = false
|
window.isReleasedWhenClosed = false
|
||||||
|
|
||||||
let contentView = SettingsWindowView(
|
window.contentView = NSHostingView(
|
||||||
settingsManager: settingsManager,
|
rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: initialTab)
|
||||||
initialTab: initialTab
|
|
||||||
)
|
)
|
||||||
window.contentView = NSHostingView(rootView: contentView)
|
|
||||||
|
|
||||||
let controller = NSWindowController(window: window)
|
let controller = NSWindowController(window: window)
|
||||||
controller.showWindow(nil)
|
controller.showWindow(nil)
|
||||||
|
|
||||||
window.makeKeyAndOrderFront(nil)
|
window.makeKeyAndOrderFront(nil)
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
|
||||||
@@ -90,32 +85,21 @@ final class SettingsWindowPresenter {
|
|||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.windowController = nil
|
self?.windowController = nil
|
||||||
self?.removeCloseObserver()
|
self?.removeCloseObserver()
|
||||||
|
|
||||||
// Notify AppDelegate that settings window closed
|
|
||||||
NotificationCenter.default.post(name: Notification.Name("SettingsWindowDidClose"), object: nil)
|
NotificationCenter.default.post(name: Notification.Name("SettingsWindowDidClose"), object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func removeCloseObserver() {
|
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 {
|
if let observer = closeObserver {
|
||||||
NotificationCenter.default.removeObserver(observer)
|
NotificationCenter.default.removeObserver(observer)
|
||||||
|
closeObserver = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SettingsWindowView: View {
|
struct SettingsWindowView: View {
|
||||||
@ObservedObject var settingsManager: SettingsManager
|
@Bindable var settingsManager: SettingsManager
|
||||||
@State private var selectedSection: SettingsSection
|
@State private var selectedSection: SettingsSection
|
||||||
|
|
||||||
init(settingsManager: SettingsManager, initialTab: Int = 0) {
|
init(settingsManager: SettingsManager, initialTab: Int = 0) {
|
||||||
@@ -140,26 +124,21 @@ struct SettingsWindowView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
detailView(for: selectedSection)
|
detailView(for: selectedSection)
|
||||||
}
|
}
|
||||||
}.onReceive(
|
}
|
||||||
NotificationCenter.default.publisher(
|
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("SwitchToSettingsTab"))) { notification in
|
||||||
for: Notification.Name("SwitchToSettingsTab"))
|
|
||||||
) { notification in
|
|
||||||
if let tab = notification.object as? Int,
|
if let tab = notification.object as? Int,
|
||||||
let section = SettingsSection(rawValue: tab)
|
let section = SettingsSection(rawValue: tab) {
|
||||||
{
|
|
||||||
selectedSection = section
|
selectedSection = section
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Button("Retrigger Onboarding") {
|
Button("Retrigger Onboarding") {
|
||||||
retriggerOnboarding()
|
retriggerOnboarding()
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
@@ -167,15 +146,9 @@ struct SettingsWindowView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if APPSTORE
|
#if APPSTORE
|
||||||
.frame(
|
.frame(minWidth: 1000, minHeight: 700)
|
||||||
minWidth: 1000,
|
|
||||||
minHeight: 700
|
|
||||||
)
|
|
||||||
#else
|
#else
|
||||||
.frame(
|
.frame(minWidth: 1000, minHeight: 900)
|
||||||
minWidth: 1000,
|
|
||||||
minHeight: 900
|
|
||||||
)
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,10 +156,7 @@ struct SettingsWindowView: View {
|
|||||||
private func detailView(for section: SettingsSection) -> some View {
|
private func detailView(for section: SettingsSection) -> some View {
|
||||||
switch section {
|
switch section {
|
||||||
case .general:
|
case .general:
|
||||||
GeneralSetupView(
|
GeneralSetupView(settingsManager: settingsManager, isOnboarding: false)
|
||||||
settingsManager: settingsManager,
|
|
||||||
isOnboarding: false
|
|
||||||
)
|
|
||||||
case .lookAway:
|
case .lookAway:
|
||||||
LookAwaySetupView(settingsManager: settingsManager)
|
LookAwaySetupView(settingsManager: settingsManager)
|
||||||
case .blink:
|
case .blink:
|
||||||
@@ -210,7 +180,6 @@ struct SettingsWindowView: View {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
private func retriggerOnboarding() {
|
private func retriggerOnboarding() {
|
||||||
SettingsWindowPresenter.shared.close()
|
SettingsWindowPresenter.shared.close()
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||||
settingsManager.settings.hasCompletedOnboarding = false
|
settingsManager.settings.hasCompletedOnboarding = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,9 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// Wrapper to properly observe AppDelegate changes in MenuBarExtra
|
|
||||||
struct MenuBarContentWrapper: View {
|
struct MenuBarContentWrapper: View {
|
||||||
@ObservedObject var appDelegate: AppDelegate
|
@ObservedObject var appDelegate: AppDelegate
|
||||||
@ObservedObject var settingsManager: SettingsManager
|
@Bindable var settingsManager: SettingsManager
|
||||||
var onQuit: () -> Void
|
var onQuit: () -> Void
|
||||||
var onOpenSettings: () -> Void
|
var onOpenSettings: () -> Void
|
||||||
var onOpenSettingsTab: (Int) -> Void
|
var onOpenSettingsTab: (Int) -> Void
|
||||||
@@ -71,7 +70,7 @@ struct MenuBarHoverButtonStyle: ButtonStyle {
|
|||||||
|
|
||||||
struct MenuBarContentView: View {
|
struct MenuBarContentView: View {
|
||||||
var timerEngine: TimerEngine?
|
var timerEngine: TimerEngine?
|
||||||
@ObservedObject var settingsManager: SettingsManager
|
@Bindable var settingsManager: SettingsManager
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
var onQuit: () -> Void
|
var onQuit: () -> Void
|
||||||
var onOpenSettings: () -> Void
|
var onOpenSettings: () -> Void
|
||||||
@@ -251,7 +250,7 @@ struct MenuBarContentView: View {
|
|||||||
struct TimerStatusRowWithIndividualControls: View {
|
struct TimerStatusRowWithIndividualControls: View {
|
||||||
let identifier: TimerIdentifier
|
let identifier: TimerIdentifier
|
||||||
@ObservedObject var timerEngine: TimerEngine
|
@ObservedObject var timerEngine: TimerEngine
|
||||||
@ObservedObject var settingsManager: SettingsManager
|
@Bindable var settingsManager: SettingsManager
|
||||||
var onSkip: () -> Void
|
var onSkip: () -> Void
|
||||||
var onDevTrigger: (() -> Void)? = nil
|
var onDevTrigger: (() -> Void)? = nil
|
||||||
var onTogglePause: (Bool) -> Void
|
var onTogglePause: (Bool) -> Void
|
||||||
|
|||||||
@@ -9,58 +9,36 @@ import AppKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct BlinkSetupView: View {
|
struct BlinkSetupView: View {
|
||||||
@ObservedObject var settingsManager: SettingsManager
|
@Bindable var settingsManager: SettingsManager
|
||||||
@State private var previewWindowController: NSWindowController?
|
@State private var previewWindowController: NSWindowController?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Fixed header section
|
SetupHeader(icon: "eye.circle", title: "Blink Reminder", color: .green)
|
||||||
VStack(spacing: 16) {
|
|
||||||
Image(systemName: "eye.circle")
|
|
||||||
.font(.system(size: 60))
|
|
||||||
.foregroundColor(.green)
|
|
||||||
|
|
||||||
Text("Blink Reminder")
|
|
||||||
.font(.system(size: 28, weight: .bold))
|
|
||||||
}
|
|
||||||
.padding(.top, 20)
|
|
||||||
.padding(.bottom, 30)
|
|
||||||
|
|
||||||
// Vertically centered content
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
VStack(spacing: 30) {
|
VStack(spacing: 30) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
if let url = URL(
|
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.") {
|
||||||
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)
|
|
||||||
NSWorkspace.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: "info.circle")
|
Image(systemName: "info.circle")
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
}.buttonStyle(.plain)
|
}
|
||||||
Text(
|
.buttonStyle(.plain)
|
||||||
"We blink much less when focusing on screens. Regular blink reminders help prevent dry eyes."
|
|
||||||
)
|
Text("We blink much less when focusing on screens. Regular blink reminders help prevent dry eyes.")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.glassEffectIfAvailable(
|
.glassEffectIfAvailable(GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8))
|
||||||
GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8))
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
Toggle("Enable Blink Reminders", isOn: Binding(
|
Toggle("Enable Blink Reminders", isOn: $settingsManager.settings.blinkTimer.enabled)
|
||||||
get: { settingsManager.settings.blinkTimer.enabled },
|
|
||||||
set: { settingsManager.settings.blinkTimer.enabled = $0 }
|
|
||||||
))
|
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
|
||||||
if settingsManager.settings.blinkTimer.enabled {
|
if settingsManager.settings.blinkTimer.enabled {
|
||||||
@@ -74,7 +52,10 @@ struct BlinkSetupView: View {
|
|||||||
value: Binding(
|
value: Binding(
|
||||||
get: { Double(settingsManager.settings.blinkTimer.intervalSeconds / 60) },
|
get: { Double(settingsManager.settings.blinkTimer.intervalSeconds / 60) },
|
||||||
set: { settingsManager.settings.blinkTimer.intervalSeconds = Int($0) * 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")
|
Text("\(settingsManager.settings.blinkTimer.intervalSeconds / 60) min")
|
||||||
.frame(width: 60, alignment: .trailing)
|
.frame(width: 60, alignment: .trailing)
|
||||||
@@ -87,23 +68,16 @@ struct BlinkSetupView: View {
|
|||||||
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
|
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
|
||||||
|
|
||||||
if settingsManager.settings.blinkTimer.enabled {
|
if settingsManager.settings.blinkTimer.enabled {
|
||||||
Text(
|
Text("You will be subtly reminded every \(settingsManager.settings.blinkTimer.intervalSeconds / 60) minutes to blink")
|
||||||
"You will be subtly reminded every \(settingsManager.settings.blinkTimer.intervalSeconds / 60) minutes to blink"
|
|
||||||
)
|
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text("Blink reminders are currently disabled.")
|
||||||
"Blink reminders are currently disabled."
|
|
||||||
)
|
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preview button
|
Button(action: showPreviewWindow) {
|
||||||
Button(action: {
|
|
||||||
showPreviewWindow()
|
|
||||||
}) {
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: "eye")
|
Image(systemName: "eye")
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
@@ -116,9 +90,7 @@ struct BlinkSetupView: View {
|
|||||||
.contentShape(RoundedRectangle(cornerRadius: 10))
|
.contentShape(RoundedRectangle(cornerRadius: 10))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.glassEffectIfAvailable(
|
.glassEffectIfAvailable(GlassStyle.regular.tint(.accentColor).interactive(), in: .rect(cornerRadius: 10))
|
||||||
GlassStyle.regular.tint(.accentColor).interactive(), in: .rect(cornerRadius: 10)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -130,35 +102,12 @@ struct BlinkSetupView: View {
|
|||||||
|
|
||||||
private func showPreviewWindow() {
|
private func showPreviewWindow() {
|
||||||
guard let screen = NSScreen.main else { return }
|
guard let screen = NSScreen.main else { return }
|
||||||
|
previewWindowController = PreviewWindowHelper.showPreview(
|
||||||
let window = NSWindow(
|
on: screen,
|
||||||
contentRect: screen.frame,
|
content: BlinkReminderView(sizePercentage: settingsManager.settings.subtleReminderSize.percentage) { [weak previewWindowController] in
|
||||||
styleMask: [.borderless, .fullSizeContentView],
|
previewWindowController?.window?.close()
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
window.contentView = NSHostingView(rootView: contentView)
|
|
||||||
window.makeFirstResponder(window.contentView)
|
|
||||||
|
|
||||||
let windowController = NSWindowController(window: window)
|
|
||||||
windowController.showWindow(nil)
|
|
||||||
window.makeKeyAndOrderFront(nil)
|
|
||||||
|
|
||||||
previewWindowController = windowController
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import SwiftUI
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct EnforceModeSetupView: View {
|
struct EnforceModeSetupView: View {
|
||||||
@ObservedObject var settingsManager: SettingsManager
|
@Bindable var settingsManager: SettingsManager
|
||||||
@ObservedObject var cameraService = CameraAccessService.shared
|
@ObservedObject var cameraService = CameraAccessService.shared
|
||||||
@ObservedObject var eyeTrackingService = EyeTrackingService.shared
|
@ObservedObject var eyeTrackingService = EyeTrackingService.shared
|
||||||
@ObservedObject var enforceModeService = EnforceModeService.shared
|
@ObservedObject var enforceModeService = EnforceModeService.shared
|
||||||
@@ -26,15 +26,7 @@ struct EnforceModeSetupView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
VStack(spacing: 16) {
|
SetupHeader(icon: "video.fill", title: "Enforce Mode", color: .accentColor)
|
||||||
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)
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
|||||||
@@ -8,22 +8,13 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct GeneralSetupView: View {
|
struct GeneralSetupView: View {
|
||||||
@ObservedObject var settingsManager: SettingsManager
|
@Bindable var settingsManager: SettingsManager
|
||||||
@ObservedObject var updateManager = UpdateManager.shared
|
var updateManager = UpdateManager.shared
|
||||||
var isOnboarding: Bool = true
|
var isOnboarding: Bool = true
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Fixed header section
|
SetupHeader(icon: "gearshape.fill", title: isOnboarding ? "Final Settings" : "General Settings", color: .accentColor)
|
||||||
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)
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
VStack(spacing: 30) {
|
VStack(spacing: 30) {
|
||||||
@@ -33,6 +24,27 @@ struct GeneralSetupView: View {
|
|||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
VStack(spacing: 20) {
|
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 {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Launch at Login")
|
Text("Launch at Login")
|
||||||
@@ -42,22 +54,18 @@ struct GeneralSetupView: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Toggle(
|
Toggle("", isOn: $settingsManager.settings.launchAtLogin)
|
||||||
"",
|
|
||||||
isOn: Binding(
|
|
||||||
get: { settingsManager.settings.launchAtLogin },
|
|
||||||
set: { settingsManager.settings.launchAtLogin = $0 }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.onChange(of: settingsManager.settings.launchAtLogin) { isEnabled in
|
.onChange(of: settingsManager.settings.launchAtLogin) { _, isEnabled in
|
||||||
applyLaunchAtLoginSetting(enabled: isEnabled)
|
applyLaunchAtLoginSetting(enabled: isEnabled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
|
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
#if !APPSTORE
|
#if !APPSTORE
|
||||||
|
private var softwareUpdatesSection: some View {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Software Updates")
|
Text("Software Updates")
|
||||||
@@ -83,17 +91,19 @@ struct GeneralSetupView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
|
|
||||||
Toggle(
|
Toggle("Automatically check for updates", isOn: Binding(
|
||||||
"Automatically check for updates",
|
get: { updateManager.automaticallyChecksForUpdates },
|
||||||
isOn: $updateManager.automaticallyChecksForUpdates
|
set: { updateManager.automaticallyChecksForUpdates = $0 }
|
||||||
)
|
))
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.help("Check for new versions of Gaze in the background")
|
.help("Check for new versions of Gaze in the background")
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
|
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
private var subtleReminderSizeSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text("Subtle Reminder Size")
|
Text("Subtle Reminder Size")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@@ -104,29 +114,16 @@ struct GeneralSetupView: View {
|
|||||||
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
ForEach(ReminderSize.allCases, id: \.self) { size in
|
ForEach(ReminderSize.allCases, id: \.self) { size in
|
||||||
Button(action: {
|
Button(action: { settingsManager.settings.subtleReminderSize = size }) {
|
||||||
settingsManager.settings.subtleReminderSize = size
|
|
||||||
}) {
|
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(
|
.fill(settingsManager.settings.subtleReminderSize == size ? Color.accentColor : Color.secondary.opacity(0.3))
|
||||||
settingsManager.settings.subtleReminderSize == size
|
.frame(width: iconSize(for: size), height: iconSize(for: size))
|
||||||
? Color.accentColor
|
|
||||||
: Color.secondary.opacity(0.3)
|
|
||||||
)
|
|
||||||
.frame(
|
|
||||||
width: iconSize(for: size),
|
|
||||||
height: iconSize(for: size))
|
|
||||||
|
|
||||||
Text(size.displayName)
|
Text(size.displayName)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.fontWeight(
|
.fontWeight(settingsManager.settings.subtleReminderSize == size ? .semibold : .regular)
|
||||||
settingsManager.settings.subtleReminderSize == size
|
.foregroundColor(settingsManager.settings.subtleReminderSize == size ? .primary : .secondary)
|
||||||
? .semibold : .regular
|
|
||||||
)
|
|
||||||
.foregroundColor(
|
|
||||||
settingsManager.settings.subtleReminderSize == size
|
|
||||||
? .primary : .secondary)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, minHeight: 60)
|
.frame(maxWidth: .infinity, minHeight: 60)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
@@ -142,83 +139,35 @@ struct GeneralSetupView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
|
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
#if !APPSTORE
|
#if !APPSTORE
|
||||||
|
private var supportSection: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text("Support & Contribute")
|
Text("Support & Contribute")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
// GitHub Link
|
ExternalLinkButton(
|
||||||
Button(action: {
|
icon: "chevron.left.forwardslash.chevron.right",
|
||||||
if let url = URL(string: "https://github.com/mikefreno/Gaze") {
|
title: "View on GitHub",
|
||||||
NSWorkspace.shared.open(url)
|
subtitle: "Star the repo, report issues, contribute",
|
||||||
}
|
url: "https://github.com/mikefreno/Gaze",
|
||||||
}) {
|
tint: nil
|
||||||
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))
|
|
||||||
|
|
||||||
Button(action: {
|
ExternalLinkButton(
|
||||||
if let url = URL(string: "https://buymeacoffee.com/mikefreno") {
|
icon: "cup.and.saucer.fill",
|
||||||
NSWorkspace.shared.open(url)
|
iconColor: .brown,
|
||||||
}
|
title: "Buy Me a Coffee",
|
||||||
}) {
|
subtitle: "Support development of Gaze",
|
||||||
HStack {
|
url: "https://buymeacoffee.com/mikefreno",
|
||||||
Image(systemName: "cup.and.saucer.fill")
|
tint: .orange
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.cornerRadius(10)
|
|
||||||
.contentShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
.glassEffectIfAvailable(
|
|
||||||
GlassStyle.regular.tint(.orange).interactive(),
|
|
||||||
in: .rect(cornerRadius: 10))
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
#endif
|
#endif
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.padding()
|
|
||||||
.background(.clear)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyLaunchAtLoginSetting(enabled: Bool) {
|
private func applyLaunchAtLoginSetting(enabled: Bool) {
|
||||||
do {
|
do {
|
||||||
@@ -227,9 +176,7 @@ struct GeneralSetupView: View {
|
|||||||
} else {
|
} else {
|
||||||
try LaunchAtLoginManager.disable()
|
try LaunchAtLoginManager.disable()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
//TODO: see what can be done here
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func iconSize(for size: ReminderSize) -> CGFloat {
|
private func iconSize(for size: ReminderSize) -> CGFloat {
|
||||||
@@ -241,9 +188,48 @@ struct GeneralSetupView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview("Settings Onboarding") {
|
struct ExternalLinkButton: View {
|
||||||
GeneralSetupView(
|
let icon: String
|
||||||
settingsManager: SettingsManager.shared,
|
var iconColor: Color = .primary
|
||||||
isOnboarding: true
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,38 +8,22 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
import UIKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
struct LookAwaySetupView: View {
|
struct LookAwaySetupView: View {
|
||||||
@ObservedObject var settingsManager: SettingsManager
|
@Bindable var settingsManager: SettingsManager
|
||||||
@State private var previewWindowController: NSWindowController?
|
@State private var previewWindowController: NSWindowController?
|
||||||
@ObservedObject var cameraAccess = CameraAccessService.shared
|
var cameraAccess = CameraAccessService.shared
|
||||||
@State private var failedCameraAccess = false
|
@State private var failedCameraAccess = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Fixed header section
|
SetupHeader(icon: "eye.fill", title: "Look Away Reminder", color: .accentColor)
|
||||||
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)
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
VStack(spacing: 30) {
|
VStack(spacing: 30) {
|
||||||
|
|
||||||
InfoBox(
|
InfoBox(
|
||||||
text: "Suggested: 20-20-20 rule",
|
text: "Suggested: 20-20-20 rule",
|
||||||
url:
|
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."
|
||||||
"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(
|
SliderSection(
|
||||||
@@ -51,8 +35,7 @@ struct LookAwaySetupView: View {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
set: { newValue in
|
set: { newValue in
|
||||||
settingsManager.settings.lookAwayTimer.intervalSeconds =
|
settingsManager.settings.lookAwayTimer.intervalSeconds = (newValue.val ?? 20) * 60
|
||||||
(newValue.val ?? 20) * 60
|
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
countdownSettings: Binding(
|
countdownSettings: Binding(
|
||||||
@@ -66,23 +49,13 @@ struct LookAwaySetupView: View {
|
|||||||
settingsManager.settings.lookAwayCountdownSeconds = newValue.val ?? 20
|
settingsManager.settings.lookAwayCountdownSeconds = newValue.val ?? 20
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
enabled: Binding(
|
enabled: $settingsManager.settings.lookAwayTimer.enabled,
|
||||||
get: { settingsManager.settings.lookAwayTimer.enabled },
|
|
||||||
set: { settingsManager.settings.lookAwayTimer.enabled = $0 }
|
|
||||||
),
|
|
||||||
type: "Look away",
|
type: "Look away",
|
||||||
previewFunc: showPreviewWindow
|
previewFunc: showPreviewWindow
|
||||||
)
|
)
|
||||||
Toggle(
|
|
||||||
"Enable enforcement mode",
|
Toggle("Enable enforcement mode", isOn: $settingsManager.settings.enforcementMode)
|
||||||
isOn: Binding(
|
.onChange(of: settingsManager.settings.enforcementMode) { _, newMode in
|
||||||
get: { settingsManager.settings.enforcementMode },
|
|
||||||
set: { settingsManager.settings.enforcementMode = $0 }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.onChange(
|
|
||||||
of: settingsManager.settings.enforcementMode,
|
|
||||||
) { newMode in
|
|
||||||
if newMode && !cameraAccess.isCameraAuthorized {
|
if newMode && !cameraAccess.isCameraAuthorized {
|
||||||
Task {
|
Task {
|
||||||
do {
|
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()
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
@@ -110,35 +78,12 @@ struct LookAwaySetupView: View {
|
|||||||
|
|
||||||
private func showPreviewWindow() {
|
private func showPreviewWindow() {
|
||||||
guard let screen = NSScreen.main else { return }
|
guard let screen = NSScreen.main else { return }
|
||||||
|
previewWindowController = PreviewWindowHelper.showPreview(
|
||||||
let window = NSWindow(
|
on: screen,
|
||||||
contentRect: screen.frame,
|
content: LookAwayReminderView(countdownSeconds: settingsManager.settings.lookAwayCountdownSeconds) { [weak previewWindowController] in
|
||||||
styleMask: [.borderless, .fullSizeContentView],
|
previewWindowController?.window?.close()
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
window.contentView = NSHostingView(rootView: contentView)
|
|
||||||
window.makeFirstResponder(window.contentView)
|
|
||||||
|
|
||||||
let windowController = NSWindowController(window: window)
|
|
||||||
windowController.showWindow(nil)
|
|
||||||
window.makeKeyAndOrderFront(nil)
|
|
||||||
|
|
||||||
previewWindowController = windowController
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,55 +9,33 @@ import AppKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct PostureSetupView: View {
|
struct PostureSetupView: View {
|
||||||
@ObservedObject var settingsManager: SettingsManager
|
@Bindable var settingsManager: SettingsManager
|
||||||
|
|
||||||
@State private var previewWindowController: NSWindowController?
|
@State private var previewWindowController: NSWindowController?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Fixed header section
|
SetupHeader(icon: "figure.stand", title: "Posture Reminder", color: .orange)
|
||||||
VStack(spacing: 16) {
|
|
||||||
Image(systemName: "figure.stand")
|
|
||||||
.font(.system(size: 60))
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
|
|
||||||
Text("Posture Reminder")
|
|
||||||
.font(.system(size: 28, weight: .bold))
|
|
||||||
}
|
|
||||||
.padding(.top, 20)
|
|
||||||
.padding(.bottom, 30)
|
|
||||||
|
|
||||||
// Vertically centered content
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
VStack(spacing: 30) {
|
VStack(spacing: 30) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
// Using properly URL-encoded text fragment
|
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") {
|
||||||
// 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)
|
|
||||||
NSWorkspace.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: "info.circle")
|
Image(systemName: "info.circle")
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
}.buttonStyle(.plain)
|
}
|
||||||
Text(
|
.buttonStyle(.plain)
|
||||||
"Regular posture checks help prevent back and neck pain from prolonged sitting"
|
|
||||||
)
|
Text("Regular posture checks help prevent back and neck pain from prolonged sitting")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.glassEffectIfAvailable(
|
.glassEffectIfAvailable(GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8))
|
||||||
GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8))
|
|
||||||
|
|
||||||
SliderSection(
|
SliderSection(
|
||||||
intervalSettings: Binding(
|
intervalSettings: Binding(
|
||||||
@@ -72,10 +50,7 @@ struct PostureSetupView: View {
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
countdownSettings: nil,
|
countdownSettings: nil,
|
||||||
enabled: Binding(
|
enabled: $settingsManager.settings.postureTimer.enabled,
|
||||||
get: { settingsManager.settings.postureTimer.enabled },
|
|
||||||
set: { settingsManager.settings.postureTimer.enabled = $0 }
|
|
||||||
),
|
|
||||||
type: "Posture",
|
type: "Posture",
|
||||||
previewFunc: showPreviewWindow
|
previewFunc: showPreviewWindow
|
||||||
)
|
)
|
||||||
@@ -90,35 +65,12 @@ struct PostureSetupView: View {
|
|||||||
|
|
||||||
private func showPreviewWindow() {
|
private func showPreviewWindow() {
|
||||||
guard let screen = NSScreen.main else { return }
|
guard let screen = NSScreen.main else { return }
|
||||||
|
previewWindowController = PreviewWindowHelper.showPreview(
|
||||||
let window = NSWindow(
|
on: screen,
|
||||||
contentRect: screen.frame,
|
content: PostureReminderView(sizePercentage: settingsManager.settings.subtleReminderSize.percentage) { [weak previewWindowController] in
|
||||||
styleMask: [.borderless, .fullSizeContentView],
|
previewWindowController?.window?.close()
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
window.contentView = NSHostingView(rootView: contentView)
|
|
||||||
window.makeFirstResponder(window.contentView)
|
|
||||||
|
|
||||||
let windowController = NSWindowController(window: window)
|
|
||||||
windowController.showWindow(nil)
|
|
||||||
window.makeKeyAndOrderFront(nil)
|
|
||||||
|
|
||||||
previewWindowController = windowController
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,31 +8,35 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SmartModeSetupView: View {
|
struct SmartModeSetupView: View {
|
||||||
@ObservedObject var settingsManager: SettingsManager
|
@Bindable var settingsManager: SettingsManager
|
||||||
@StateObject private var permissionManager = ScreenCapturePermissionManager.shared
|
@State private var permissionManager = ScreenCapturePermissionManager.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Fixed header section
|
SetupHeader(icon: "brain.fill", title: "Smart Mode", color: .purple)
|
||||||
VStack(spacing: 16) {
|
|
||||||
Image(systemName: "brain.fill")
|
|
||||||
.font(.system(size: 60))
|
|
||||||
.foregroundColor(.purple)
|
|
||||||
|
|
||||||
Text("Smart Mode")
|
|
||||||
.font(.system(size: 28, weight: .bold))
|
|
||||||
|
|
||||||
Text("Automatically manage timers based on your activity")
|
Text("Automatically manage timers based on your activity")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
|
||||||
.padding(.top, 20)
|
|
||||||
.padding(.bottom, 30)
|
.padding(.bottom, 30)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
VStack(spacing: 24) {
|
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) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
@@ -42,33 +46,30 @@ struct SmartModeSetupView: View {
|
|||||||
Text("Auto-pause on Fullscreen")
|
Text("Auto-pause on Fullscreen")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
}
|
}
|
||||||
Text(
|
Text("Timers will automatically pause when you enter fullscreen mode (videos, games, presentations)")
|
||||||
"Timers will automatically pause when you enter fullscreen mode (videos, games, presentations)"
|
|
||||||
)
|
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Toggle(
|
Toggle("", isOn: $settingsManager.settings.smartMode.autoPauseOnFullscreen)
|
||||||
"",
|
.labelsHidden()
|
||||||
isOn: Binding(
|
.onChange(of: settingsManager.settings.smartMode.autoPauseOnFullscreen) { _, newValue in
|
||||||
get: { settingsManager.settings.smartMode.autoPauseOnFullscreen },
|
|
||||||
set: { newValue in
|
|
||||||
print("🔧 Smart Mode - Auto-pause on fullscreen changed to: \(newValue)")
|
|
||||||
settingsManager.settings.smartMode.autoPauseOnFullscreen = newValue
|
|
||||||
|
|
||||||
if newValue {
|
if newValue {
|
||||||
permissionManager.requestAuthorizationIfNeeded()
|
permissionManager.requestAuthorizationIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
|
||||||
)
|
|
||||||
.labelsHidden()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if settingsManager.settings.smartMode.autoPauseOnFullscreen,
|
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) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Label(
|
Label(
|
||||||
permissionManager.authorizationStatus == .denied
|
permissionManager.authorizationStatus == .denied
|
||||||
@@ -78,9 +79,7 @@ struct SmartModeSetupView: View {
|
|||||||
)
|
)
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.orange)
|
||||||
|
|
||||||
Text(
|
Text("macOS requires Screen Recording permission to detect other apps in fullscreen.")
|
||||||
"macOS requires Screen Recording permission to detect other apps in fullscreen."
|
|
||||||
)
|
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
@@ -101,11 +100,8 @@ struct SmartModeSetupView: View {
|
|||||||
}
|
}
|
||||||
.padding(.top, 8)
|
.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) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
@@ -115,62 +111,29 @@ struct SmartModeSetupView: View {
|
|||||||
Text("Auto-pause on Idle")
|
Text("Auto-pause on Idle")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
}
|
}
|
||||||
Text(
|
Text("Timers will pause when you're inactive for more than the threshold below")
|
||||||
"Timers will pause when you're inactive for more than the threshold below"
|
|
||||||
)
|
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Toggle(
|
Toggle("", isOn: $settingsManager.settings.smartMode.autoPauseOnIdle)
|
||||||
"",
|
|
||||||
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
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
}
|
}
|
||||||
|
|
||||||
if settingsManager.settings.smartMode.autoPauseOnIdle {
|
if settingsManager.settings.smartMode.autoPauseOnIdle {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
ThresholdSlider(
|
||||||
HStack {
|
label: "Idle Threshold:",
|
||||||
Text("Idle Threshold:")
|
value: $settingsManager.settings.smartMode.idleThresholdMinutes,
|
||||||
.font(.subheadline)
|
range: 1...30,
|
||||||
Spacer()
|
unit: "min"
|
||||||
Text(
|
|
||||||
"\(settingsManager.settings.smartMode.idleThresholdMinutes) 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()
|
.padding()
|
||||||
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8))
|
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
// Usage tracking toggle with reset threshold
|
private var usageTrackingSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
@@ -180,70 +143,59 @@ struct SmartModeSetupView: View {
|
|||||||
Text("Track Usage Statistics")
|
Text("Track Usage Statistics")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
}
|
}
|
||||||
Text(
|
Text("Monitor active and idle time, with automatic reset after the specified duration")
|
||||||
"Monitor active and idle time, with automatic reset after the specified duration"
|
|
||||||
)
|
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Toggle(
|
Toggle("", isOn: $settingsManager.settings.smartMode.trackUsage)
|
||||||
"",
|
|
||||||
isOn: Binding(
|
|
||||||
get: { settingsManager.settings.smartMode.trackUsage },
|
|
||||||
set: { newValue in
|
|
||||||
print("🔧 Smart Mode - Track usage changed to: \(newValue)")
|
|
||||||
settingsManager.settings.smartMode.trackUsage = newValue
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
}
|
}
|
||||||
|
|
||||||
if settingsManager.settings.smartMode.trackUsage {
|
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) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Reset After:")
|
Text(label)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(
|
Text("\(value) \(unit)")
|
||||||
"\(settingsManager.settings.smartMode.usageResetAfterMinutes) min"
|
|
||||||
)
|
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Slider(
|
Slider(
|
||||||
value: Binding(
|
value: Binding(
|
||||||
get: {
|
get: { Double(value) },
|
||||||
Double(
|
set: { value = Int($0) }
|
||||||
settingsManager.settings.smartMode
|
|
||||||
.usageResetAfterMinutes)
|
|
||||||
},
|
|
||||||
set: { newValue in
|
|
||||||
print("🔧 Smart Mode - Usage reset after changed to: \(Int(newValue))")
|
|
||||||
settingsManager.settings.smartMode.usageResetAfterMinutes =
|
|
||||||
Int(newValue)
|
|
||||||
}
|
|
||||||
),
|
),
|
||||||
in: 15...240,
|
in: Double(range.lowerBound)...Double(range.upperBound),
|
||||||
step: 15
|
step: Double(step)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8))
|
|
||||||
}
|
|
||||||
.frame(maxWidth: 600)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.padding()
|
|
||||||
.background(.clear)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ final class OnboardingNavigationTests: XCTestCase {
|
|||||||
// MARK: - Navigation Tests
|
// MARK: - Navigation Tests
|
||||||
|
|
||||||
func testOnboardingStartsAtWelcomePage() {
|
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
|
// Verify initial state
|
||||||
XCTAssertFalse(testEnv.settingsManager.settings.hasCompletedOnboarding)
|
XCTAssertFalse(testEnv.settingsManager.settings.hasCompletedOnboarding)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ final class SettingsManagerTests: XCTestCase {
|
|||||||
let defaults = AppSettings.defaults
|
let defaults = AppSettings.defaults
|
||||||
|
|
||||||
XCTAssertTrue(defaults.lookAwayTimer.enabled)
|
XCTAssertTrue(defaults.lookAwayTimer.enabled)
|
||||||
XCTAssertTrue(defaults.blinkTimer.enabled)
|
XCTAssertFalse(defaults.blinkTimer.enabled) // Blink timer is disabled by default
|
||||||
XCTAssertTrue(defaults.postureTimer.enabled)
|
XCTAssertTrue(defaults.postureTimer.enabled)
|
||||||
XCTAssertFalse(defaults.hasCompletedOnboarding)
|
XCTAssertFalse(defaults.hasCompletedOnboarding)
|
||||||
}
|
}
|
||||||
@@ -92,7 +92,7 @@ final class SettingsManagerTests: XCTestCase {
|
|||||||
let expectation = XCTestExpectation(description: "Settings changed")
|
let expectation = XCTestExpectation(description: "Settings changed")
|
||||||
var receivedSettings: AppSettings?
|
var receivedSettings: AppSettings?
|
||||||
|
|
||||||
settingsManager.$settings
|
settingsManager.settingsPublisher
|
||||||
.dropFirst() // Skip initial value
|
.dropFirst() // Skip initial value
|
||||||
.sink { settings in
|
.sink { settings in
|
||||||
receivedSettings = settings
|
receivedSettings = settings
|
||||||
@@ -102,6 +102,7 @@ final class SettingsManagerTests: XCTestCase {
|
|||||||
|
|
||||||
// Trigger change
|
// Trigger change
|
||||||
settingsManager.settings.playSounds = !settingsManager.settings.playSounds
|
settingsManager.settings.playSounds = !settingsManager.settings.playSounds
|
||||||
|
settingsManager.save()
|
||||||
|
|
||||||
await fulfillment(of: [expectation], timeout: 1.0)
|
await fulfillment(of: [expectation], timeout: 1.0)
|
||||||
XCTAssertNotNil(receivedSettings)
|
XCTAssertNotNil(receivedSettings)
|
||||||
|
|||||||
@@ -13,13 +13,18 @@ import XCTest
|
|||||||
|
|
||||||
/// Enhanced mock settings manager with full control over state
|
/// Enhanced mock settings manager with full control over state
|
||||||
@MainActor
|
@MainActor
|
||||||
final class EnhancedMockSettingsManager: ObservableObject, SettingsProviding {
|
@Observable
|
||||||
@Published var settings: AppSettings
|
final class EnhancedMockSettingsManager: SettingsProviding {
|
||||||
|
var settings: AppSettings
|
||||||
|
|
||||||
var settingsPublisher: Published<AppSettings>.Publisher {
|
@ObservationIgnored
|
||||||
$settings
|
private let _settingsSubject: CurrentValueSubject<AppSettings, Never>
|
||||||
|
|
||||||
|
var settingsPublisher: AnyPublisher<AppSettings, Never> {
|
||||||
|
_settingsSubject.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ObservationIgnored
|
||||||
private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] = [
|
private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] = [
|
||||||
.lookAway: \.lookAwayTimer,
|
.lookAway: \.lookAwayTimer,
|
||||||
.blink: \.blinkTimer,
|
.blink: \.blinkTimer,
|
||||||
@@ -27,13 +32,18 @@ final class EnhancedMockSettingsManager: ObservableObject, SettingsProviding {
|
|||||||
]
|
]
|
||||||
|
|
||||||
// Track method calls for verification
|
// Track method calls for verification
|
||||||
|
@ObservationIgnored
|
||||||
private(set) var saveCallCount = 0
|
private(set) var saveCallCount = 0
|
||||||
|
@ObservationIgnored
|
||||||
private(set) var saveImmediatelyCallCount = 0
|
private(set) var saveImmediatelyCallCount = 0
|
||||||
|
@ObservationIgnored
|
||||||
private(set) var loadCallCount = 0
|
private(set) var loadCallCount = 0
|
||||||
|
@ObservationIgnored
|
||||||
private(set) var resetToDefaultsCallCount = 0
|
private(set) var resetToDefaultsCallCount = 0
|
||||||
|
|
||||||
init(settings: AppSettings = .defaults) {
|
init(settings: AppSettings = .defaults) {
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
|
self._settingsSubject = CurrentValueSubject(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func timerConfiguration(for type: TimerType) -> TimerConfiguration {
|
func timerConfiguration(for type: TimerType) -> TimerConfiguration {
|
||||||
@@ -48,6 +58,7 @@ final class EnhancedMockSettingsManager: ObservableObject, SettingsProviding {
|
|||||||
preconditionFailure("Unknown timer type: \(type)")
|
preconditionFailure("Unknown timer type: \(type)")
|
||||||
}
|
}
|
||||||
settings[keyPath: keyPath] = configuration
|
settings[keyPath: keyPath] = configuration
|
||||||
|
_settingsSubject.send(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func allTimerConfigurations() -> [TimerType: TimerConfiguration] {
|
func allTimerConfigurations() -> [TimerType: TimerConfiguration] {
|
||||||
@@ -60,10 +71,12 @@ final class EnhancedMockSettingsManager: ObservableObject, SettingsProviding {
|
|||||||
|
|
||||||
func save() {
|
func save() {
|
||||||
saveCallCount += 1
|
saveCallCount += 1
|
||||||
|
_settingsSubject.send(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveImmediately() {
|
func saveImmediately() {
|
||||||
saveImmediatelyCallCount += 1
|
saveImmediatelyCallCount += 1
|
||||||
|
_settingsSubject.send(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
@@ -73,6 +86,7 @@ final class EnhancedMockSettingsManager: ObservableObject, SettingsProviding {
|
|||||||
func resetToDefaults() {
|
func resetToDefaults() {
|
||||||
resetToDefaultsCallCount += 1
|
resetToDefaultsCallCount += 1
|
||||||
settings = .defaults
|
settings = .defaults
|
||||||
|
_settingsSubject.send(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test helpers
|
// Test helpers
|
||||||
@@ -82,6 +96,7 @@ final class EnhancedMockSettingsManager: ObservableObject, SettingsProviding {
|
|||||||
loadCallCount = 0
|
loadCallCount = 0
|
||||||
resetToDefaultsCallCount = 0
|
resetToDefaultsCallCount = 0
|
||||||
settings = .defaults
|
settings = .defaults
|
||||||
|
_settingsSubject.send(settings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,8 @@ final class BlinkSetupViewTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testBlinkSetupInitialization() {
|
func testBlinkSetupInitialization() {
|
||||||
let view = BlinkSetupView(
|
// Use real SettingsManager for view initialization test since @Bindable requires concrete type
|
||||||
settingsManager: testEnv.settingsManager as! SettingsManager
|
let view = BlinkSetupView(settingsManager: SettingsManager.shared)
|
||||||
)
|
|
||||||
XCTAssertNotNil(view)
|
XCTAssertNotNil(view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,8 @@ final class GeneralSetupViewTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testGeneralSetupInitialization() {
|
func testGeneralSetupInitialization() {
|
||||||
let view = GeneralSetupView(
|
// Use real SettingsManager for view initialization test since @Bindable requires concrete type
|
||||||
settingsManager: testEnv.settingsManager as! SettingsManager,
|
let view = GeneralSetupView(settingsManager: SettingsManager.shared, isOnboarding: true)
|
||||||
isOnboarding: true
|
|
||||||
)
|
|
||||||
XCTAssertNotNil(view)
|
XCTAssertNotNil(view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,8 @@ final class LookAwaySetupViewTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testLookAwaySetupInitialization() {
|
func testLookAwaySetupInitialization() {
|
||||||
let view = LookAwaySetupView(
|
// Use real SettingsManager for view initialization test since @Bindable requires concrete type
|
||||||
settingsManager: testEnv.settingsManager as! SettingsManager
|
let view = LookAwaySetupView(settingsManager: SettingsManager.shared)
|
||||||
)
|
|
||||||
XCTAssertNotNil(view)
|
XCTAssertNotNil(view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,8 @@ final class PostureSetupViewTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testPostureSetupInitialization() {
|
func testPostureSetupInitialization() {
|
||||||
let view = PostureSetupView(
|
// Use real SettingsManager for view initialization test since @Bindable requires concrete type
|
||||||
settingsManager: testEnv.settingsManager as! SettingsManager
|
let view = PostureSetupView(settingsManager: SettingsManager.shared)
|
||||||
)
|
|
||||||
XCTAssertNotNil(view)
|
XCTAssertNotNil(view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user