feat: big arrow boi
This commit is contained in:
@@ -18,7 +18,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var hasStartedTimers = false
|
||||
|
||||
// Convenience accessor for settings
|
||||
private var settingsManager: any SettingsProviding {
|
||||
serviceContainer.settingsManager
|
||||
}
|
||||
@@ -39,6 +38,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
NSApplication.shared.setActivationPolicy(.accessory)
|
||||
|
||||
// Handle test launch arguments
|
||||
if TestingEnvironment.shouldSkipOnboarding {
|
||||
SettingsManager.shared.settings.hasCompletedOnboarding = true
|
||||
} else if TestingEnvironment.shouldResetOnboarding {
|
||||
SettingsManager.shared.settings.hasCompletedOnboarding = false
|
||||
}
|
||||
|
||||
timerEngine = serviceContainer.timerEngine
|
||||
|
||||
serviceContainer.setupSmartModeServices()
|
||||
@@ -54,6 +60,15 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
||||
|
||||
if settingsManager.settings.hasCompletedOnboarding {
|
||||
startTimers()
|
||||
} else {
|
||||
showOnboardingOnLaunch()
|
||||
}
|
||||
}
|
||||
|
||||
private func showOnboardingOnLaunch() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.windowManager.showOnboarding(settingsManager: self.settingsManager)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +112,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
||||
settingsManager.settingsPublisher
|
||||
.sink { [weak self] settings in
|
||||
if settings.hasCompletedOnboarding && self?.hasStartedTimers == false {
|
||||
self?.startTimers()
|
||||
self?.onboardingCompleted()
|
||||
} else if self?.hasStartedTimers == true {
|
||||
// Defer timer restart to next runloop to ensure settings are fully propagated
|
||||
DispatchQueue.main.async {
|
||||
|
||||
@@ -12,41 +12,7 @@ struct GazeApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
@State private var settingsManager = SettingsManager.shared
|
||||
|
||||
init() {
|
||||
// Handle test launch arguments
|
||||
if TestingEnvironment.shouldSkipOnboarding {
|
||||
SettingsManager.shared.settings.hasCompletedOnboarding = true
|
||||
} else if TestingEnvironment.shouldResetOnboarding {
|
||||
SettingsManager.shared.settings.hasCompletedOnboarding = false
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
// Onboarding window (only shown when not completed)
|
||||
WindowGroup {
|
||||
if settingsManager.settings.hasCompletedOnboarding {
|
||||
EmptyView()
|
||||
.onAppear {
|
||||
closeAllWindows()
|
||||
}
|
||||
} else {
|
||||
OnboardingContainerView(settingsManager: settingsManager)
|
||||
.onChange(of: settingsManager.settings.hasCompletedOnboarding) { _, completed in
|
||||
if completed {
|
||||
closeAllWindows()
|
||||
appDelegate.onboardingCompleted()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
.windowResizability(.contentSize)
|
||||
.defaultSize(width: 1000, height: 700)
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) {}
|
||||
}
|
||||
|
||||
// Menu bar extra (always present)
|
||||
MenuBarExtra("Gaze", systemImage: "eye.fill") {
|
||||
MenuBarContentWrapper(
|
||||
appDelegate: appDelegate,
|
||||
@@ -58,11 +24,8 @@ struct GazeApp: App {
|
||||
)
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
}
|
||||
|
||||
private func closeAllWindows() {
|
||||
for window in NSApplication.shared.windows {
|
||||
window.close()
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
154
Gaze/Services/MenuBarItemLocator.swift
Normal file
154
Gaze/Services/MenuBarItemLocator.swift
Normal file
@@ -0,0 +1,154 @@
|
||||
//
|
||||
// MenuBarItemLocator.swift
|
||||
// Gaze
|
||||
//
|
||||
// Created by Mike Freno on 1/17/26.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
struct MenuBarLocationResult {
|
||||
let frame: CGRect
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class MenuBarItemLocator {
|
||||
static let shared = MenuBarItemLocator()
|
||||
|
||||
private var cachedLocation: MenuBarLocationResult?
|
||||
|
||||
private init() {}
|
||||
|
||||
func probeLocation() {
|
||||
// Strategy 1: NSApp.windows at status bar level (most reliable)
|
||||
if let result = findViaAppWindows() {
|
||||
print("✅ Strategy 1 (NSApp.windows level 25): \(result.frame)")
|
||||
cachedLocation = result
|
||||
return
|
||||
}
|
||||
|
||||
// Strategy 2: CGWindowList
|
||||
if let result = findViaCGWindowList() {
|
||||
print("✅ Strategy 2 (CGWindowList): \(result.frame)")
|
||||
cachedLocation = result
|
||||
return
|
||||
}
|
||||
|
||||
// Strategy 3: Calculate based on screen geometry
|
||||
if let result = calculateFromScreenGeometry() {
|
||||
print("✅ Strategy 3 (Screen geometry fallback): \(result.frame)")
|
||||
cachedLocation = result
|
||||
return
|
||||
}
|
||||
|
||||
print("❌ All strategies failed")
|
||||
}
|
||||
|
||||
func getLocation() -> MenuBarLocationResult? {
|
||||
if cachedLocation == nil {
|
||||
probeLocation()
|
||||
}
|
||||
return cachedLocation
|
||||
}
|
||||
|
||||
/// Strategy 1: Find windows at status bar level (25) in NSApp.windows
|
||||
private func findViaAppWindows() -> MenuBarLocationResult? {
|
||||
guard let screen = NSScreen.main else { return nil }
|
||||
|
||||
let menuBarHeight = NSStatusBar.system.thickness
|
||||
let screenFrame = screen.frame
|
||||
|
||||
for window in NSApp.windows {
|
||||
let frame = window.frame
|
||||
let level = window.level.rawValue
|
||||
|
||||
// Status bar level is 25
|
||||
let isStatusBarLevel = level == 25
|
||||
|
||||
// Status item windows have small dimensions
|
||||
let hasSmallHeight = frame.height > 0 && frame.height <= 50
|
||||
let hasSmallWidth = frame.width > 0 && frame.width < 100
|
||||
|
||||
if isStatusBarLevel && hasSmallHeight && hasSmallWidth {
|
||||
// We found it! Use the x position, but set y to top of screen
|
||||
let targetFrame = CGRect(
|
||||
x: frame.minX,
|
||||
y: screenFrame.maxY - menuBarHeight,
|
||||
width: frame.width,
|
||||
height: menuBarHeight
|
||||
)
|
||||
return MenuBarLocationResult(frame: targetFrame)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Strategy 2: Use CGWindowListCopyWindowInfo
|
||||
private func findViaCGWindowList() -> MenuBarLocationResult? {
|
||||
let options = CGWindowListOption(arrayLiteral: .optionOnScreenOnly, .excludeDesktopElements)
|
||||
guard let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let myPID = ProcessInfo.processInfo.processIdentifier
|
||||
guard let screen = NSScreen.main else { return nil }
|
||||
|
||||
let menuBarHeight = NSStatusBar.system.thickness
|
||||
let screenHeight = screen.frame.height
|
||||
|
||||
for info in windowList {
|
||||
guard let ownerPID = info[kCGWindowOwnerPID as String] as? Int32,
|
||||
ownerPID == myPID,
|
||||
let boundsDict = info[kCGWindowBounds as String] as? [String: CGFloat],
|
||||
let x = boundsDict["X"],
|
||||
let y = boundsDict["Y"],
|
||||
let width = boundsDict["Width"],
|
||||
let height = boundsDict["Height"]
|
||||
else { continue }
|
||||
|
||||
// CGWindowList uses top-left origin (y=0 at top)
|
||||
let isAtTop = y < menuBarHeight + 10
|
||||
let hasSmallHeight = height > 0 && height <= 50
|
||||
let hasSmallWidth = width > 0 && width < 100
|
||||
|
||||
if isAtTop && hasSmallHeight && hasSmallWidth {
|
||||
let frame = CGRect(
|
||||
x: x,
|
||||
y: screenHeight - menuBarHeight,
|
||||
width: width,
|
||||
height: menuBarHeight
|
||||
)
|
||||
return MenuBarLocationResult(frame: frame)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Strategy 3: Fallback calculation based on screen geometry
|
||||
private func calculateFromScreenGeometry() -> MenuBarLocationResult? {
|
||||
guard let screen = NSScreen.main else { return nil }
|
||||
|
||||
let menuBarHeight = NSStatusBar.system.thickness
|
||||
let screenFrame = screen.frame
|
||||
|
||||
// Estimate: status items typically around 2/3 from left
|
||||
let estimatedX = screenFrame.width * 0.667
|
||||
|
||||
let frame = CGRect(
|
||||
x: estimatedX,
|
||||
y: screenFrame.maxY - menuBarHeight,
|
||||
width: 24,
|
||||
height: menuBarHeight
|
||||
)
|
||||
|
||||
return MenuBarLocationResult(frame: frame)
|
||||
}
|
||||
|
||||
func refreshLocation() {
|
||||
cachedLocation = nil
|
||||
probeLocation()
|
||||
}
|
||||
}
|
||||
144
Gaze/Views/Containers/MenuBarGuideOverlayView.swift
Normal file
144
Gaze/Views/Containers/MenuBarGuideOverlayView.swift
Normal file
@@ -0,0 +1,144 @@
|
||||
//
|
||||
// MenuBarGuideOverlayView.swift
|
||||
// Gaze
|
||||
//
|
||||
// Created by Mike Freno on 1/17/26.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class MenuBarGuideOverlayPresenter {
|
||||
static let shared = MenuBarGuideOverlayPresenter()
|
||||
|
||||
private var window: NSWindow?
|
||||
|
||||
func updateVisibility(isVisible: Bool) {
|
||||
if isVisible {
|
||||
// Probe location before showing
|
||||
MenuBarItemLocator.shared.probeLocation()
|
||||
show()
|
||||
} else {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
func hide() {
|
||||
window?.orderOut(nil)
|
||||
window?.close()
|
||||
window = nil
|
||||
}
|
||||
|
||||
private func show() {
|
||||
if let window {
|
||||
window.orderFrontRegardless()
|
||||
return
|
||||
}
|
||||
|
||||
guard let screen = NSScreen.main else { return }
|
||||
|
||||
let overlayWindow = NSPanel(
|
||||
contentRect: screen.frame,
|
||||
styleMask: [.borderless, .nonactivatingPanel],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
||||
overlayWindow.backgroundColor = .clear
|
||||
overlayWindow.isOpaque = false
|
||||
overlayWindow.hasShadow = false
|
||||
overlayWindow.level = .statusBar
|
||||
overlayWindow.ignoresMouseEvents = true
|
||||
overlayWindow.isReleasedWhenClosed = false
|
||||
overlayWindow.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
||||
overlayWindow.contentView = NSHostingView(rootView: MenuBarGuideOverlayView())
|
||||
|
||||
overlayWindow.orderFrontRegardless()
|
||||
window = overlayWindow
|
||||
}
|
||||
}
|
||||
|
||||
struct MenuBarGuideOverlayView: View {
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
let size = proxy.size
|
||||
|
||||
if let locationResult = MenuBarItemLocator.shared.getLocation(),
|
||||
let screen = NSScreen.main
|
||||
{
|
||||
let target = convertToViewCoordinates(
|
||||
frame: locationResult.frame,
|
||||
screenHeight: screen.frame.height
|
||||
)
|
||||
|
||||
// Adjust control and start points based on target position
|
||||
let control = CGPoint(
|
||||
x: target.x * 0.9 + size.width * 0.1,
|
||||
y: size.height * 0.15
|
||||
)
|
||||
let start = CGPoint(
|
||||
x: size.width * 0.5,
|
||||
y: size.height * 0.45
|
||||
)
|
||||
|
||||
CurvedArrowShape(start: start, end: target, control: control)
|
||||
.stroke(
|
||||
Color.accentColor.opacity(0.9),
|
||||
style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 2)
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
.background(Color.clear)
|
||||
}
|
||||
|
||||
private func convertToViewCoordinates(frame: CGRect, screenHeight: CGFloat) -> CGPoint {
|
||||
// We want to point to the center of the menu bar icon
|
||||
// x: use the center of the detected frame
|
||||
// y: fixed at ~20 from top (menu bar is at top of screen in view coordinates)
|
||||
let centerX = frame.midX
|
||||
let targetY: CGFloat = 30 // Fixed y near top of screen
|
||||
return CGPoint(x: centerX, y: targetY)
|
||||
}
|
||||
}
|
||||
|
||||
struct CurvedArrowShape: Shape {
|
||||
let start: CGPoint
|
||||
let end: CGPoint
|
||||
let control: CGPoint
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
|
||||
path.move(to: start)
|
||||
path.addQuadCurve(to: end, control: control)
|
||||
|
||||
// Arrowhead
|
||||
let arrowLength: CGFloat = 18
|
||||
let arrowAngle: CGFloat = .pi / 7
|
||||
let angle = atan2(end.y - control.y, end.x - control.x)
|
||||
|
||||
let left = CGPoint(
|
||||
x: end.x - arrowLength * cos(angle - arrowAngle),
|
||||
y: end.y - arrowLength * sin(angle - arrowAngle)
|
||||
)
|
||||
let right = CGPoint(
|
||||
x: end.x - arrowLength * cos(angle + arrowAngle),
|
||||
y: end.y - arrowLength * sin(angle + arrowAngle)
|
||||
)
|
||||
|
||||
path.move(to: end)
|
||||
path.addLine(to: left)
|
||||
path.move(to: end)
|
||||
path.addLine(to: right)
|
||||
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Menu Bar Guide Overlay") {
|
||||
MenuBarGuideOverlayView()
|
||||
.frame(width: 1200, height: 800)
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
// Created by Mike Freno on 1/7/26.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct VisualEffectView: NSViewRepresentable {
|
||||
@@ -30,7 +29,7 @@ struct VisualEffectView: NSViewRepresentable {
|
||||
final class OnboardingWindowPresenter {
|
||||
static let shared = OnboardingWindowPresenter()
|
||||
|
||||
private weak var windowController: NSWindowController?
|
||||
private var windowController: NSWindowController?
|
||||
private var closeObserver: NSObjectProtocol?
|
||||
private var isShowingWindow = false
|
||||
|
||||
@@ -43,36 +42,41 @@ final class OnboardingWindowPresenter {
|
||||
|
||||
@discardableResult
|
||||
func activateIfPresent() -> Bool {
|
||||
guard let window = windowController?.window else {
|
||||
windowController = nil
|
||||
guard let window = windowController?.window, window.isVisible else {
|
||||
return false
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
NSApp.unhide(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
NSApp.unhide(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
||||
if window.isMiniaturized {
|
||||
window.deminiaturize(nil)
|
||||
}
|
||||
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
window.orderFrontRegardless()
|
||||
window.makeMain()
|
||||
if window.isMiniaturized {
|
||||
window.deminiaturize(nil)
|
||||
}
|
||||
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
window.orderFrontRegardless()
|
||||
window.makeMain()
|
||||
return true
|
||||
}
|
||||
|
||||
func close() {
|
||||
windowController?.close()
|
||||
removeCloseObserver()
|
||||
windowController?.window?.close()
|
||||
windowController = nil
|
||||
isShowingWindow = false
|
||||
removeCloseObserver()
|
||||
}
|
||||
|
||||
private func createWindow(settingsManager: SettingsManager) {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 700, height: 700),
|
||||
contentRect: NSRect(
|
||||
x: 0, y: 0, width: 1000,
|
||||
height: {
|
||||
#if APPSTORE
|
||||
return 700
|
||||
#else
|
||||
return 1000
|
||||
#endif
|
||||
}()),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
@@ -82,7 +86,7 @@ final class OnboardingWindowPresenter {
|
||||
window.titleVisibility = .hidden
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.center()
|
||||
window.isReleasedWhenClosed = true
|
||||
window.isReleasedWhenClosed = false
|
||||
window.collectionBehavior = [
|
||||
.managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary,
|
||||
]
|
||||
@@ -128,6 +132,8 @@ struct OnboardingContainerView: View {
|
||||
@Bindable var settingsManager: SettingsManager
|
||||
@State private var currentPage = 0
|
||||
|
||||
private let lastPageIndex = 6
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
|
||||
@@ -138,36 +144,53 @@ struct OnboardingContainerView: View {
|
||||
.tag(0)
|
||||
.tabItem { Image(systemName: "hand.wave.fill") }
|
||||
|
||||
LookAwaySetupView(settingsManager: settingsManager)
|
||||
MenuBarWelcomeView()
|
||||
.tag(1)
|
||||
.tabItem { Image(systemName: "menubar.rectangle") }
|
||||
|
||||
LookAwaySetupView(settingsManager: settingsManager)
|
||||
.tag(2)
|
||||
.tabItem { Image(systemName: "eye.fill") }
|
||||
|
||||
BlinkSetupView(settingsManager: settingsManager)
|
||||
.tag(2)
|
||||
.tag(3)
|
||||
.tabItem { Image(systemName: "eye.circle.fill") }
|
||||
|
||||
PostureSetupView(settingsManager: settingsManager)
|
||||
.tag(3)
|
||||
.tag(4)
|
||||
.tabItem { Image(systemName: "figure.stand") }
|
||||
|
||||
GeneralSetupView(settingsManager: settingsManager, isOnboarding: true)
|
||||
.tag(4)
|
||||
.tag(5)
|
||||
.tabItem { Image(systemName: "gearshape.fill") }
|
||||
|
||||
CompletionView()
|
||||
.tag(5)
|
||||
.tag(6)
|
||||
.tabItem { Image(systemName: "checkmark.circle.fill") }
|
||||
}
|
||||
.tabViewStyle(.automatic)
|
||||
.onChange(of: currentPage) { _, newValue in
|
||||
MenuBarGuideOverlayPresenter.shared.updateVisibility(isVisible: newValue == 1)
|
||||
}
|
||||
|
||||
navigationButtons
|
||||
}
|
||||
}
|
||||
#if APPSTORE
|
||||
.frame(minWidth: 1000, minHeight: 700)
|
||||
#else
|
||||
.frame(minWidth: 1000, minHeight: 900)
|
||||
#endif
|
||||
.frame(
|
||||
minWidth: 1000,
|
||||
minHeight: {
|
||||
#if APPSTORE
|
||||
return 700
|
||||
#else
|
||||
return 1000
|
||||
#endif
|
||||
}())
|
||||
.onAppear {
|
||||
MenuBarGuideOverlayPresenter.shared.updateVisibility(isVisible: currentPage == 1)
|
||||
}
|
||||
.onDisappear {
|
||||
MenuBarGuideOverlayPresenter.shared.hide()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -190,7 +213,7 @@ struct OnboardingContainerView: View {
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
if currentPage == 5 {
|
||||
if currentPage == lastPageIndex {
|
||||
completeOnboarding()
|
||||
} else {
|
||||
currentPage += 1
|
||||
@@ -198,7 +221,8 @@ struct OnboardingContainerView: View {
|
||||
}) {
|
||||
Text(
|
||||
currentPage == 0
|
||||
? "Let's Get Started" : currentPage == 5 ? "Get Started" : "Continue"
|
||||
? "Let's Get Started"
|
||||
: currentPage == lastPageIndex ? "Get Started" : "Continue"
|
||||
)
|
||||
.font(.headline)
|
||||
.frame(minWidth: 100, maxWidth: .infinity, minHeight: 44, maxHeight: 44)
|
||||
|
||||
@@ -11,7 +11,7 @@ import SwiftUI
|
||||
final class SettingsWindowPresenter {
|
||||
static let shared = SettingsWindowPresenter()
|
||||
|
||||
private weak var windowController: NSWindowController?
|
||||
private var windowController: NSWindowController?
|
||||
private var closeObserver: NSObjectProtocol?
|
||||
private var isShowingWindow = false
|
||||
|
||||
@@ -78,8 +78,10 @@ final class SettingsWindowPresenter {
|
||||
window.setFrameAutosaveName("SettingsWindow")
|
||||
window.isReleasedWhenClosed = false
|
||||
|
||||
window.collectionBehavior = [.managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary]
|
||||
|
||||
window.collectionBehavior = [
|
||||
.managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary,
|
||||
]
|
||||
|
||||
window.contentView = NSHostingView(
|
||||
rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: initialTab)
|
||||
)
|
||||
@@ -204,8 +206,9 @@ struct SettingsWindowView: View {
|
||||
#if DEBUG
|
||||
private func retriggerOnboarding() {
|
||||
SettingsWindowPresenter.shared.close()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
settingsManager.settings.hasCompletedOnboarding = false
|
||||
settingsManager.settings.hasCompletedOnboarding = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
OnboardingWindowPresenter.shared.show(settingsManager: settingsManager)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
53
Gaze/Views/Setup/MenuBarWelcomeView.swift
Normal file
53
Gaze/Views/Setup/MenuBarWelcomeView.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// MenuBarWelcomeView.swift
|
||||
// Gaze
|
||||
//
|
||||
// Created by Mike Freno on 1/17/26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MenuBarWelcomeView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 30) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "menubar.rectangle")
|
||||
.font(.system(size: 72))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("Gaze Lives in Your Menu Bar")
|
||||
.font(.system(size: 34, weight: .bold))
|
||||
|
||||
Text("Keep an eye on the top-right of your screen for the Gaze icon.")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
FeatureRow(
|
||||
icon: "cursorarrow.click", title: "Always Within Reach",
|
||||
description: "Open settings and timers from the menu bar anytime")
|
||||
FeatureRow(
|
||||
icon: "bell.badge", title: "Friendly Reminders",
|
||||
description: "Notifications pop up without interrupting your flow")
|
||||
FeatureRow(
|
||||
icon: "sparkles", title: "Quick Tweaks",
|
||||
description: "Pause, resume, and adjust timers in one click")
|
||||
}
|
||||
.padding()
|
||||
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(width: 600, height: 450)
|
||||
.padding()
|
||||
.background(.clear)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Menu Bar Welcome") {
|
||||
MenuBarWelcomeView()
|
||||
}
|
||||
@@ -41,11 +41,12 @@ final class OnboardingNavigationTests: XCTestCase {
|
||||
// Simulate moving through pages
|
||||
let pages = [
|
||||
"Welcome", // 0
|
||||
"LookAway", // 1
|
||||
"Blink", // 2
|
||||
"Posture", // 3
|
||||
"General", // 4
|
||||
"Completion", // 5
|
||||
"MenuBar", // 1
|
||||
"LookAway", // 2
|
||||
"Blink", // 3
|
||||
"Posture", // 4
|
||||
"General", // 5
|
||||
"Completion", // 6
|
||||
]
|
||||
|
||||
for (index, pageName) in pages.enumerated() {
|
||||
@@ -177,7 +178,9 @@ final class OnboardingNavigationTests: XCTestCase {
|
||||
|
||||
// Page 0: Welcome - no configuration needed
|
||||
|
||||
// Page 1: LookAway Setup
|
||||
// Page 1: MenuBar Welcome - no configuration needed
|
||||
|
||||
// Page 2: LookAway Setup
|
||||
var lookAwayConfig = testEnv.settingsManager.settings.lookAwayTimer
|
||||
lookAwayConfig.enabled = true
|
||||
lookAwayConfig.intervalSeconds = 1200
|
||||
|
||||
Reference in New Issue
Block a user