feat: onboarding flow styled
This commit is contained in:
@@ -191,4 +191,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
reminderWindowController?.close()
|
reminderWindowController?.close()
|
||||||
reminderWindowController = nil
|
reminderWindowController = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Public method to get menubar icon position for animations
|
||||||
|
func getMenuBarIconPosition() -> NSRect? {
|
||||||
|
return statusItem?.button?.window?.frame
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ class SettingsManager: ObservableObject {
|
|||||||
private let settingsKey = "gazeAppSettings"
|
private let settingsKey = "gazeAppSettings"
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
|
#if DEBUG
|
||||||
|
// Clear settings on every development build
|
||||||
|
UserDefaults.standard.removeObject(forKey: "gazeAppSettings")
|
||||||
|
#endif
|
||||||
self.settings = Self.loadSettings()
|
self.settings = Self.loadSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class TimerEngine: ObservableObject {
|
|||||||
private var timerSubscription: AnyCancellable?
|
private var timerSubscription: AnyCancellable?
|
||||||
private let settingsManager: SettingsManager
|
private let settingsManager: SettingsManager
|
||||||
|
|
||||||
nonisolated init(settingsManager: SettingsManager = .shared) {
|
init(settingsManager: SettingsManager) {
|
||||||
self.settingsManager = settingsManager
|
self.settingsManager = settingsManager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,39 @@
|
|||||||
|
|
||||||
import SwiftUI
|
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 {
|
struct MenuBarContentView: View {
|
||||||
@ObservedObject var timerEngine: TimerEngine
|
@ObservedObject var timerEngine: TimerEngine
|
||||||
@ObservedObject var settingsManager: SettingsManager
|
@ObservedObject var settingsManager: SettingsManager
|
||||||
@@ -54,7 +87,7 @@ struct MenuBarContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Controls
|
// Controls
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 4) {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
if timerEngine.timerStates.values.first?.isPaused == true {
|
if timerEngine.timerStates.values.first?.isPaused == true {
|
||||||
timerEngine.resume()
|
timerEngine.resume()
|
||||||
@@ -67,10 +100,10 @@ struct MenuBarContentView: View {
|
|||||||
Text(isPaused ? "Resume All Timers" : "Pause All Timers")
|
Text(isPaused ? "Resume All Timers" : "Pause All Timers")
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 6)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(MenuBarHoverButtonStyle())
|
||||||
.padding(.horizontal)
|
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
// TODO: Open settings window
|
// TODO: Open settings window
|
||||||
@@ -80,12 +113,13 @@ struct MenuBarContentView: View {
|
|||||||
Text("Settings...")
|
Text("Settings...")
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 6)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(MenuBarHoverButtonStyle())
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
}
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
@@ -93,13 +127,16 @@ struct MenuBarContentView: View {
|
|||||||
Button(action: onQuit) {
|
Button(action: onQuit) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "power")
|
Image(systemName: "power")
|
||||||
|
.foregroundColor(.red)
|
||||||
Text("Quit Gaze")
|
Text("Quit Gaze")
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 6)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(MenuBarHoverButtonStyle())
|
||||||
.padding()
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
.frame(width: 300)
|
.frame(width: 300)
|
||||||
}
|
}
|
||||||
@@ -113,6 +150,7 @@ struct TimerStatusRow: View {
|
|||||||
let type: TimerType
|
let type: TimerType
|
||||||
let state: TimerState
|
let state: TimerState
|
||||||
var onSkip: () -> Void
|
var onSkip: () -> Void
|
||||||
|
@State private var isHovered = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -136,9 +174,17 @@ struct TimerStatusRow: View {
|
|||||||
Image(systemName: "forward.fill")
|
Image(systemName: "forward.fill")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.blue)
|
.foregroundColor(.blue)
|
||||||
|
.padding(6)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(isHovered ? Color.blue.opacity(0.1) : Color.clear)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.help("Skip to next \(type.displayName) reminder")
|
.help("Skip to next \(type.displayName) reminder")
|
||||||
|
.onHover { hovering in
|
||||||
|
isHovered = hovering
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
@@ -170,9 +216,11 @@ struct TimerStatusRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
let settingsManager = SettingsManager.shared
|
||||||
|
let timerEngine = TimerEngine(settingsManager: settingsManager)
|
||||||
MenuBarContentView(
|
MenuBarContentView(
|
||||||
timerEngine: TimerEngine(settingsManager: .shared),
|
timerEngine: timerEngine,
|
||||||
settingsManager: .shared,
|
settingsManager: settingsManager,
|
||||||
onQuit: {}
|
onQuit: {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ struct BlinkSetupView: View {
|
|||||||
@Binding var enabled: Bool
|
@Binding var enabled: Bool
|
||||||
@Binding var intervalMinutes: Int
|
@Binding var intervalMinutes: Int
|
||||||
var onContinue: () -> Void
|
var onContinue: () -> Void
|
||||||
|
var onBack: (() -> Void)?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 30) {
|
VStack(spacing: 30) {
|
||||||
@@ -49,27 +50,41 @@ struct BlinkSetupView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.gray.opacity(0.1))
|
.glassEffect(in: .rect(cornerRadius: 12))
|
||||||
.cornerRadius(12)
|
|
||||||
|
|
||||||
InfoBox(text: "We blink much less when focusing on screens. Regular blink reminders help prevent dry eyes")
|
InfoBox(text: "We blink much less when focusing on screens. Regular blink reminders help prevent dry eyes")
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button(action: onContinue) {
|
HStack(spacing: 12) {
|
||||||
Text("Continue")
|
if let onBack = onBack {
|
||||||
.font(.headline)
|
Button(action: onBack) {
|
||||||
.frame(maxWidth: .infinity)
|
HStack {
|
||||||
.padding()
|
Image(systemName: "chevron.left")
|
||||||
.background(Color.blue)
|
Text("Back")
|
||||||
.foregroundColor(.white)
|
}
|
||||||
.cornerRadius(12)
|
.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())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
.padding(.horizontal, 40)
|
.padding(.horizontal, 40)
|
||||||
}
|
}
|
||||||
.frame(width: 600, height: 500)
|
.frame(width: 600, height: 500)
|
||||||
.padding()
|
.padding()
|
||||||
|
.background(.clear)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +92,7 @@ struct BlinkSetupView: View {
|
|||||||
BlinkSetupView(
|
BlinkSetupView(
|
||||||
enabled: .constant(true),
|
enabled: .constant(true),
|
||||||
intervalMinutes: .constant(5),
|
intervalMinutes: .constant(5),
|
||||||
onContinue: {}
|
onContinue: {},
|
||||||
|
onBack: {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct CompletionView: View {
|
struct CompletionView: View {
|
||||||
var onComplete: () -> Void
|
var onComplete: () -> Void
|
||||||
|
var onBack: (() -> Void)?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 30) {
|
VStack(spacing: 30) {
|
||||||
@@ -60,28 +61,42 @@ struct CompletionView: View {
|
|||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.gray.opacity(0.1))
|
.glassEffect(in: .rect(cornerRadius: 12))
|
||||||
.cornerRadius(12)
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button(action: onComplete) {
|
HStack(spacing: 12) {
|
||||||
Text("Get Started")
|
if let onBack = onBack {
|
||||||
.font(.headline)
|
Button(action: onBack) {
|
||||||
.frame(maxWidth: .infinity)
|
HStack {
|
||||||
.padding()
|
Image(systemName: "chevron.left")
|
||||||
.background(Color.green)
|
Text("Back")
|
||||||
.foregroundColor(.white)
|
}
|
||||||
.cornerRadius(12)
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.glassEffect(.regular.interactive())
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: onComplete) {
|
||||||
|
Text("Get Started")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.glassEffect(.regular.tint(.green).interactive())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
.padding(.horizontal, 40)
|
.padding(.horizontal, 40)
|
||||||
}
|
}
|
||||||
.frame(width: 600, height: 500)
|
.frame(width: 600, height: 500)
|
||||||
.padding()
|
.padding()
|
||||||
|
.background(.clear)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
CompletionView(onComplete: {})
|
CompletionView(onComplete: {}, onBack: {})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,27 +66,41 @@ struct LookAwaySetupView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.gray.opacity(0.1))
|
.glassEffect(in: .rect(cornerRadius: 12))
|
||||||
.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()
|
Spacer()
|
||||||
|
|
||||||
Button(action: onContinue) {
|
HStack(spacing: 12) {
|
||||||
Text("Continue")
|
if let onBack = onBack {
|
||||||
.font(.headline)
|
Button(action: onBack) {
|
||||||
.frame(maxWidth: .infinity)
|
HStack {
|
||||||
.padding()
|
Image(systemName: "chevron.left")
|
||||||
.background(Color.blue)
|
Text("Back")
|
||||||
.foregroundColor(.white)
|
}
|
||||||
.cornerRadius(12)
|
.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())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
.padding(.horizontal, 40)
|
.padding(.horizontal, 40)
|
||||||
}
|
}
|
||||||
.frame(width: 600, height: 500)
|
.frame(width: 600, height: 500)
|
||||||
.padding()
|
.padding()
|
||||||
|
.background(.clear)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,8 +116,7 @@ struct InfoBox: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.blue.opacity(0.1))
|
.glassEffect(.regular.tint(.blue), in: .rect(cornerRadius: 8))
|
||||||
.cornerRadius(8)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +125,7 @@ struct InfoBox: View {
|
|||||||
enabled: .constant(true),
|
enabled: .constant(true),
|
||||||
intervalMinutes: .constant(20),
|
intervalMinutes: .constant(20),
|
||||||
countdownSeconds: .constant(20),
|
countdownSeconds: .constant(20),
|
||||||
onContinue: {}
|
onContinue: {},
|
||||||
|
onBack: {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,26 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
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 {
|
struct OnboardingContainerView: View {
|
||||||
@ObservedObject var settingsManager: SettingsManager
|
@ObservedObject var settingsManager: SettingsManager
|
||||||
@@ -17,54 +37,90 @@ struct OnboardingContainerView: View {
|
|||||||
@State private var blinkIntervalMinutes = 5
|
@State private var blinkIntervalMinutes = 5
|
||||||
@State private var postureEnabled = true
|
@State private var postureEnabled = true
|
||||||
@State private var postureIntervalMinutes = 30
|
@State private var postureIntervalMinutes = 30
|
||||||
|
@State private var launchAtLogin = false
|
||||||
|
@State private var isAnimatingOut = false
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
ZStack {
|
||||||
TabView(selection: $currentPage) {
|
// Semi-transparent background with blur
|
||||||
WelcomeView(onContinue: { currentPage = 1 })
|
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
|
||||||
.tag(0)
|
.ignoresSafeArea()
|
||||||
|
|
||||||
LookAwaySetupView(
|
|
||||||
enabled: $lookAwayEnabled,
|
|
||||||
intervalMinutes: $lookAwayIntervalMinutes,
|
|
||||||
countdownSeconds: $lookAwayCountdownSeconds,
|
|
||||||
onContinue: { currentPage = 2 }
|
|
||||||
)
|
|
||||||
.tag(1)
|
|
||||||
|
|
||||||
BlinkSetupView(
|
|
||||||
enabled: $blinkEnabled,
|
|
||||||
intervalMinutes: $blinkIntervalMinutes,
|
|
||||||
onContinue: { currentPage = 3 }
|
|
||||||
)
|
|
||||||
.tag(2)
|
|
||||||
|
|
||||||
PostureSetupView(
|
|
||||||
enabled: $postureEnabled,
|
|
||||||
intervalMinutes: $postureIntervalMinutes,
|
|
||||||
onContinue: { currentPage = 4 }
|
|
||||||
)
|
|
||||||
.tag(3)
|
|
||||||
|
|
||||||
CompletionView(
|
|
||||||
onComplete: {
|
|
||||||
completeOnboarding()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.tag(4)
|
|
||||||
}
|
|
||||||
.tabViewStyle(.automatic)
|
|
||||||
|
|
||||||
// Page indicator
|
VStack(spacing: 0) {
|
||||||
Text("\(currentPage + 1)/5")
|
TabView(selection: $currentPage) {
|
||||||
.font(.subheadline)
|
WelcomeView(
|
||||||
.foregroundColor(.secondary)
|
onContinue: { currentPage = 1 }
|
||||||
.padding(.top, 8)
|
)
|
||||||
.padding(.bottom, 20)
|
.tag(0)
|
||||||
|
.tabItem {
|
||||||
|
Image(systemName: "hand.wave.fill")
|
||||||
|
}
|
||||||
|
|
||||||
|
LookAwaySetupView(
|
||||||
|
enabled: $lookAwayEnabled,
|
||||||
|
intervalMinutes: $lookAwayIntervalMinutes,
|
||||||
|
countdownSeconds: $lookAwayCountdownSeconds,
|
||||||
|
onContinue: { currentPage = 2 },
|
||||||
|
onBack: { currentPage = 0 }
|
||||||
|
)
|
||||||
|
.tag(1)
|
||||||
|
.tabItem {
|
||||||
|
Image(systemName: "eye.fill")
|
||||||
|
}
|
||||||
|
|
||||||
|
BlinkSetupView(
|
||||||
|
enabled: $blinkEnabled,
|
||||||
|
intervalMinutes: $blinkIntervalMinutes,
|
||||||
|
onContinue: { currentPage = 3 },
|
||||||
|
onBack: { currentPage = 1 }
|
||||||
|
)
|
||||||
|
.tag(2)
|
||||||
|
.tabItem {
|
||||||
|
Image(systemName: "eye.circle.fill")
|
||||||
|
}
|
||||||
|
|
||||||
|
PostureSetupView(
|
||||||
|
enabled: $postureEnabled,
|
||||||
|
intervalMinutes: $postureIntervalMinutes,
|
||||||
|
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(5)
|
||||||
|
.tabItem {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tabViewStyle(.automatic)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.opacity(isAnimatingOut ? 0 : 1)
|
||||||
|
.scaleEffect(isAnimatingOut ? 0.3 : 1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func completeOnboarding() {
|
private func completeOnboarding() {
|
||||||
|
// Save settings
|
||||||
settingsManager.settings.lookAwayTimer = TimerConfiguration(
|
settingsManager.settings.lookAwayTimer = TimerConfiguration(
|
||||||
enabled: lookAwayEnabled,
|
enabled: lookAwayEnabled,
|
||||||
intervalSeconds: lookAwayIntervalMinutes * 60
|
intervalSeconds: lookAwayIntervalMinutes * 60
|
||||||
@@ -81,7 +137,73 @@ struct OnboardingContainerView: View {
|
|||||||
intervalSeconds: postureIntervalMinutes * 60
|
intervalSeconds: postureIntervalMinutes * 60
|
||||||
)
|
)
|
||||||
|
|
||||||
|
settingsManager.settings.launchAtLogin = launchAtLogin
|
||||||
settingsManager.settings.hasCompletedOnboarding = true
|
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()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ struct PostureSetupView: View {
|
|||||||
@Binding var enabled: Bool
|
@Binding var enabled: Bool
|
||||||
@Binding var intervalMinutes: Int
|
@Binding var intervalMinutes: Int
|
||||||
var onContinue: () -> Void
|
var onContinue: () -> Void
|
||||||
|
var onBack: (() -> Void)?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 30) {
|
VStack(spacing: 30) {
|
||||||
@@ -49,27 +50,41 @@ struct PostureSetupView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.gray.opacity(0.1))
|
.glassEffect(in: .rect(cornerRadius: 12))
|
||||||
.cornerRadius(12)
|
|
||||||
|
|
||||||
InfoBox(text: "Regular posture checks help prevent back and neck pain from prolonged sitting")
|
InfoBox(text: "Regular posture checks help prevent back and neck pain from prolonged sitting")
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button(action: onContinue) {
|
HStack(spacing: 12) {
|
||||||
Text("Continue")
|
if let onBack = onBack {
|
||||||
.font(.headline)
|
Button(action: onBack) {
|
||||||
.frame(maxWidth: .infinity)
|
HStack {
|
||||||
.padding()
|
Image(systemName: "chevron.left")
|
||||||
.background(Color.blue)
|
Text("Back")
|
||||||
.foregroundColor(.white)
|
}
|
||||||
.cornerRadius(12)
|
.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())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
.padding(.horizontal, 40)
|
.padding(.horizontal, 40)
|
||||||
}
|
}
|
||||||
.frame(width: 600, height: 500)
|
.frame(width: 600, height: 500)
|
||||||
.padding()
|
.padding()
|
||||||
|
.background(.clear)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +92,7 @@ struct PostureSetupView: View {
|
|||||||
PostureSetupView(
|
PostureSetupView(
|
||||||
enabled: .constant(true),
|
enabled: .constant(true),
|
||||||
intervalMinutes: .constant(30),
|
intervalMinutes: .constant(30),
|
||||||
onContinue: {}
|
onContinue: {},
|
||||||
|
onBack: {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
170
Gaze/Views/Onboarding/SettingsOnboardingView.swift
Normal file
170
Gaze/Views/Onboarding/SettingsOnboardingView.swift
Normal 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: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ struct WelcomeView: View {
|
|||||||
FeatureRow(icon: "figure.stand", title: "Maintain Good Posture", description: "Gentle reminders to sit up straight")
|
FeatureRow(icon: "figure.stand", title: "Maintain Good Posture", description: "Gentle reminders to sit up straight")
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
.glassEffect(in: .rect(cornerRadius: 16))
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
@@ -39,15 +40,14 @@ struct WelcomeView: View {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.blue)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.cornerRadius(12)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.glassEffect(.regular.tint(.blue).interactive())
|
||||||
.padding(.horizontal, 40)
|
.padding(.horizontal, 40)
|
||||||
}
|
}
|
||||||
.frame(width: 600, height: 500)
|
.frame(width: 600, height: 500)
|
||||||
.padding()
|
.padding()
|
||||||
|
.background(.clear)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user