feat: onboarding flow styled

This commit is contained in:
Michael Freno
2026-01-08 08:22:42 -05:00
parent 658bbd8e02
commit f9e9966857
11 changed files with 518 additions and 108 deletions

View File

@@ -191,4 +191,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
reminderWindowController?.close()
reminderWindowController = nil
}
// Public method to get menubar icon position for animations
func getMenuBarIconPosition() -> NSRect? {
return statusItem?.button?.window?.frame
}
}

View File

@@ -22,6 +22,10 @@ class SettingsManager: ObservableObject {
private let settingsKey = "gazeAppSettings"
private init() {
#if DEBUG
// Clear settings on every development build
UserDefaults.standard.removeObject(forKey: "gazeAppSettings")
#endif
self.settings = Self.loadSettings()
}

View File

@@ -16,7 +16,7 @@ class TimerEngine: ObservableObject {
private var timerSubscription: AnyCancellable?
private let settingsManager: SettingsManager
nonisolated init(settingsManager: SettingsManager = .shared) {
init(settingsManager: SettingsManager) {
self.settingsManager = settingsManager
}

View File

@@ -7,6 +7,39 @@
import SwiftUI
// Hover button style for menubar items
struct MenuBarButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(
RoundedRectangle(cornerRadius: 6)
.fill(configuration.isPressed ? Color.blue.opacity(0.2) : Color.gray.opacity(0.1))
.opacity(configuration.isPressed ? 1 : 0)
)
.contentShape(Rectangle())
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
}
}
struct MenuBarHoverButtonStyle: ButtonStyle {
@State private var isHovered = false
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(
RoundedRectangle(cornerRadius: 6)
.fill(isHovered ? Color.gray.opacity(0.15) : Color.clear)
)
.contentShape(Rectangle())
.onHover { hovering in
isHovered = hovering
}
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
.animation(.easeInOut(duration: 0.1), value: isHovered)
.animation(.easeInOut(duration: 0.05), value: configuration.isPressed)
}
}
struct MenuBarContentView: View {
@ObservedObject var timerEngine: TimerEngine
@ObservedObject var settingsManager: SettingsManager
@@ -54,7 +87,7 @@ struct MenuBarContentView: View {
}
// Controls
VStack(spacing: 8) {
VStack(spacing: 4) {
Button(action: {
if timerEngine.timerStates.values.first?.isPaused == true {
timerEngine.resume()
@@ -67,10 +100,10 @@ struct MenuBarContentView: View {
Text(isPaused ? "Resume All Timers" : "Pause All Timers")
Spacer()
}
.contentShape(Rectangle())
.padding(.horizontal, 8)
.padding(.vertical, 6)
}
.buttonStyle(.plain)
.padding(.horizontal)
.buttonStyle(MenuBarHoverButtonStyle())
Button(action: {
// TODO: Open settings window
@@ -80,12 +113,13 @@ struct MenuBarContentView: View {
Text("Settings...")
Spacer()
}
.contentShape(Rectangle())
.padding(.horizontal, 8)
.padding(.vertical, 6)
}
.buttonStyle(.plain)
.padding(.horizontal)
.buttonStyle(MenuBarHoverButtonStyle())
}
.padding(.vertical, 8)
.padding(.horizontal, 8)
Divider()
@@ -93,13 +127,16 @@ struct MenuBarContentView: View {
Button(action: onQuit) {
HStack {
Image(systemName: "power")
.foregroundColor(.red)
Text("Quit Gaze")
Spacer()
}
.contentShape(Rectangle())
.padding(.horizontal, 8)
.padding(.vertical, 6)
}
.buttonStyle(.plain)
.padding()
.buttonStyle(MenuBarHoverButtonStyle())
.padding(.horizontal, 8)
.padding(.vertical, 8)
}
.frame(width: 300)
}
@@ -113,6 +150,7 @@ struct TimerStatusRow: View {
let type: TimerType
let state: TimerState
var onSkip: () -> Void
@State private var isHovered = false
var body: some View {
HStack {
@@ -136,9 +174,17 @@ struct TimerStatusRow: View {
Image(systemName: "forward.fill")
.font(.caption)
.foregroundColor(.blue)
.padding(6)
.background(
Circle()
.fill(isHovered ? Color.blue.opacity(0.1) : Color.clear)
)
}
.buttonStyle(.plain)
.help("Skip to next \(type.displayName) reminder")
.onHover { hovering in
isHovered = hovering
}
}
.padding(.horizontal)
.padding(.vertical, 4)
@@ -170,9 +216,11 @@ struct TimerStatusRow: View {
}
#Preview {
let settingsManager = SettingsManager.shared
let timerEngine = TimerEngine(settingsManager: settingsManager)
MenuBarContentView(
timerEngine: TimerEngine(settingsManager: .shared),
settingsManager: .shared,
timerEngine: timerEngine,
settingsManager: settingsManager,
onQuit: {}
)
}

View File

@@ -11,6 +11,7 @@ struct BlinkSetupView: View {
@Binding var enabled: Bool
@Binding var intervalMinutes: Int
var onContinue: () -> Void
var onBack: (() -> Void)?
var body: some View {
VStack(spacing: 30) {
@@ -49,27 +50,41 @@ struct BlinkSetupView: View {
}
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
.glassEffect(in: .rect(cornerRadius: 12))
InfoBox(text: "We blink much less when focusing on screens. Regular blink reminders help prevent dry eyes")
Spacer()
HStack(spacing: 12) {
if let onBack = onBack {
Button(action: onBack) {
HStack {
Image(systemName: "chevron.left")
Text("Back")
}
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
}
.buttonStyle(.plain)
.glassEffect(.regular.interactive())
}
Button(action: onContinue) {
Text("Continue")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(12)
}
.buttonStyle(.plain)
.glassEffect(.regular.tint(.blue).interactive())
}
.padding(.horizontal, 40)
}
.frame(width: 600, height: 500)
.padding()
.background(.clear)
}
}
@@ -77,6 +92,7 @@ struct BlinkSetupView: View {
BlinkSetupView(
enabled: .constant(true),
intervalMinutes: .constant(5),
onContinue: {}
onContinue: {},
onBack: {}
)
}

View File

@@ -9,6 +9,7 @@ import SwiftUI
struct CompletionView: View {
var onComplete: () -> Void
var onBack: (() -> Void)?
var body: some View {
VStack(spacing: 30) {
@@ -60,28 +61,42 @@ struct CompletionView: View {
.padding(.horizontal)
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
.glassEffect(in: .rect(cornerRadius: 12))
Spacer()
HStack(spacing: 12) {
if let onBack = onBack {
Button(action: onBack) {
HStack {
Image(systemName: "chevron.left")
Text("Back")
}
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
}
.buttonStyle(.plain)
.glassEffect(.regular.interactive())
}
Button(action: onComplete) {
Text("Get Started")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.green)
.foregroundColor(.white)
.cornerRadius(12)
}
.buttonStyle(.plain)
.glassEffect(.regular.tint(.green).interactive())
}
.padding(.horizontal, 40)
}
.frame(width: 600, height: 500)
.padding()
.background(.clear)
}
}
#Preview {
CompletionView(onComplete: {})
CompletionView(onComplete: {}, onBack: {})
}

View File

@@ -66,27 +66,41 @@ struct LookAwaySetupView: View {
}
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
.glassEffect(in: .rect(cornerRadius: 12))
InfoBox(text: "Every 20 minutes, look at something 20 feet away for 20 seconds to reduce eye strain")
InfoBox(text: "Every \(intervalMinutes) minutes, look in the distance for \(countdownSeconds) seconds to reduce eye strain")
Spacer()
HStack(spacing: 12) {
if let onBack = onBack {
Button(action: onBack) {
HStack {
Image(systemName: "chevron.left")
Text("Back")
}
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
}
.buttonStyle(.plain)
.glassEffect(.regular.interactive())
}
Button(action: onContinue) {
Text("Continue")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(12)
}
.buttonStyle(.plain)
.glassEffect(.regular.tint(.blue).interactive())
}
.padding(.horizontal, 40)
}
.frame(width: 600, height: 500)
.padding()
.background(.clear)
}
}
@@ -102,8 +116,7 @@ struct InfoBox: View {
.foregroundColor(.secondary)
}
.padding()
.background(Color.blue.opacity(0.1))
.cornerRadius(8)
.glassEffect(.regular.tint(.blue), in: .rect(cornerRadius: 8))
}
}
@@ -112,6 +125,7 @@ struct InfoBox: View {
enabled: .constant(true),
intervalMinutes: .constant(20),
countdownSeconds: .constant(20),
onContinue: {}
onContinue: {},
onBack: {}
)
}

View File

@@ -6,6 +6,26 @@
//
import SwiftUI
import AppKit
// NSVisualEffectView wrapper for SwiftUI
struct VisualEffectView: NSViewRepresentable {
let material: NSVisualEffectView.Material
let blendingMode: NSVisualEffectView.BlendingMode
func makeNSView(context: Context) -> NSVisualEffectView {
let view = NSVisualEffectView()
view.material = material
view.blendingMode = blendingMode
view.state = .active
return view
}
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {
nsView.material = material
nsView.blendingMode = blendingMode
}
}
struct OnboardingContainerView: View {
@ObservedObject var settingsManager: SettingsManager
@@ -17,54 +37,90 @@ struct OnboardingContainerView: View {
@State private var blinkIntervalMinutes = 5
@State private var postureEnabled = true
@State private var postureIntervalMinutes = 30
@State private var launchAtLogin = false
@State private var isAnimatingOut = false
@Environment(\.dismiss) private var dismiss
var body: some View {
ZStack {
// Semi-transparent background with blur
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
.ignoresSafeArea()
VStack(spacing: 0) {
TabView(selection: $currentPage) {
WelcomeView(onContinue: { currentPage = 1 })
WelcomeView(
onContinue: { currentPage = 1 }
)
.tag(0)
.tabItem {
Image(systemName: "hand.wave.fill")
}
LookAwaySetupView(
enabled: $lookAwayEnabled,
intervalMinutes: $lookAwayIntervalMinutes,
countdownSeconds: $lookAwayCountdownSeconds,
onContinue: { currentPage = 2 }
onContinue: { currentPage = 2 },
onBack: { currentPage = 0 }
)
.tag(1)
.tabItem {
Image(systemName: "eye.fill")
}
BlinkSetupView(
enabled: $blinkEnabled,
intervalMinutes: $blinkIntervalMinutes,
onContinue: { currentPage = 3 }
onContinue: { currentPage = 3 },
onBack: { currentPage = 1 }
)
.tag(2)
.tabItem {
Image(systemName: "eye.circle.fill")
}
PostureSetupView(
enabled: $postureEnabled,
intervalMinutes: $postureIntervalMinutes,
onContinue: { currentPage = 4 }
onContinue: { currentPage = 4 },
onBack: { currentPage = 2 }
)
.tag(3)
.tabItem {
Image(systemName: "figure.stand")
}
SettingsOnboardingView(
launchAtLogin: $launchAtLogin,
onContinue: { currentPage = 5 },
onBack: { currentPage = 3 }
)
.tag(4)
.tabItem {
Image(systemName: "gearshape.fill")
}
CompletionView(
onComplete: {
completeOnboarding()
}
},
onBack: { currentPage = 4 }
)
.tag(4)
.tag(5)
.tabItem {
Image(systemName: "checkmark.circle.fill")
}
}
.tabViewStyle(.automatic)
// Page indicator
Text("\(currentPage + 1)/5")
.font(.subheadline)
.foregroundColor(.secondary)
.padding(.top, 8)
.padding(.bottom, 20)
}
}
.opacity(isAnimatingOut ? 0 : 1)
.scaleEffect(isAnimatingOut ? 0.3 : 1.0)
}
private func completeOnboarding() {
// Save settings
settingsManager.settings.lookAwayTimer = TimerConfiguration(
enabled: lookAwayEnabled,
intervalSeconds: lookAwayIntervalMinutes * 60
@@ -81,7 +137,73 @@ struct OnboardingContainerView: View {
intervalSeconds: postureIntervalMinutes * 60
)
settingsManager.settings.launchAtLogin = launchAtLogin
settingsManager.settings.hasCompletedOnboarding = true
// Apply launch at login setting
do {
if launchAtLogin {
try LaunchAtLoginManager.enable()
} else {
try LaunchAtLoginManager.disable()
}
} catch {
print("Failed to set launch at login: \(error)")
}
// Perform vacuum animation
performVacuumAnimation()
}
private func performVacuumAnimation() {
// Get the NSWindow reference
guard let window = NSApplication.shared.windows.first(where: { $0.isVisible && $0.contentView != nil }) else {
// Fallback: just dismiss without animation
dismiss()
return
}
// Get menubar icon position from AppDelegate
let appDelegate = NSApplication.shared.delegate as? AppDelegate
let targetFrame = appDelegate?.getMenuBarIconPosition()
// Calculate target position (menubar icon or top-center as fallback)
let targetRect: NSRect
if let menuBarFrame = targetFrame {
// Use menubar icon position
targetRect = NSRect(
x: menuBarFrame.midX,
y: menuBarFrame.midY,
width: 0,
height: 0
)
} else {
// Fallback to top-center of screen
let screen = NSScreen.main?.frame ?? .zero
targetRect = NSRect(
x: screen.midX,
y: screen.maxY,
width: 0,
height: 0
)
}
// Start SwiftUI animation for visual effects
withAnimation(.easeInOut(duration: 0.7)) {
isAnimatingOut = true
}
// Animate window frame using AppKit
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.7
context.timingFunction = CAMediaTimingFunction(name: .easeIn)
window.animator().setFrame(targetRect, display: true)
window.animator().alphaValue = 0
}, completionHandler: {
// Close window after animation completes
self.dismiss()
window.close()
})
}
}

View File

@@ -11,6 +11,7 @@ struct PostureSetupView: View {
@Binding var enabled: Bool
@Binding var intervalMinutes: Int
var onContinue: () -> Void
var onBack: (() -> Void)?
var body: some View {
VStack(spacing: 30) {
@@ -49,27 +50,41 @@ struct PostureSetupView: View {
}
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
.glassEffect(in: .rect(cornerRadius: 12))
InfoBox(text: "Regular posture checks help prevent back and neck pain from prolonged sitting")
Spacer()
HStack(spacing: 12) {
if let onBack = onBack {
Button(action: onBack) {
HStack {
Image(systemName: "chevron.left")
Text("Back")
}
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
}
.buttonStyle(.plain)
.glassEffect(.regular.interactive())
}
Button(action: onContinue) {
Text("Continue")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(12)
}
.buttonStyle(.plain)
.glassEffect(.regular.tint(.blue).interactive())
}
.padding(.horizontal, 40)
}
.frame(width: 600, height: 500)
.padding()
.background(.clear)
}
}
@@ -77,6 +92,7 @@ struct PostureSetupView: View {
PostureSetupView(
enabled: .constant(true),
intervalMinutes: .constant(30),
onContinue: {}
onContinue: {},
onBack: {}
)
}

View File

@@ -0,0 +1,170 @@
//
// SettingsOnboardingView.swift
// Gaze
//
// Created by Mike Freno on 1/8/26.
//
import SwiftUI
struct SettingsOnboardingView: View {
@Binding var launchAtLogin: Bool
var onContinue: () -> Void
var onBack: (() -> Void)?
var body: some View {
VStack(spacing: 30) {
Spacer()
Image(systemName: "gearshape.fill")
.font(.system(size: 80))
.foregroundColor(.blue)
Text("Final Settings")
.font(.system(size: 36, weight: .bold))
Text("Configure app preferences and support the project")
.font(.title3)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
VStack(spacing: 20) {
// Launch at Login Toggle
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Launch at Login")
.font(.headline)
Text("Start Gaze automatically when you log in")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Toggle("", isOn: $launchAtLogin)
.labelsHidden()
.onChange(of: launchAtLogin) { oldValue, newValue in
applyLaunchAtLoginSetting(enabled: newValue)
}
}
.padding()
.glassEffect(in: .rect(cornerRadius: 12))
// Links Section
VStack(spacing: 12) {
Text("Support & Contribute")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
// GitHub Link
Button(action: {
if let url = URL(string: "https://github.com/mikefreno/Gaze") {
NSWorkspace.shared.open(url)
}
}) {
HStack {
Image(systemName: "chevron.left.forwardslash.chevron.right")
.font(.title3)
VStack(alignment: .leading, spacing: 2) {
Text("View on GitHub")
.font(.subheadline)
.fontWeight(.semibold)
Text("Star the repo, report issues, contribute")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
}
.padding()
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 10))
// Buy Me a Coffee
Button(action: {
if let url = URL(string: "https://buymeacoffee.com/placeholder") {
NSWorkspace.shared.open(url)
}
}) {
HStack {
Image(systemName: "cup.and.saucer.fill")
.font(.title3)
.foregroundColor(.orange)
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()
.frame(maxWidth: .infinity)
.background(Color.orange.opacity(0.1))
.cornerRadius(10)
}
.buttonStyle(.plain)
.glassEffect(.regular.tint(.orange).interactive(), in: .rect(cornerRadius: 10))
}
.padding()
}
Spacer()
HStack(spacing: 12) {
if let onBack = onBack {
Button(action: onBack) {
HStack {
Image(systemName: "chevron.left")
Text("Back")
}
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
}
.buttonStyle(.plain)
.glassEffect(.regular.interactive())
}
Button(action: onContinue) {
Text("Continue")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
}
.buttonStyle(.plain)
.glassEffect(.regular.tint(.blue).interactive())
}
.padding(.horizontal, 40)
}
.frame(width: 600, height: 500)
.padding()
.background(.clear)
}
private func applyLaunchAtLoginSetting(enabled: Bool) {
do {
if enabled {
try LaunchAtLoginManager.enable()
} else {
try LaunchAtLoginManager.disable()
}
} catch {
print("Failed to set launch at login: \(error)")
}
}
}
#Preview {
SettingsOnboardingView(
launchAtLogin: .constant(false),
onContinue: {},
onBack: {}
)
}

View File

@@ -31,6 +31,7 @@ struct WelcomeView: View {
FeatureRow(icon: "figure.stand", title: "Maintain Good Posture", description: "Gentle reminders to sit up straight")
}
.padding()
.glassEffect(in: .rect(cornerRadius: 16))
Spacer()
@@ -39,15 +40,14 @@ struct WelcomeView: View {
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(12)
}
.buttonStyle(.plain)
.glassEffect(.regular.tint(.blue).interactive())
.padding(.horizontal, 40)
}
.frame(width: 600, height: 500)
.padding()
.background(.clear)
}
}