diff --git a/Gaze/Animations/look-away.json b/Gaze/Animations/look-away.json index 185222f..9a4dd8e 100644 --- a/Gaze/Animations/look-away.json +++ b/Gaze/Animations/look-away.json @@ -192,10 +192,45 @@ ] }, "s": { - "a": 0, + "a": 1, "k": [ - 20, - 20 + { + "t": 0, + "s": [ + 20, + 20 + ], + "i": { + "x": 0.4, + "y": 1 + }, + "o": { + "x": 0.6, + "y": 0 + } + }, + { + "t": 70, + "s": [ + 20, + 2 + ], + "i": { + "x": 0.4, + "y": 1 + }, + "o": { + "x": 0.6, + "y": 0 + } + }, + { + "t": 75, + "s": [ + 20, + 20 + ] + } ] }, "nm": "Eye Ellipse" @@ -382,205 +417,6 @@ } ], "nm": "Eye" - }, - { - "ty": "gr", - "it": [ - { - "ty": "el", - "p": { - "a": 0, - "k": [ - 0, - 0 - ] - }, - "s": { - "a": 0, - "k": [ - 8, - 8 - ] - }, - "nm": "Pupil Ellipse" - }, - { - "ty": "fl", - "c": { - "a": 0, - "k": [ - 0, - 0.478, - 1, - 1 - ] - }, - "o": { - "a": 0, - "k": 100 - }, - "r": 1, - "nm": "Fill" - }, - { - "ty": "tr", - "p": { - "a": 1, - "k": [ - { - "t": 0, - "s": [ - 0, - 0 - ], - "i": { - "x": 0.4, - "y": 1 - }, - "o": { - "x": 0.6, - "y": 0 - } - }, - { - "t": 20, - "s": [ - -20, - -8 - ], - "i": { - "x": 0.4, - "y": 1 - }, - "o": { - "x": 0.6, - "y": 0 - } - }, - { - "t": 40, - "s": [ - -25, - 5 - ], - "i": { - "x": 0.4, - "y": 1 - }, - "o": { - "x": 0.6, - "y": 0 - } - }, - { - "t": 60, - "s": [ - 0, - 8 - ], - "i": { - "x": 0.4, - "y": 1 - }, - "o": { - "x": 0.6, - "y": 0 - } - }, - { - "t": 80, - "s": [ - 25, - 5 - ], - "i": { - "x": 0.4, - "y": 1 - }, - "o": { - "x": 0.6, - "y": 0 - } - }, - { - "t": 100, - "s": [ - 20, - -8 - ], - "i": { - "x": 0.4, - "y": 1 - }, - "o": { - "x": 0.6, - "y": 0 - } - }, - { - "t": 120, - "s": [ - 0, - -15 - ], - "i": { - "x": 0.4, - "y": 1 - }, - "o": { - "x": 0.6, - "y": 0 - } - }, - { - "t": 140, - "s": [ - 0, - -8 - ], - "i": { - "x": 0.4, - "y": 1 - }, - "o": { - "x": 0.6, - "y": 0 - } - }, - { - "t": 150, - "s": [ - 0, - 0 - ] - } - ] - }, - "a": { - "a": 0, - "k": [ - 0, - 0 - ] - }, - "s": { - "a": 0, - "k": [ - 100, - 100 - ] - }, - "r": { - "a": 0, - "k": 0 - }, - "o": { - "a": 0, - "k": 100 - } - } - ], - "nm": "Pupil" } ], "ip": 0, @@ -643,10 +479,45 @@ ] }, "s": { - "a": 0, + "a": 1, "k": [ - 20, - 20 + { + "t": 0, + "s": [ + 20, + 20 + ], + "i": { + "x": 0.4, + "y": 1 + }, + "o": { + "x": 0.6, + "y": 0 + } + }, + { + "t": 70, + "s": [ + 20, + 2 + ], + "i": { + "x": 0.4, + "y": 1 + }, + "o": { + "x": 0.6, + "y": 0 + } + }, + { + "t": 75, + "s": [ + 20, + 20 + ] + } ] }, "nm": "Eye Ellipse" @@ -833,205 +704,6 @@ } ], "nm": "Eye" - }, - { - "ty": "gr", - "it": [ - { - "ty": "el", - "p": { - "a": 0, - "k": [ - 0, - 0 - ] - }, - "s": { - "a": 0, - "k": [ - 8, - 8 - ] - }, - "nm": "Pupil Ellipse" - }, - { - "ty": "fl", - "c": { - "a": 0, - "k": [ - 0, - 0.478, - 1, - 1 - ] - }, - "o": { - "a": 0, - "k": 100 - }, - "r": 1, - "nm": "Fill" - }, - { - "ty": "tr", - "p": { - "a": 1, - "k": [ - { - "t": 0, - "s": [ - 0, - 0 - ], - "i": { - "x": 0.4, - "y": 1 - }, - "o": { - "x": 0.6, - "y": 0 - } - }, - { - "t": 20, - "s": [ - -20, - -8 - ], - "i": { - "x": 0.4, - "y": 1 - }, - "o": { - "x": 0.6, - "y": 0 - } - }, - { - "t": 40, - "s": [ - -25, - 5 - ], - "i": { - "x": 0.4, - "y": 1 - }, - "o": { - "x": 0.6, - "y": 0 - } - }, - { - "t": 60, - "s": [ - 0, - 8 - ], - "i": { - "x": 0.4, - "y": 1 - }, - "o": { - "x": 0.6, - "y": 0 - } - }, - { - "t": 80, - "s": [ - 25, - 5 - ], - "i": { - "x": 0.4, - "y": 1 - }, - "o": { - "x": 0.6, - "y": 0 - } - }, - { - "t": 100, - "s": [ - 20, - -8 - ], - "i": { - "x": 0.4, - "y": 1 - }, - "o": { - "x": 0.6, - "y": 0 - } - }, - { - "t": 120, - "s": [ - 0, - -15 - ], - "i": { - "x": 0.4, - "y": 1 - }, - "o": { - "x": 0.6, - "y": 0 - } - }, - { - "t": 140, - "s": [ - 0, - -8 - ], - "i": { - "x": 0.4, - "y": 1 - }, - "o": { - "x": 0.6, - "y": 0 - } - }, - { - "t": 150, - "s": [ - 0, - 0 - ] - } - ] - }, - "a": { - "a": 0, - "k": [ - 0, - 0 - ] - }, - "s": { - "a": 0, - "k": [ - 100, - 100 - ] - }, - "r": { - "a": 0, - "k": 0 - }, - "o": { - "a": 0, - "k": 100 - } - } - ], - "nm": "Pupil" } ], "ip": 0, diff --git a/Gaze/AppDelegate.swift b/Gaze/AppDelegate.swift index caa8dca..7fcb892 100644 --- a/Gaze/AppDelegate.swift +++ b/Gaze/AppDelegate.swift @@ -201,6 +201,34 @@ private func showReminderWindow(_ content: AnyView) { } } + // Public method to reopen onboarding window + func openOnboarding() { + // Post notification to close menu bar popover + NotificationCenter.default.post(name: Notification.Name("CloseMenuBarPopover"), object: nil) + + // Small delay to allow menu bar to close before opening onboarding + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + guard let self = self, let settingsManager = self.settingsManager else { return } + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 700), + styleMask: [.titled, .closable, .miniaturizable], + backing: .buffered, + defer: false + ) + + window.title = "Gaze Onboarding" + window.center() + window.isReleasedWhenClosed = true + window.contentView = NSHostingView( + rootView: OnboardingContainerView(settingsManager: settingsManager) + ) + + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + } + private func openSettingsWindow(tab: Int) { // If window already exists, switch to the tab and bring it to front if let existingWindow = settingsWindowController?.window { diff --git a/Gaze/GazeApp.swift b/Gaze/GazeApp.swift index 4cefd4b..f152448 100644 --- a/Gaze/GazeApp.swift +++ b/Gaze/GazeApp.swift @@ -45,7 +45,8 @@ struct GazeApp: App { settingsManager: settingsManager, onQuit: { NSApplication.shared.terminate(nil) }, onOpenSettings: { appDelegate.openSettings() }, - onOpenSettingsTab: { tab in appDelegate.openSettings(tab: tab) } + onOpenSettingsTab: { tab in appDelegate.openSettings(tab: tab) }, + onOpenOnboarding: { appDelegate.openOnboarding() } ) } } diff --git a/Gaze/Models/UserTimer.swift b/Gaze/Models/UserTimer.swift index 188038a..4e1a3c7 100644 --- a/Gaze/Models/UserTimer.swift +++ b/Gaze/Models/UserTimer.swift @@ -14,6 +14,7 @@ struct UserTimer: Codable, Equatable, Identifiable, Hashable { var title: String var type: UserTimerType var timeOnScreenSeconds: Int + var intervalMinutes: Int var message: String? var colorHex: String var enabled: Bool @@ -23,6 +24,7 @@ struct UserTimer: Codable, Equatable, Identifiable, Hashable { title: String? = nil, type: UserTimerType = .subtle, timeOnScreenSeconds: Int = 30, + intervalMinutes: Int = 15, message: String? = nil, colorHex: String? = nil, enabled: Bool = true @@ -31,6 +33,7 @@ struct UserTimer: Codable, Equatable, Identifiable, Hashable { self.title = title ?? "User Reminder" self.type = type self.timeOnScreenSeconds = timeOnScreenSeconds + self.intervalMinutes = intervalMinutes self.message = message self.colorHex = colorHex ?? UserTimer.defaultColors[0] self.enabled = enabled @@ -38,8 +41,8 @@ struct UserTimer: Codable, Equatable, Identifiable, Hashable { static func == (lhs: UserTimer, rhs: UserTimer) -> Bool { lhs.id == rhs.id && lhs.title == rhs.title && lhs.type == rhs.type - && lhs.timeOnScreenSeconds == rhs.timeOnScreenSeconds && lhs.message == rhs.message - && lhs.colorHex == rhs.colorHex && lhs.enabled == rhs.enabled + && lhs.timeOnScreenSeconds == rhs.timeOnScreenSeconds && lhs.intervalMinutes == rhs.intervalMinutes + && lhs.message == rhs.message && lhs.colorHex == rhs.colorHex && lhs.enabled == rhs.enabled } // Default color palette for user timers diff --git a/Gaze/Views/MenuBar/MenuBarContentView.swift b/Gaze/Views/MenuBar/MenuBarContentView.swift index 8336fed..11adf6e 100644 --- a/Gaze/Views/MenuBar/MenuBarContentView.swift +++ b/Gaze/Views/MenuBar/MenuBarContentView.swift @@ -50,6 +50,7 @@ struct MenuBarContentView: View { var onQuit: () -> Void var onOpenSettings: () -> Void var onOpenSettingsTab: (Int) -> Void + var onOpenOnboarding: () -> Void var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -116,6 +117,23 @@ struct MenuBarContentView: View { // Controls VStack(spacing: 4) { + // Show "Complete Onboarding" button if not completed + if !settingsManager.settings.hasCompletedOnboarding { + Button(action: { + onOpenOnboarding() + }) { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.accentColor) + Text("Complete Onboarding") + Spacer() + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + .buttonStyle(MenuBarHoverButtonStyle()) + } + Button(action: { if timerEngine.timerStates.values.first?.isPaused == true { timerEngine.resume() @@ -420,6 +438,7 @@ struct UserTimerStatusRow: View { settingsManager: settingsManager, onQuit: {}, onOpenSettings: {}, - onOpenSettingsTab: { _ in } + onOpenSettingsTab: { _ in }, + onOpenOnboarding: {} ) } \ No newline at end of file diff --git a/Gaze/Views/Onboarding/BlinkSetupView.swift b/Gaze/Views/Onboarding/BlinkSetupView.swift index 5da8ed1..1b04a4a 100644 --- a/Gaze/Views/Onboarding/BlinkSetupView.swift +++ b/Gaze/Views/Onboarding/BlinkSetupView.swift @@ -27,18 +27,14 @@ struct BlinkSetupView: View { // Vertically centered content Spacer() - - VStack(spacing: 30) { - Text("Keep your eyes hydrated") - .font(.title3) - .foregroundColor(.secondary) - // InfoBox with link functionality + VStack(spacing: 30) { HStack(spacing: 12) { Button(action: { if let url = URL( - string: "https://www.healthline.com/health/eye-health/eye-strain#symptoms") - { + 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) @@ -50,7 +46,7 @@ struct BlinkSetupView: View { .foregroundColor(.white) }.buttonStyle(.plain) Text( - "We blink much less when focusing on screens. Regular blink reminders help prevent dry eyes" + "We blink much less when focusing on screens. Regular blink reminders help prevent dry eyes." ) .font(.headline) .foregroundColor(.white) @@ -99,7 +95,7 @@ struct BlinkSetupView: View { .foregroundColor(.secondary) } } - + Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Gaze/Views/Onboarding/LookAwaySetupView.swift b/Gaze/Views/Onboarding/LookAwaySetupView.swift index 35a4f43..9ed5742 100644 --- a/Gaze/Views/Onboarding/LookAwaySetupView.swift +++ b/Gaze/Views/Onboarding/LookAwaySetupView.swift @@ -40,8 +40,9 @@ struct LookAwaySetupView: View { HStack(spacing: 12) { Button(action: { if let url = URL( - string: "https://www.healthline.com/health/eye-health/20-20-20-rule") - { + string: + "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." + ) { #if os(iOS) UIApplication.shared.open(url) #elseif os(macOS) diff --git a/Gaze/Views/Onboarding/PostureSetupView.swift b/Gaze/Views/Onboarding/PostureSetupView.swift index 27695ea..fefae65 100644 --- a/Gaze/Views/Onboarding/PostureSetupView.swift +++ b/Gaze/Views/Onboarding/PostureSetupView.swift @@ -10,6 +10,8 @@ import SwiftUI struct PostureSetupView: View { @Binding var enabled: Bool @Binding var intervalMinutes: Int + + @State private var isPreviewShowing = false var body: some View { VStack(spacing: 0) { @@ -29,16 +31,12 @@ struct PostureSetupView: View { Spacer() VStack(spacing: 30) { - Text("Maintain proper ergonomics") - .font(.title3) - .foregroundColor(.secondary) - - // InfoBox with link functionality HStack(spacing: 12) { Button(action: { if let url = URL( - string: "https://www.healthline.com/health/ergonomic-workspace") - { + string: + "https://pubmed.ncbi.nlm.nih.gov/40111906/#:~:text=For studies exploring sitting posture, seven found a relationship with LBP. Regarding studies on sitting behavior, only one showed no relationship between LBP prevalence, while twelve indicated a relationship." + ) { #if os(iOS) UIApplication.shared.open(url) #elseif os(macOS) @@ -98,6 +96,29 @@ struct PostureSetupView: View { .font(.caption) .foregroundColor(.secondary) } + + // Preview button + Button(action: { + isPreviewShowing = true + }) { + HStack { + Image(systemName: "eye") + .foregroundColor(.white) + Text("Preview Reminder") + .foregroundColor(.white) + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(.blue) + .cornerRadius(8) + } + .fullScreenCover(isPresented: $isPreviewShowing) { + PostureReminderView(sizePercentage: 10.0, onDismiss: { + isPreviewShowing = false + }) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.black.opacity(0.85)) + } } Spacer() diff --git a/Gaze/Views/Onboarding/UserTimersView.swift b/Gaze/Views/Onboarding/UserTimersView.swift index f4dc6a7..46bf20e 100644 --- a/Gaze/Views/Onboarding/UserTimersView.swift +++ b/Gaze/Views/Onboarding/UserTimersView.swift @@ -139,6 +139,7 @@ struct UserTimerRow: View { var onEdit: () -> Void var onDelete: () -> Void @State private var isHovered = false + @State private var showingDeleteConfirmation = false var body: some View { HStack(spacing: 12) { @@ -155,7 +156,7 @@ struct UserTimerRow: View { .font(.subheadline) .fontWeight(.medium) .lineLimit(1) - Text("\(timer.type.displayName) • \(timer.timeOnScreenSeconds)s on screen") + Text("\(timer.type.displayName) • \(timer.timeOnScreenSeconds)s on screen • \(timer.intervalMinutes) min interval") .font(.caption) .foregroundColor(.secondary) } @@ -175,12 +176,20 @@ struct UserTimerRow: View { } .buttonStyle(.plain) - Button(action: onDelete) { + Button(action: { showingDeleteConfirmation = true }) { Image(systemName: "trash.circle.fill") .font(.title3) .foregroundColor(.red) } .buttonStyle(.plain) + .confirmationDialog("Delete Timer", isPresented: $showingDeleteConfirmation) { + Button("Delete", role: .destructive) { + onDelete() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Are you sure you want to delete this timer? This action cannot be undone.") + } } } .padding() @@ -200,10 +209,11 @@ struct UserTimerEditSheet: View { var onSave: (UserTimer) -> Void var onCancel: () -> Void - @State private var title: String +@State private var title: String @State private var message: String @State private var type: UserTimerType @State private var timeOnScreen: Int + @State private var intervalMinutes: Int @State private var selectedColorHex: String init( @@ -222,6 +232,7 @@ struct UserTimerEditSheet: View { _message = State(initialValue: timer?.message ?? "") _type = State(initialValue: timer?.type ?? .subtle) _timeOnScreen = State(initialValue: timer?.timeOnScreenSeconds ?? 30) + _intervalMinutes = State(initialValue: timer?.intervalMinutes ?? 15) _selectedColorHex = State( initialValue: timer?.colorHex ?? UserTimer.defaultColors[existingTimersCount % UserTimer.defaultColors.count]) @@ -311,6 +322,27 @@ struct UserTimerEditSheet: View { } } + VStack(alignment: .leading, spacing: 8) { + Text("Interval") + .font(.headline) + HStack { + Slider( + value: Binding( + get: { Double(intervalMinutes) }, + set: { intervalMinutes = Int($0) } + ), + in: 1...120, + step: 1 + ) + Text("\(intervalMinutes) min") + .frame(width: 60, alignment: .trailing) + .monospacedDigit() + } + Text("How often this reminder will appear (in minutes)") + .font(.caption) + .foregroundColor(.secondary) + } + VStack(alignment: .leading, spacing: 8) { Text("Message (Optional)") .font(.headline) @@ -334,6 +366,7 @@ struct UserTimerEditSheet: View { title: title, type: type, timeOnScreenSeconds: timeOnScreen, + intervalMinutes: intervalMinutes, message: message.isEmpty ? nil : message, colorHex: selectedColorHex, enabled: timer?.enabled ?? true @@ -358,10 +391,10 @@ struct UserTimerEditSheet: View { userTimers: .constant([ UserTimer( id: "1", title: "User Reminder 1", type: .subtle, timeOnScreenSeconds: 30, - message: "Take a break", colorHex: "9B59B6"), + intervalMinutes: 15, message: "Take a break", colorHex: "9B59B6"), UserTimer( id: "2", title: "User Reminder 2", type: .overlay, timeOnScreenSeconds: 60, - message: "Stretch your legs", colorHex: "3498DB"), + intervalMinutes: 30, message: "Stretch your legs", colorHex: "3498DB"), ]) ) } diff --git a/GazeUITests/MenuBarUITests.swift b/GazeUITests/MenuBarUITests.swift index 4a9d703..472feca 100644 --- a/GazeUITests/MenuBarUITests.swift +++ b/GazeUITests/MenuBarUITests.swift @@ -99,4 +99,38 @@ final class MenuBarUITests: XCTestCase { } } } + + func testCompleteOnboardingButtonVisibleWhenOnboardingIncomplete() throws { + // Relaunch app without skip-onboarding flag + app.terminate() + let newApp = XCUIApplication() + newApp.launchArguments.append("--reset-onboarding") + newApp.launch() + + let menuBar = newApp.menuBarItems.firstMatch + if menuBar.waitForExistence(timeout: 5) { + menuBar.click() + + let completeOnboardingButton = newApp.buttons["Complete Onboarding"] + XCTAssertTrue( + completeOnboardingButton.waitForExistence(timeout: 2), + "Complete Onboarding button should be visible when onboarding is incomplete" + ) + } + + newApp.terminate() + } + + func testCompleteOnboardingButtonNotVisibleWhenOnboardingComplete() throws { + let menuBar = app.menuBarItems.firstMatch + if menuBar.waitForExistence(timeout: 5) { + menuBar.click() + + let completeOnboardingButton = app.buttons["Complete Onboarding"] + XCTAssertFalse( + completeOnboardingButton.exists, + "Complete Onboarding button should not be visible when onboarding is complete" + ) + } + } }