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 cancellables = Set<AnyCancellable>()
|
||||||
private var hasStartedTimers = false
|
private var hasStartedTimers = false
|
||||||
|
|
||||||
// Convenience accessor for settings
|
|
||||||
private var settingsManager: any SettingsProviding {
|
private var settingsManager: any SettingsProviding {
|
||||||
serviceContainer.settingsManager
|
serviceContainer.settingsManager
|
||||||
}
|
}
|
||||||
@@ -39,6 +38,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
|||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
NSApplication.shared.setActivationPolicy(.accessory)
|
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
|
timerEngine = serviceContainer.timerEngine
|
||||||
|
|
||||||
serviceContainer.setupSmartModeServices()
|
serviceContainer.setupSmartModeServices()
|
||||||
@@ -54,6 +60,15 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
|||||||
|
|
||||||
if settingsManager.settings.hasCompletedOnboarding {
|
if settingsManager.settings.hasCompletedOnboarding {
|
||||||
startTimers()
|
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
|
settingsManager.settingsPublisher
|
||||||
.sink { [weak self] settings in
|
.sink { [weak self] settings in
|
||||||
if settings.hasCompletedOnboarding && self?.hasStartedTimers == false {
|
if settings.hasCompletedOnboarding && self?.hasStartedTimers == false {
|
||||||
self?.startTimers()
|
self?.onboardingCompleted()
|
||||||
} else if self?.hasStartedTimers == true {
|
} else if self?.hasStartedTimers == true {
|
||||||
// Defer timer restart to next runloop to ensure settings are fully propagated
|
// Defer timer restart to next runloop to ensure settings are fully propagated
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
|||||||
@@ -12,41 +12,7 @@ struct GazeApp: App {
|
|||||||
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
@State private var settingsManager = SettingsManager.shared
|
@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 {
|
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") {
|
MenuBarExtra("Gaze", systemImage: "eye.fill") {
|
||||||
MenuBarContentWrapper(
|
MenuBarContentWrapper(
|
||||||
appDelegate: appDelegate,
|
appDelegate: appDelegate,
|
||||||
@@ -58,11 +24,8 @@ struct GazeApp: App {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.menuBarExtraStyle(.window)
|
.menuBarExtraStyle(.window)
|
||||||
}
|
.commands {
|
||||||
|
CommandGroup(replacing: .newItem) {}
|
||||||
private func closeAllWindows() {
|
|
||||||
for window in NSApplication.shared.windows {
|
|
||||||
window.close()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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.
|
// Created by Mike Freno on 1/7/26.
|
||||||
//
|
//
|
||||||
|
|
||||||
import AppKit
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct VisualEffectView: NSViewRepresentable {
|
struct VisualEffectView: NSViewRepresentable {
|
||||||
@@ -30,7 +29,7 @@ struct VisualEffectView: NSViewRepresentable {
|
|||||||
final class OnboardingWindowPresenter {
|
final class OnboardingWindowPresenter {
|
||||||
static let shared = OnboardingWindowPresenter()
|
static let shared = OnboardingWindowPresenter()
|
||||||
|
|
||||||
private weak var windowController: NSWindowController?
|
private var windowController: NSWindowController?
|
||||||
private var closeObserver: NSObjectProtocol?
|
private var closeObserver: NSObjectProtocol?
|
||||||
private var isShowingWindow = false
|
private var isShowingWindow = false
|
||||||
|
|
||||||
@@ -43,36 +42,41 @@ final class OnboardingWindowPresenter {
|
|||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func activateIfPresent() -> Bool {
|
func activateIfPresent() -> Bool {
|
||||||
guard let window = windowController?.window else {
|
guard let window = windowController?.window, window.isVisible else {
|
||||||
windowController = nil
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
NSApp.unhide(nil)
|
||||||
NSApp.unhide(nil)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
|
||||||
|
|
||||||
if window.isMiniaturized {
|
if window.isMiniaturized {
|
||||||
window.deminiaturize(nil)
|
window.deminiaturize(nil)
|
||||||
}
|
|
||||||
|
|
||||||
window.makeKeyAndOrderFront(nil)
|
|
||||||
window.orderFrontRegardless()
|
|
||||||
window.makeMain()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
window.orderFrontRegardless()
|
||||||
|
window.makeMain()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func close() {
|
func close() {
|
||||||
windowController?.close()
|
removeCloseObserver()
|
||||||
|
windowController?.window?.close()
|
||||||
windowController = nil
|
windowController = nil
|
||||||
isShowingWindow = false
|
isShowingWindow = false
|
||||||
removeCloseObserver()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createWindow(settingsManager: SettingsManager) {
|
private func createWindow(settingsManager: SettingsManager) {
|
||||||
let window = NSWindow(
|
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],
|
styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView],
|
||||||
backing: .buffered,
|
backing: .buffered,
|
||||||
defer: false
|
defer: false
|
||||||
@@ -82,7 +86,7 @@ final class OnboardingWindowPresenter {
|
|||||||
window.titleVisibility = .hidden
|
window.titleVisibility = .hidden
|
||||||
window.titlebarAppearsTransparent = true
|
window.titlebarAppearsTransparent = true
|
||||||
window.center()
|
window.center()
|
||||||
window.isReleasedWhenClosed = true
|
window.isReleasedWhenClosed = false
|
||||||
window.collectionBehavior = [
|
window.collectionBehavior = [
|
||||||
.managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary,
|
.managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary,
|
||||||
]
|
]
|
||||||
@@ -128,6 +132,8 @@ struct OnboardingContainerView: View {
|
|||||||
@Bindable var settingsManager: SettingsManager
|
@Bindable var settingsManager: SettingsManager
|
||||||
@State private var currentPage = 0
|
@State private var currentPage = 0
|
||||||
|
|
||||||
|
private let lastPageIndex = 6
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
|
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
|
||||||
@@ -138,36 +144,53 @@ struct OnboardingContainerView: View {
|
|||||||
.tag(0)
|
.tag(0)
|
||||||
.tabItem { Image(systemName: "hand.wave.fill") }
|
.tabItem { Image(systemName: "hand.wave.fill") }
|
||||||
|
|
||||||
LookAwaySetupView(settingsManager: settingsManager)
|
MenuBarWelcomeView()
|
||||||
.tag(1)
|
.tag(1)
|
||||||
|
.tabItem { Image(systemName: "menubar.rectangle") }
|
||||||
|
|
||||||
|
LookAwaySetupView(settingsManager: settingsManager)
|
||||||
|
.tag(2)
|
||||||
.tabItem { Image(systemName: "eye.fill") }
|
.tabItem { Image(systemName: "eye.fill") }
|
||||||
|
|
||||||
BlinkSetupView(settingsManager: settingsManager)
|
BlinkSetupView(settingsManager: settingsManager)
|
||||||
.tag(2)
|
.tag(3)
|
||||||
.tabItem { Image(systemName: "eye.circle.fill") }
|
.tabItem { Image(systemName: "eye.circle.fill") }
|
||||||
|
|
||||||
PostureSetupView(settingsManager: settingsManager)
|
PostureSetupView(settingsManager: settingsManager)
|
||||||
.tag(3)
|
.tag(4)
|
||||||
.tabItem { Image(systemName: "figure.stand") }
|
.tabItem { Image(systemName: "figure.stand") }
|
||||||
|
|
||||||
GeneralSetupView(settingsManager: settingsManager, isOnboarding: true)
|
GeneralSetupView(settingsManager: settingsManager, isOnboarding: true)
|
||||||
.tag(4)
|
.tag(5)
|
||||||
.tabItem { Image(systemName: "gearshape.fill") }
|
.tabItem { Image(systemName: "gearshape.fill") }
|
||||||
|
|
||||||
CompletionView()
|
CompletionView()
|
||||||
.tag(5)
|
.tag(6)
|
||||||
.tabItem { Image(systemName: "checkmark.circle.fill") }
|
.tabItem { Image(systemName: "checkmark.circle.fill") }
|
||||||
}
|
}
|
||||||
.tabViewStyle(.automatic)
|
.tabViewStyle(.automatic)
|
||||||
|
.onChange(of: currentPage) { _, newValue in
|
||||||
|
MenuBarGuideOverlayPresenter.shared.updateVisibility(isVisible: newValue == 1)
|
||||||
|
}
|
||||||
|
|
||||||
navigationButtons
|
navigationButtons
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if APPSTORE
|
.frame(
|
||||||
.frame(minWidth: 1000, minHeight: 700)
|
minWidth: 1000,
|
||||||
#else
|
minHeight: {
|
||||||
.frame(minWidth: 1000, minHeight: 900)
|
#if APPSTORE
|
||||||
#endif
|
return 700
|
||||||
|
#else
|
||||||
|
return 1000
|
||||||
|
#endif
|
||||||
|
}())
|
||||||
|
.onAppear {
|
||||||
|
MenuBarGuideOverlayPresenter.shared.updateVisibility(isVisible: currentPage == 1)
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
MenuBarGuideOverlayPresenter.shared.hide()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -190,7 +213,7 @@ struct OnboardingContainerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
if currentPage == 5 {
|
if currentPage == lastPageIndex {
|
||||||
completeOnboarding()
|
completeOnboarding()
|
||||||
} else {
|
} else {
|
||||||
currentPage += 1
|
currentPage += 1
|
||||||
@@ -198,7 +221,8 @@ struct OnboardingContainerView: View {
|
|||||||
}) {
|
}) {
|
||||||
Text(
|
Text(
|
||||||
currentPage == 0
|
currentPage == 0
|
||||||
? "Let's Get Started" : currentPage == 5 ? "Get Started" : "Continue"
|
? "Let's Get Started"
|
||||||
|
: currentPage == lastPageIndex ? "Get Started" : "Continue"
|
||||||
)
|
)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.frame(minWidth: 100, maxWidth: .infinity, minHeight: 44, maxHeight: 44)
|
.frame(minWidth: 100, maxWidth: .infinity, minHeight: 44, maxHeight: 44)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import SwiftUI
|
|||||||
final class SettingsWindowPresenter {
|
final class SettingsWindowPresenter {
|
||||||
static let shared = SettingsWindowPresenter()
|
static let shared = SettingsWindowPresenter()
|
||||||
|
|
||||||
private weak var windowController: NSWindowController?
|
private var windowController: NSWindowController?
|
||||||
private var closeObserver: NSObjectProtocol?
|
private var closeObserver: NSObjectProtocol?
|
||||||
private var isShowingWindow = false
|
private var isShowingWindow = false
|
||||||
|
|
||||||
@@ -78,7 +78,9 @@ final class SettingsWindowPresenter {
|
|||||||
window.setFrameAutosaveName("SettingsWindow")
|
window.setFrameAutosaveName("SettingsWindow")
|
||||||
window.isReleasedWhenClosed = false
|
window.isReleasedWhenClosed = false
|
||||||
|
|
||||||
window.collectionBehavior = [.managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary]
|
window.collectionBehavior = [
|
||||||
|
.managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary,
|
||||||
|
]
|
||||||
|
|
||||||
window.contentView = NSHostingView(
|
window.contentView = NSHostingView(
|
||||||
rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: initialTab)
|
rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: initialTab)
|
||||||
@@ -204,8 +206,9 @@ 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) {
|
settingsManager.settings.hasCompletedOnboarding = false
|
||||||
settingsManager.settings.hasCompletedOnboarding = false
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||||
|
OnboardingWindowPresenter.shared.show(settingsManager: settingsManager)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#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
|
// Simulate moving through pages
|
||||||
let pages = [
|
let pages = [
|
||||||
"Welcome", // 0
|
"Welcome", // 0
|
||||||
"LookAway", // 1
|
"MenuBar", // 1
|
||||||
"Blink", // 2
|
"LookAway", // 2
|
||||||
"Posture", // 3
|
"Blink", // 3
|
||||||
"General", // 4
|
"Posture", // 4
|
||||||
"Completion", // 5
|
"General", // 5
|
||||||
|
"Completion", // 6
|
||||||
]
|
]
|
||||||
|
|
||||||
for (index, pageName) in pages.enumerated() {
|
for (index, pageName) in pages.enumerated() {
|
||||||
@@ -177,7 +178,9 @@ final class OnboardingNavigationTests: XCTestCase {
|
|||||||
|
|
||||||
// Page 0: Welcome - no configuration needed
|
// 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
|
var lookAwayConfig = testEnv.settingsManager.settings.lookAwayTimer
|
||||||
lookAwayConfig.enabled = true
|
lookAwayConfig.enabled = true
|
||||||
lookAwayConfig.intervalSeconds = 1200
|
lookAwayConfig.intervalSeconds = 1200
|
||||||
|
|||||||
Reference in New Issue
Block a user