feat: better links and animation

This commit is contained in:
Michael Freno
2026-01-10 09:14:32 -05:00
parent 97cbfebb9f
commit 0bfbe30350
10 changed files with 240 additions and 432 deletions

View File

@@ -192,10 +192,45 @@
] ]
}, },
"s": { "s": {
"a": 0, "a": 1,
"k": [ "k": [
{
"t": 0,
"s": [
20, 20,
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" "nm": "Eye Ellipse"
@@ -382,205 +417,6 @@
} }
], ],
"nm": "Eye" "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, "ip": 0,
@@ -643,10 +479,45 @@
] ]
}, },
"s": { "s": {
"a": 0, "a": 1,
"k": [ "k": [
{
"t": 0,
"s": [
20, 20,
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" "nm": "Eye Ellipse"
@@ -833,205 +704,6 @@
} }
], ],
"nm": "Eye" "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, "ip": 0,

View File

@@ -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) { private func openSettingsWindow(tab: Int) {
// If window already exists, switch to the tab and bring it to front // If window already exists, switch to the tab and bring it to front
if let existingWindow = settingsWindowController?.window { if let existingWindow = settingsWindowController?.window {

View File

@@ -45,7 +45,8 @@ struct GazeApp: App {
settingsManager: settingsManager, settingsManager: settingsManager,
onQuit: { NSApplication.shared.terminate(nil) }, onQuit: { NSApplication.shared.terminate(nil) },
onOpenSettings: { appDelegate.openSettings() }, onOpenSettings: { appDelegate.openSettings() },
onOpenSettingsTab: { tab in appDelegate.openSettings(tab: tab) } onOpenSettingsTab: { tab in appDelegate.openSettings(tab: tab) },
onOpenOnboarding: { appDelegate.openOnboarding() }
) )
} }
} }

View File

@@ -14,6 +14,7 @@ struct UserTimer: Codable, Equatable, Identifiable, Hashable {
var title: String var title: String
var type: UserTimerType var type: UserTimerType
var timeOnScreenSeconds: Int var timeOnScreenSeconds: Int
var intervalMinutes: Int
var message: String? var message: String?
var colorHex: String var colorHex: String
var enabled: Bool var enabled: Bool
@@ -23,6 +24,7 @@ struct UserTimer: Codable, Equatable, Identifiable, Hashable {
title: String? = nil, title: String? = nil,
type: UserTimerType = .subtle, type: UserTimerType = .subtle,
timeOnScreenSeconds: Int = 30, timeOnScreenSeconds: Int = 30,
intervalMinutes: Int = 15,
message: String? = nil, message: String? = nil,
colorHex: String? = nil, colorHex: String? = nil,
enabled: Bool = true enabled: Bool = true
@@ -31,6 +33,7 @@ struct UserTimer: Codable, Equatable, Identifiable, Hashable {
self.title = title ?? "User Reminder" self.title = title ?? "User Reminder"
self.type = type self.type = type
self.timeOnScreenSeconds = timeOnScreenSeconds self.timeOnScreenSeconds = timeOnScreenSeconds
self.intervalMinutes = intervalMinutes
self.message = message self.message = message
self.colorHex = colorHex ?? UserTimer.defaultColors[0] self.colorHex = colorHex ?? UserTimer.defaultColors[0]
self.enabled = enabled self.enabled = enabled
@@ -38,8 +41,8 @@ struct UserTimer: Codable, Equatable, Identifiable, Hashable {
static func == (lhs: UserTimer, rhs: UserTimer) -> Bool { static func == (lhs: UserTimer, rhs: UserTimer) -> Bool {
lhs.id == rhs.id && lhs.title == rhs.title && lhs.type == rhs.type lhs.id == rhs.id && lhs.title == rhs.title && lhs.type == rhs.type
&& lhs.timeOnScreenSeconds == rhs.timeOnScreenSeconds && lhs.message == rhs.message && lhs.timeOnScreenSeconds == rhs.timeOnScreenSeconds && lhs.intervalMinutes == rhs.intervalMinutes
&& lhs.colorHex == rhs.colorHex && lhs.enabled == rhs.enabled && lhs.message == rhs.message && lhs.colorHex == rhs.colorHex && lhs.enabled == rhs.enabled
} }
// Default color palette for user timers // Default color palette for user timers

View File

@@ -50,6 +50,7 @@ struct MenuBarContentView: View {
var onQuit: () -> Void var onQuit: () -> Void
var onOpenSettings: () -> Void var onOpenSettings: () -> Void
var onOpenSettingsTab: (Int) -> Void var onOpenSettingsTab: (Int) -> Void
var onOpenOnboarding: () -> Void
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
@@ -116,6 +117,23 @@ struct MenuBarContentView: View {
// Controls // Controls
VStack(spacing: 4) { 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: { Button(action: {
if timerEngine.timerStates.values.first?.isPaused == true { if timerEngine.timerStates.values.first?.isPaused == true {
timerEngine.resume() timerEngine.resume()
@@ -420,6 +438,7 @@ struct UserTimerStatusRow: View {
settingsManager: settingsManager, settingsManager: settingsManager,
onQuit: {}, onQuit: {},
onOpenSettings: {}, onOpenSettings: {},
onOpenSettingsTab: { _ in } onOpenSettingsTab: { _ in },
onOpenOnboarding: {}
) )
} }

View File

@@ -29,16 +29,12 @@ struct BlinkSetupView: View {
Spacer() Spacer()
VStack(spacing: 30) { VStack(spacing: 30) {
Text("Keep your eyes hydrated")
.font(.title3)
.foregroundColor(.secondary)
// InfoBox with link functionality
HStack(spacing: 12) { HStack(spacing: 12) {
Button(action: { Button(action: {
if let url = URL( 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) #if os(iOS)
UIApplication.shared.open(url) UIApplication.shared.open(url)
#elseif os(macOS) #elseif os(macOS)
@@ -50,7 +46,7 @@ struct BlinkSetupView: View {
.foregroundColor(.white) .foregroundColor(.white)
}.buttonStyle(.plain) }.buttonStyle(.plain)
Text( 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) .font(.headline)
.foregroundColor(.white) .foregroundColor(.white)

View File

@@ -40,8 +40,9 @@ struct LookAwaySetupView: View {
HStack(spacing: 12) { HStack(spacing: 12) {
Button(action: { Button(action: {
if let url = URL( 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) #if os(iOS)
UIApplication.shared.open(url) UIApplication.shared.open(url)
#elseif os(macOS) #elseif os(macOS)

View File

@@ -11,6 +11,8 @@ struct PostureSetupView: View {
@Binding var enabled: Bool @Binding var enabled: Bool
@Binding var intervalMinutes: Int @Binding var intervalMinutes: Int
@State private var isPreviewShowing = false
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Fixed header section // Fixed header section
@@ -29,16 +31,12 @@ struct PostureSetupView: View {
Spacer() Spacer()
VStack(spacing: 30) { VStack(spacing: 30) {
Text("Maintain proper ergonomics")
.font(.title3)
.foregroundColor(.secondary)
// InfoBox with link functionality
HStack(spacing: 12) { HStack(spacing: 12) {
Button(action: { Button(action: {
if let url = URL( 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) #if os(iOS)
UIApplication.shared.open(url) UIApplication.shared.open(url)
#elseif os(macOS) #elseif os(macOS)
@@ -98,6 +96,29 @@ struct PostureSetupView: View {
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .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() Spacer()

View File

@@ -139,6 +139,7 @@ struct UserTimerRow: View {
var onEdit: () -> Void var onEdit: () -> Void
var onDelete: () -> Void var onDelete: () -> Void
@State private var isHovered = false @State private var isHovered = false
@State private var showingDeleteConfirmation = false
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
@@ -155,7 +156,7 @@ struct UserTimerRow: View {
.font(.subheadline) .font(.subheadline)
.fontWeight(.medium) .fontWeight(.medium)
.lineLimit(1) .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) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
@@ -175,12 +176,20 @@ struct UserTimerRow: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
Button(action: onDelete) { Button(action: { showingDeleteConfirmation = true }) {
Image(systemName: "trash.circle.fill") Image(systemName: "trash.circle.fill")
.font(.title3) .font(.title3)
.foregroundColor(.red) .foregroundColor(.red)
} }
.buttonStyle(.plain) .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() .padding()
@@ -200,10 +209,11 @@ struct UserTimerEditSheet: View {
var onSave: (UserTimer) -> Void var onSave: (UserTimer) -> Void
var onCancel: () -> Void var onCancel: () -> Void
@State private var title: String @State private var title: String
@State private var message: String @State private var message: String
@State private var type: UserTimerType @State private var type: UserTimerType
@State private var timeOnScreen: Int @State private var timeOnScreen: Int
@State private var intervalMinutes: Int
@State private var selectedColorHex: String @State private var selectedColorHex: String
init( init(
@@ -222,6 +232,7 @@ struct UserTimerEditSheet: View {
_message = State(initialValue: timer?.message ?? "") _message = State(initialValue: timer?.message ?? "")
_type = State(initialValue: timer?.type ?? .subtle) _type = State(initialValue: timer?.type ?? .subtle)
_timeOnScreen = State(initialValue: timer?.timeOnScreenSeconds ?? 30) _timeOnScreen = State(initialValue: timer?.timeOnScreenSeconds ?? 30)
_intervalMinutes = State(initialValue: timer?.intervalMinutes ?? 15)
_selectedColorHex = State( _selectedColorHex = State(
initialValue: timer?.colorHex initialValue: timer?.colorHex
?? UserTimer.defaultColors[existingTimersCount % UserTimer.defaultColors.count]) ?? 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) { VStack(alignment: .leading, spacing: 8) {
Text("Message (Optional)") Text("Message (Optional)")
.font(.headline) .font(.headline)
@@ -334,6 +366,7 @@ struct UserTimerEditSheet: View {
title: title, title: title,
type: type, type: type,
timeOnScreenSeconds: timeOnScreen, timeOnScreenSeconds: timeOnScreen,
intervalMinutes: intervalMinutes,
message: message.isEmpty ? nil : message, message: message.isEmpty ? nil : message,
colorHex: selectedColorHex, colorHex: selectedColorHex,
enabled: timer?.enabled ?? true enabled: timer?.enabled ?? true
@@ -358,10 +391,10 @@ struct UserTimerEditSheet: View {
userTimers: .constant([ userTimers: .constant([
UserTimer( UserTimer(
id: "1", title: "User Reminder 1", type: .subtle, timeOnScreenSeconds: 30, 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( UserTimer(
id: "2", title: "User Reminder 2", type: .overlay, timeOnScreenSeconds: 60, id: "2", title: "User Reminder 2", type: .overlay, timeOnScreenSeconds: 60,
message: "Stretch your legs", colorHex: "3498DB"), intervalMinutes: 30, message: "Stretch your legs", colorHex: "3498DB"),
]) ])
) )
} }

View File

@@ -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"
)
}
}
} }