fix: small screen/resolution fixes

This commit is contained in:
Michael Freno
2026-01-19 14:19:30 -05:00
parent ac6287bc82
commit b888e1126a
9 changed files with 551 additions and 369 deletions

View File

@@ -0,0 +1,108 @@
//
// AdaptiveLayout.swift
// Gaze
//
// Created by Claude on 1/19/26.
//
import SwiftUI
/// Adaptive layout constants for responsive UI scaling on different display sizes
enum AdaptiveLayout {
/// Minimum window dimensions
enum Window {
static let minWidth: CGFloat = 700
#if APPSTORE
static let minHeight: CGFloat = 500
#else
static let minHeight: CGFloat = 600
#endif
static let defaultWidth: CGFloat = 900
#if APPSTORE
static let defaultHeight: CGFloat = 650
#else
static let defaultHeight: CGFloat = 800
#endif
}
/// Content area constraints
enum Content {
/// Maximum width for content cards/sections
static let maxWidth: CGFloat = 560
/// Minimum width for content cards/sections
static let minWidth: CGFloat = 400
/// Ideal width for onboarding/welcome cards
static let idealCardWidth: CGFloat = 520
}
/// Font sizes that scale based on available space
enum Font {
static let heroIcon: CGFloat = 60
static let heroIconSmall: CGFloat = 48
static let heroTitle: CGFloat = 28
static let heroTitleSmall: CGFloat = 24
static let cardIcon: CGFloat = 32
static let cardIconSmall: CGFloat = 28
}
/// Spacing values
enum Spacing {
static let standard: CGFloat = 20
static let compact: CGFloat = 12
static let section: CGFloat = 30
static let sectionCompact: CGFloat = 20
}
/// Card dimensions for swipeable cards
enum Card {
static let maxWidth: CGFloat = 520
static let minWidth: CGFloat = 380
static let maxHeight: CGFloat = 480
static let minHeight: CGFloat = 360
static let backOffset: CGFloat = 24
static let backScale: CGFloat = 0.92
}
}
/// Environment key to determine if we're in a compact layout
struct IsCompactLayoutKey: EnvironmentKey {
static let defaultValue: Bool = false
}
extension EnvironmentValues {
var isCompactLayout: Bool {
get { self[IsCompactLayoutKey.self] }
set { self[IsCompactLayoutKey.self] = newValue }
}
}
/// View modifier that adapts layout based on available size
struct AdaptiveContainerModifier: ViewModifier {
@State private var isCompact = false
let compactThreshold: CGFloat
init(compactThreshold: CGFloat = 600) {
self.compactThreshold = compactThreshold
}
func body(content: Content) -> some View {
GeometryReader { geometry in
content
.environment(\.isCompactLayout, geometry.size.height < compactThreshold)
.onAppear {
isCompact = geometry.size.height < compactThreshold
}
.onChange(of: geometry.size.height) { _, newHeight in
isCompact = newHeight < compactThreshold
}
}
}
}
extension View {
/// Makes the view adapt its layout based on available space
func adaptiveContainer(compactThreshold: CGFloat = 600) -> some View {
modifier(AdaptiveContainerModifier(compactThreshold: compactThreshold))
}
}

View File

@@ -12,81 +12,95 @@ struct AdditionalModifiersView: View {
@State private var frontCardIndex: Int = 0
@State private var dragOffset: CGFloat = 0
@State private var isDragging: Bool = false
private let cardWidth: CGFloat = 480
private let cardHeight: CGFloat = 480
private let backCardOffset: CGFloat = 30
private let backCardScale: CGFloat = 0.92
@Environment(\.isCompactLayout) private var isCompact
private var backCardOffset: CGFloat { isCompact ? 20 : AdaptiveLayout.Card.backOffset }
private var backCardScale: CGFloat { AdaptiveLayout.Card.backScale }
var body: some View {
VStack(spacing: 0) {
SetupHeader(icon: "slider.horizontal.3", title: "Additional Options", color: .purple)
GeometryReader { geometry in
let availableWidth = geometry.size.width - 80 // Account for padding
let availableHeight = geometry.size.height - 200 // Account for header and nav
Text("Optional features to enhance your experience")
.font(.title3)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.bottom, 20)
let cardWidth = min(
max(availableWidth * 0.85, AdaptiveLayout.Card.minWidth),
AdaptiveLayout.Card.maxWidth
)
let cardHeight = min(
max(availableHeight * 0.75, AdaptiveLayout.Card.minHeight),
AdaptiveLayout.Card.maxHeight
)
Spacer()
// Card stack
ZStack {
// Card 0 (Enforce Mode)
cardView(for: 0)
.zIndex(zIndex(for: 0))
.scaleEffect(scale(for: 0))
.offset(x: xOffset(for: 0), y: yOffset(for: 0))
.opacity(opacity(for: 0))
// Card 1 (Smart Mode)
cardView(for: 1)
.zIndex(zIndex(for: 1))
.scaleEffect(scale(for: 1))
.offset(x: xOffset(for: 1), y: yOffset(for: 1))
.opacity(opacity(for: 1))
VStack(spacing: 0) {
SetupHeader(icon: "slider.horizontal.3", title: "Additional Options", color: .purple)
Text("Optional features to enhance your experience")
.font(isCompact ? .subheadline : .title3)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.bottom, isCompact ? 12 : 20)
Spacer()
ZStack {
cardView(for: 0, width: cardWidth, height: cardHeight)
.zIndex(zIndex(for: 0))
.scaleEffect(scale(for: 0))
.offset(x: xOffset(for: 0), y: yOffset(for: 0))
cardView(for: 1, width: cardWidth, height: cardHeight)
.zIndex(zIndex(for: 1))
.scaleEffect(scale(for: 1))
.offset(x: xOffset(for: 1), y: yOffset(for: 1))
}
.padding(isCompact ? 12 : 20)
.gesture(dragGesture)
Spacer()
// Navigation controls
HStack(spacing: isCompact ? 12 : 20) {
Button(action: { swapCards() }) {
Image(systemName: "chevron.left")
.font(isCompact ? .body : .title2)
.frame(width: isCompact ? 36 : 44, height: isCompact ? 36 : 44)
.contentShape(.rect)
}
.buttonStyle(.plain)
.glassEffectIfAvailable(
GlassStyle.regular.interactive(), in: .rect(cornerRadius: 10)
)
.opacity(frontCardIndex == 0 ? 0.3 : 1.0)
.disabled(frontCardIndex == 0)
// Page indicators with labels
HStack(spacing: isCompact ? 10 : 16) {
cardIndicator(index: 0, icon: "video.fill", label: "Enforce")
cardIndicator(index: 1, icon: "brain.fill", label: "Smart")
}
Button(action: { swapCards() }) {
Image(systemName: "chevron.right")
.font(isCompact ? .body : .title2)
.frame(width: isCompact ? 36 : 44, height: isCompact ? 36 : 44)
.contentShape(.rect)
}
.buttonStyle(.plain)
.glassEffectIfAvailable(
GlassStyle.regular.interactive(), in: .rect(cornerRadius: 10)
)
.opacity(frontCardIndex == 1 ? 0.3 : 1.0)
.disabled(frontCardIndex == 1)
}
.padding(.bottom, isCompact ? 6 : 10)
}
.gesture(dragGesture)
Spacer()
// Navigation controls
HStack(spacing: 20) {
Button(action: { swapCards() }) {
Image(systemName: "chevron.left")
.font(.title2)
.frame(width: 44, height: 44)
}
.buttonStyle(.plain)
.glassEffectIfAvailable(GlassStyle.regular.interactive(), in: .rect(cornerRadius: 10))
.disabled(frontCardIndex == 0)
.opacity(frontCardIndex == 0 ? 0.4 : 1)
// Page indicators with labels
HStack(spacing: 16) {
cardIndicator(index: 0, icon: "video.fill", label: "Enforce")
cardIndicator(index: 1, icon: "brain.fill", label: "Smart")
}
Button(action: { swapCards() }) {
Image(systemName: "chevron.right")
.font(.title2)
.frame(width: 44, height: 44)
}
.buttonStyle(.plain)
.glassEffectIfAvailable(GlassStyle.regular.interactive(), in: .rect(cornerRadius: 10))
.disabled(frontCardIndex == 1)
.opacity(frontCardIndex == 1 ? 0.4 : 1)
}
.padding(.bottom, 10)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.background(.clear)
}
// MARK: - Card Indicator
@ViewBuilder
private func cardIndicator(index: Int, icon: String, label: String) -> some View {
Button(action: {
@@ -101,9 +115,10 @@ struct AdditionalModifiersView: View {
.font(.caption)
.fontWeight(.medium)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.padding(.horizontal, isCompact ? 10 : 12)
.padding(.vertical, isCompact ? 5 : 6)
.foregroundStyle(index == frontCardIndex ? .primary : .secondary)
.contentShape(.rect)
}
.buttonStyle(.plain)
.glassEffectIfAvailable(
@@ -113,74 +128,74 @@ struct AdditionalModifiersView: View {
in: .capsule
)
}
// MARK: - Card Transform Calculations
private func zIndex(for cardIndex: Int) -> Double {
let isFront = cardIndex == frontCardIndex
let dragProgress = abs(dragOffset) / 150
if isDragging && dragProgress > 0.3 {
return isFront ? 0 : 1
}
return isFront ? 1 : 0
}
private func scale(for cardIndex: Int) -> CGFloat {
let isFront = cardIndex == frontCardIndex
let dragProgress = min(abs(dragOffset) / 150, 1.0)
if isFront {
return 1.0 - (dragProgress * (1.0 - backCardScale))
} else {
return backCardScale + (dragProgress * (1.0 - backCardScale))
}
}
private func xOffset(for cardIndex: Int) -> CGFloat {
let isFront = cardIndex == frontCardIndex
let dragProgress = min(abs(dragOffset) / 150, 1.0)
let backPeekX = backCardOffset
if isFront {
return dragOffset + (dragProgress * backPeekX * (dragOffset > 0 ? -1 : 1))
} else {
return backPeekX * (1.0 - dragProgress)
}
}
private func yOffset(for cardIndex: Int) -> CGFloat {
let isFront = cardIndex == frontCardIndex
let dragProgress = min(abs(dragOffset) / 150, 1.0)
let backPeekY: CGFloat = 15
let backPeekY: CGFloat = isCompact ? 10 : 15
if isFront {
return dragProgress * backPeekY
} else {
return backPeekY * (1.0 - dragProgress)
}
}
private func opacity(for cardIndex: Int) -> CGFloat {
let isFront = cardIndex == frontCardIndex
let dragProgress = min(abs(dragOffset) / 150, 1.0)
if isFront {
return 1.0 - (dragProgress * 0.3)
} else {
return 0.7 + (dragProgress * 0.3)
}
}
// MARK: - Card Views
@ViewBuilder
private func cardView(for index: Int) -> some View {
private func cardView(for index: Int, width: CGFloat, height: CGFloat) -> some View {
ZStack {
RoundedRectangle(cornerRadius: 16)
.fill(Color(NSColor.windowBackgroundColor).opacity(0.8))
.fill(Color(NSColor.windowBackgroundColor))
.shadow(color: Color.black.opacity(0.2), radius: 10, x: 0, y: 4)
Group {
if index == 0 {
enforceModeContent
@@ -188,66 +203,70 @@ struct AdditionalModifiersView: View {
smartModeContent
}
}
.padding(20)
.padding(isCompact ? 12 : 20)
}
.frame(width: cardWidth, height: cardHeight)
.frame(width: width, height: height)
}
private var enforceModeContent: some View {
VStack(spacing: 16) {
VStack(spacing: isCompact ? 10 : 16) {
Image(systemName: "video.fill")
.font(.system(size: 40))
.font(.system(size: isCompact ? AdaptiveLayout.Font.cardIconSmall : AdaptiveLayout.Font.cardIcon))
.foregroundStyle(Color.accentColor)
Text("Enforce Mode")
.font(.title2)
.font(isCompact ? .headline : .title2)
.fontWeight(.bold)
Text("Use your camera to ensure you take breaks")
.font(.subheadline)
.font(isCompact ? .caption : .subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Spacer()
VStack(spacing: 16) {
VStack(spacing: isCompact ? 10 : 16) {
HStack {
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: 2) {
Text("Enable Enforce Mode")
.font(.headline)
.font(isCompact ? .subheadline : .headline)
Text("Camera activates before lookaway reminders")
.font(.caption)
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
Toggle("", isOn: $settingsManager.settings.enforcementMode)
.labelsHidden()
.controlSize(isCompact ? .small : .regular)
}
.padding()
.padding(isCompact ? 10 : 16)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
HStack {
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: 2) {
Text("Camera Access")
.font(.headline)
.font(isCompact ? .subheadline : .headline)
if CameraAccessService.shared.isCameraAuthorized {
Label("Authorized", systemImage: "checkmark.circle.fill")
.font(.caption)
.font(.caption2)
.foregroundStyle(.green)
} else if let error = CameraAccessService.shared.cameraError {
Label(error.localizedDescription, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
Label(
error.localizedDescription,
systemImage: "exclamationmark.triangle.fill"
)
.font(.caption2)
.foregroundStyle(.orange)
} else {
Label("Not authorized", systemImage: "xmark.circle.fill")
.font(.caption)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer()
if !CameraAccessService.shared.isCameraAuthorized {
Button("Request Access") {
Task { @MainActor in
@@ -259,34 +278,35 @@ struct AdditionalModifiersView: View {
}
}
.buttonStyle(.bordered)
.controlSize(isCompact ? .small : .regular)
}
}
.padding()
.padding(isCompact ? 10 : 16)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
Spacer()
}
}
private var smartModeContent: some View {
VStack(spacing: 16) {
VStack(spacing: isCompact ? 10 : 16) {
Image(systemName: "brain.fill")
.font(.system(size: 40))
.font(.system(size: isCompact ? AdaptiveLayout.Font.cardIconSmall : AdaptiveLayout.Font.cardIcon))
.foregroundStyle(.purple)
Text("Smart Mode")
.font(.title2)
.font(isCompact ? .headline : .title2)
.fontWeight(.bold)
Text("Automatically manage timers based on activity")
.font(.subheadline)
.font(isCompact ? .caption : .subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Spacer()
VStack(spacing: 12) {
VStack(spacing: isCompact ? 8 : 12) {
smartModeToggle(
icon: "arrow.up.left.and.arrow.down.right",
iconColor: .blue,
@@ -294,7 +314,7 @@ struct AdditionalModifiersView: View {
subtitle: "Pause during videos, games, presentations",
isOn: $settingsManager.settings.smartMode.autoPauseOnFullscreen
)
smartModeToggle(
icon: "moon.zzz.fill",
iconColor: .indigo,
@@ -302,7 +322,7 @@ struct AdditionalModifiersView: View {
subtitle: "Pause when you're inactive",
isOn: $settingsManager.settings.smartMode.autoPauseOnIdle
)
smartModeToggle(
icon: "chart.line.uptrend.xyaxis",
iconColor: .green,
@@ -311,40 +331,43 @@ struct AdditionalModifiersView: View {
isOn: $settingsManager.settings.smartMode.trackUsage
)
}
Spacer()
}
}
@ViewBuilder
private func smartModeToggle(icon: String, iconColor: Color, title: String, subtitle: String, isOn: Binding<Bool>) -> some View {
private func smartModeToggle(
icon: String, iconColor: Color, title: String, subtitle: String, isOn: Binding<Bool>
) -> some View {
HStack {
Image(systemName: icon)
.foregroundStyle(iconColor)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
.frame(width: isCompact ? 20 : 24)
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(.subheadline)
.font(isCompact ? .caption : .subheadline)
.fontWeight(.medium)
Text(subtitle)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
Toggle("", isOn: isOn)
.labelsHidden()
.controlSize(.small)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.padding(.horizontal, isCompact ? 8 : 12)
.padding(.vertical, isCompact ? 6 : 10)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 10))
}
// MARK: - Gestures & Navigation
private var dragGesture: some Gesture {
DragGesture()
.onChanged { value in
@@ -353,9 +376,10 @@ struct AdditionalModifiersView: View {
}
.onEnded { value in
let threshold: CGFloat = 80
let shouldSwap = abs(value.translation.width) > threshold ||
abs(value.predictedEndTranslation.width) > 150
let shouldSwap =
abs(value.translation.width) > threshold
|| abs(value.predictedEndTranslation.width) > 150
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
if shouldSwap {
frontCardIndex = 1 - frontCardIndex
@@ -365,7 +389,7 @@ struct AdditionalModifiersView: View {
}
}
}
private func swapCards() {
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
frontCardIndex = 1 - frontCardIndex

View File

@@ -72,7 +72,7 @@ final class OnboardingWindowPresenter {
func close() {
// Notify overlay presenter to hide the guide overlay
MenuBarGuideOverlayPresenter.shared.hide()
windowController?.window?.close()
windowController = nil
}
@@ -80,15 +80,11 @@ final class OnboardingWindowPresenter {
private func createWindow(settingsManager: SettingsManager) {
let window = NSWindow(
contentRect: NSRect(
x: 0, y: 0, width: 1000,
height: {
#if APPSTORE
return 700
#else
return 1000
#endif
}()),
styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView],
x: 0, y: 0,
width: AdaptiveLayout.Window.defaultWidth,
height: AdaptiveLayout.Window.defaultHeight
),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
@@ -114,7 +110,7 @@ final class OnboardingWindowPresenter {
window.orderFrontRegardless()
windowController = controller
// Setup observer for when the onboarding window closes
MenuBarGuideOverlayPresenter.shared.setupOnboardingWindowObserver()
}
@@ -128,60 +124,62 @@ struct OnboardingContainerView: View {
private let lastPageIndex = 7
var body: some View {
ZStack {
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
.ignoresSafeArea()
VStack(spacing: 0) {
TabView(selection: $currentPage) {
WelcomeView()
.tag(0)
.tabItem { Image(systemName: "hand.wave.fill") }
GeometryReader { geometry in
let isCompact = geometry.size.height < 600
MenuBarWelcomeView()
.tag(1)
.tabItem { Image(systemName: "menubar.rectangle") }
ZStack {
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
.ignoresSafeArea()
VStack(spacing: 0) {
TabView(selection: $currentPage) {
WelcomeView()
.tag(0)
.tabItem { Image(systemName: "hand.wave.fill") }
LookAwaySetupView(settingsManager: settingsManager)
.tag(2)
.tabItem { Image(systemName: "eye.fill") }
MenuBarWelcomeView()
.tag(1)
.tabItem { Image(systemName: "menubar.rectangle") }
BlinkSetupView(settingsManager: settingsManager)
.tag(3)
.tabItem { Image(systemName: "eye.circle.fill") }
LookAwaySetupView(settingsManager: settingsManager)
.tag(2)
.tabItem { Image(systemName: "eye.fill") }
PostureSetupView(settingsManager: settingsManager)
.tag(4)
.tabItem { Image(systemName: "figure.stand") }
BlinkSetupView(settingsManager: settingsManager)
.tag(3)
.tabItem { Image(systemName: "eye.circle.fill") }
AdditionalModifiersView(settingsManager: settingsManager)
.tag(5)
.tabItem { Image(systemName: "slider.horizontal.3") }
PostureSetupView(settingsManager: settingsManager)
.tag(4)
.tabItem { Image(systemName: "figure.stand") }
GeneralSetupView(settingsManager: settingsManager, isOnboarding: true)
.tag(6)
.tabItem { Image(systemName: "gearshape.fill") }
AdditionalModifiersView(settingsManager: settingsManager)
.tag(5)
.tabItem { Image(systemName: "slider.horizontal.3") }
CompletionView()
.tag(7)
.tabItem { Image(systemName: "checkmark.circle.fill") }
ScrollView {
GeneralSetupView(settingsManager: settingsManager, isOnboarding: true)
}.tag(6)
.tabItem { Image(systemName: "gearshape.fill") }
CompletionView()
.tag(7)
.tabItem { Image(systemName: "checkmark.circle.fill") }
}
.tabViewStyle(.automatic)
.onChange(of: currentPage) { _, newValue in
MenuBarGuideOverlayPresenter.shared.updateVisibility(
isVisible: newValue == 1)
}
navigationButtons(isCompact: isCompact)
}
.tabViewStyle(.automatic)
.onChange(of: currentPage) { _, newValue in
MenuBarGuideOverlayPresenter.shared.updateVisibility(isVisible: newValue == 1)
}
navigationButtons
}
.environment(\.isCompactLayout, isCompact)
}
.frame(
minWidth: 1000,
minHeight: {
#if APPSTORE
return 700
#else
return 1000
#endif
}()
minWidth: AdaptiveLayout.Window.minWidth,
minHeight: AdaptiveLayout.Window.minHeight
)
.onAppear {
MenuBarGuideOverlayPresenter.shared.updateVisibility(isVisible: currentPage == 1)
@@ -192,7 +190,7 @@ struct OnboardingContainerView: View {
}
@ViewBuilder
private var navigationButtons: some View {
private func navigationButtons(isCompact: Bool) -> some View {
HStack(spacing: 12) {
if currentPage > 0 {
Button(action: { currentPage -= 1 }) {
@@ -200,8 +198,11 @@ struct OnboardingContainerView: View {
Image(systemName: "chevron.left")
Text("Back")
}
.font(.headline)
.frame(minWidth: 100, maxWidth: .infinity, minHeight: 44, maxHeight: 44)
.font(isCompact ? .subheadline : .headline)
.frame(
minWidth: 80, maxWidth: .infinity, minHeight: isCompact ? 36 : 44,
maxHeight: isCompact ? 36 : 44
)
.foregroundStyle(.primary)
.contentShape(RoundedRectangle(cornerRadius: 10))
}
@@ -222,8 +223,11 @@ struct OnboardingContainerView: View {
? "Let's Get Started"
: currentPage == lastPageIndex ? "Get Started" : "Continue"
)
.font(.headline)
.frame(minWidth: 100, maxWidth: .infinity, minHeight: 44, maxHeight: 44)
.font(isCompact ? .subheadline : .headline)
.frame(
minWidth: 80, maxWidth: .infinity, minHeight: isCompact ? 36 : 44,
maxHeight: isCompact ? 36 : 44
)
.foregroundStyle(.white)
.contentShape(RoundedRectangle(cornerRadius: 10))
}
@@ -234,8 +238,8 @@ struct OnboardingContainerView: View {
in: .rect(cornerRadius: 10)
)
}
.padding(.horizontal, 40)
.padding(.bottom, 20)
.padding(.horizontal, isCompact ? 24 : 40)
.padding(.bottom, isCompact ? 12 : 20)
}
private func completeOnboarding() {

View File

@@ -55,7 +55,11 @@ final class SettingsWindowPresenter {
private func createWindow(settingsManager: SettingsManager, initialTab: Int) {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1000, height: 900),
contentRect: NSRect(
x: 0, y: 0,
width: AdaptiveLayout.Window.defaultWidth,
height: AdaptiveLayout.Window.defaultHeight
),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false
@@ -100,52 +104,54 @@ struct SettingsWindowView: View {
}
var body: some View {
ZStack {
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
.ignoresSafeArea()
GeometryReader { geometry in
let isCompact = geometry.size.height < 600
ZStack {
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
.ignoresSafeArea()
VStack(spacing: 0) {
NavigationSplitView {
List(SettingsSection.allCases, selection: $selectedSection) { section in
NavigationLink(value: section) {
Label(section.title, systemImage: section.iconName)
VStack(spacing: 0) {
NavigationSplitView {
List(SettingsSection.allCases, selection: $selectedSection) { section in
NavigationLink(value: section) {
Label(section.title, systemImage: section.iconName)
}
}
.listStyle(.sidebar)
} detail: {
ScrollView {
detailView(for: selectedSection)
}
}
.listStyle(.sidebar)
} detail: {
ScrollView {
detailView(for: selectedSection)
}
}
.onReceive(
NotificationCenter.default.publisher(
for: Notification.Name("SwitchToSettingsTab"))
) { notification in
if let tab = notification.object as? Int,
let section = SettingsSection(rawValue: tab)
{
selectedSection = section
}
}
#if DEBUG
Divider()
HStack {
Button("Retrigger Onboarding") {
retriggerOnboarding()
.onReceive(
NotificationCenter.default.publisher(
for: Notification.Name("SwitchToSettingsTab"))
) { notification in
if let tab = notification.object as? Int,
let section = SettingsSection(rawValue: tab)
{
selectedSection = section
}
.buttonStyle(.bordered)
Spacer()
}
.padding()
#endif
#if DEBUG
Divider()
HStack {
Button("Retrigger Onboarding") {
retriggerOnboarding()
}
.buttonStyle(.bordered)
.controlSize(isCompact ? .small : .regular)
Spacer()
}
.padding(isCompact ? 8 : 16)
#endif
}
}
.environment(\.isCompactLayout, isCompact)
}
#if APPSTORE
.frame(minWidth: 1000, minHeight: 700)
#else
.frame(minWidth: 1000, minHeight: 900)
#endif
.frame(minWidth: AdaptiveLayout.Window.minWidth, minHeight: AdaptiveLayout.Window.minHeight)
}
@ViewBuilder

View File

@@ -8,73 +8,69 @@
import SwiftUI
struct CompletionView: View {
@Environment(\.isCompactLayout) private var isCompact
private var iconSize: CGFloat {
isCompact ? AdaptiveLayout.Font.heroIconSmall : AdaptiveLayout.Font.heroIcon
}
private var titleSize: CGFloat {
isCompact ? AdaptiveLayout.Font.heroTitleSmall : AdaptiveLayout.Font.heroTitle
}
private var spacing: CGFloat {
isCompact ? AdaptiveLayout.Spacing.compact : AdaptiveLayout.Spacing.standard
}
var body: some View {
VStack(spacing: 30) {
VStack(spacing: spacing * 1.5) {
Spacer()
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 80))
.font(.system(size: iconSize))
.foregroundStyle(.green)
Text("You're All Set!")
.font(.system(size: 36, weight: .bold))
.font(.system(size: titleSize, weight: .bold))
Text("Gaze will now help you take care of your eyes and posture")
.font(.title3)
.font(isCompact ? .subheadline : .title3)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
.padding(.horizontal, isCompact ? 20 : 40)
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: isCompact ? 10 : 16) {
Text("What happens next:")
.font(.headline)
.font(.subheadline)
.fontWeight(.semibold)
.padding(.horizontal)
HStack(spacing: 16) {
Image(systemName: "menubar.rectangle")
.foregroundStyle(Color.accentColor)
.frame(width: 30)
Text("Gaze will appear in your menu bar")
.font(.subheadline)
}
.padding(.horizontal)
HStack(spacing: 16) {
Image(systemName: "clock")
.foregroundStyle(Color.accentColor)
.frame(width: 30)
Text("Timers will start automatically")
.font(.subheadline)
}
.padding(.horizontal)
HStack(spacing: 16) {
Image(systemName: "gearshape")
.foregroundStyle(Color.accentColor)
.frame(width: 30)
Text("Adjust settings anytime from the menu bar")
.font(.subheadline)
}
.padding(.horizontal)
HStack(spacing: 16) {
Image(systemName: "plus.circle")
.foregroundStyle(Color.accentColor)
.frame(width: 30)
Text("Create custom timers in Settings for additional reminders")
.font(.subheadline)
}
.padding(.horizontal)
completionItem(icon: "menubar.rectangle", text: "Gaze will appear in your menu bar")
completionItem(icon: "clock", text: "Timers will start automatically")
completionItem(icon: "gearshape", text: "Adjust settings anytime from the menu bar")
completionItem(icon: "plus.circle", text: "Create custom timers in Settings for additional reminders")
}
.padding()
.padding(isCompact ? 12 : 16)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
Spacer()
}
.frame(width: 600, height: 450)
.frame(maxWidth: AdaptiveLayout.Content.maxWidth)
.padding()
.background(.clear)
}
@ViewBuilder
private func completionItem(icon: String, text: String) -> some View {
HStack(spacing: 12) {
Image(systemName: icon)
.foregroundStyle(Color.accentColor)
.frame(width: 24)
Text(text)
.font(.caption)
}
.padding(.horizontal)
}
}
#Preview("Completion View") {

View File

@@ -14,6 +14,7 @@ struct EnforceModeSetupView: View {
@ObservedObject var cameraService = CameraAccessService.shared
@ObservedObject var eyeTrackingService = EyeTrackingService.shared
@ObservedObject var enforceModeService = EnforceModeService.shared
@Environment(\.isCompactLayout) private var isCompact
@State private var isProcessingToggle = false
@State private var isTestModeActive = false
@@ -30,19 +31,19 @@ struct EnforceModeSetupView: View {
Spacer()
VStack(spacing: 30) {
VStack(spacing: isCompact ? 16 : 30) {
Text("Use your camera to ensure you take breaks")
.font(.title3)
.font(isCompact ? .subheadline : .title3)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
VStack(spacing: 20) {
VStack(spacing: isCompact ? 12 : 20) {
HStack {
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: 2) {
Text("Enable Enforce Mode")
.font(.headline)
.font(isCompact ? .subheadline : .headline)
Text("Camera activates 3 seconds before lookaway reminders")
.font(.caption)
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
@@ -65,8 +66,9 @@ struct EnforceModeSetupView: View {
)
.labelsHidden()
.disabled(isProcessingToggle)
.controlSize(isCompact ? .small : .regular)
}
.padding()
.padding(isCompact ? 10 : 16)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
cameraStatusView
@@ -215,7 +217,7 @@ struct EnforceModeSetupView: View {
Spacer()
}
}
.frame(height: 300)
.frame(height: isCompact ? 200 : 300)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
.onAppear {
if cachedPreviewLayer == nil {

View File

@@ -8,25 +8,40 @@
import SwiftUI
struct MenuBarWelcomeView: View {
@Environment(\.isCompactLayout) private var isCompact
private var iconSize: CGFloat {
isCompact ? AdaptiveLayout.Font.heroIconSmall : AdaptiveLayout.Font.heroIcon
}
private var titleSize: CGFloat {
isCompact ? AdaptiveLayout.Font.heroTitleSmall : AdaptiveLayout.Font.heroTitle
}
private var spacing: CGFloat {
isCompact ? AdaptiveLayout.Spacing.compact : AdaptiveLayout.Spacing.standard
}
var body: some View {
VStack(spacing: 30) {
VStack(spacing: spacing * 1.5) {
Spacer()
Image(systemName: "menubar.rectangle")
.font(.system(size: 72))
.font(.system(size: iconSize))
.foregroundStyle(Color.accentColor)
VStack(spacing: 8) {
Text("Gaze Lives in Your Menu Bar")
.font(.system(size: 34, weight: .bold))
.font(.system(size: titleSize, weight: .bold))
.multilineTextAlignment(.center)
Text("Keep an eye on the top-right of your screen for the Gaze icon.")
.font(.title3)
.font(isCompact ? .subheadline : .title3)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: isCompact ? 12 : 16) {
FeatureRow(
icon: "cursorarrow.click", title: "Always Within Reach",
description: "Open settings and timers from the menu bar anytime")
@@ -37,12 +52,12 @@ struct MenuBarWelcomeView: View {
icon: "sparkles", title: "Quick Tweaks",
description: "Pause, resume, and adjust timers in one click")
}
.padding()
.padding(isCompact ? 12 : 16)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
Spacer()
}
.frame(width: 600, height: 450)
.frame(maxWidth: AdaptiveLayout.Content.maxWidth)
.padding()
.background(.clear)
}

View File

@@ -11,33 +11,34 @@ struct UserTimersView: View {
@Binding var userTimers: [UserTimer]
@State private var editingTimer: UserTimer?
@State private var showingAddTimer = false
@Environment(\.isCompactLayout) private var isCompact
var body: some View {
VStack(spacing: 0) {
VStack(spacing: 16) {
VStack(spacing: isCompact ? 10 : 16) {
Image(systemName: "clock.badge.checkmark")
.font(.system(size: 60))
.font(.system(size: isCompact ? AdaptiveLayout.Font.heroIconSmall : AdaptiveLayout.Font.heroIcon))
.foregroundStyle(.purple)
Text("Custom Timers")
.font(.system(size: 28, weight: .bold))
.font(.system(size: isCompact ? AdaptiveLayout.Font.heroTitleSmall : AdaptiveLayout.Font.heroTitle, weight: .bold))
}
.padding(.top, 20)
.padding(.bottom, 30)
.padding(.top, isCompact ? 12 : 20)
.padding(.bottom, isCompact ? 16 : 30)
Spacer()
VStack(spacing: 30) {
VStack(spacing: isCompact ? 16 : 30) {
Text("Create your own reminder schedules")
.font(.title3)
.font(isCompact ? .subheadline : .title3)
.foregroundStyle(.secondary)
HStack(spacing: 12) {
Image(systemName: "info.circle")
.foregroundStyle(.white)
Text("Add up to 3 custom timers with your own intervals and messages")
.font(.headline)
.font(isCompact ? .subheadline : .headline)
.foregroundStyle(.white)
}
.padding()
.padding(isCompact ? 10 : 16)
.glassEffectIfAvailable(
GlassStyle.regular.tint(.purple), in: .rect(cornerRadius: 8))
@@ -47,7 +48,7 @@ struct UserTimersView: View {
// for
HStack {
Text("Active Timers (\(userTimers.count)/3)")
.font(.headline)
.font(isCompact ? .subheadline : .headline)
Spacer()
if userTimers.count < 3 {
Button(action: {
@@ -56,6 +57,7 @@ struct UserTimersView: View {
Label("Add Timer", systemImage: "plus.circle.fill")
}
.buttonStyle(.borderedProminent)
.controlSize(isCompact ? .small : .regular)
}
}
/*#else*/
@@ -65,7 +67,7 @@ struct UserTimersView: View {
if userTimers.isEmpty {
VStack(spacing: 12) {
Image(systemName: "clock.badge.questionmark")
.font(.system(size: 40))
.font(.system(size: isCompact ? 28 : 40))
.foregroundStyle(.secondary)
Text("No custom timers yet")
.font(.subheadline)
@@ -75,7 +77,7 @@ struct UserTimersView: View {
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(40)
.padding(isCompact ? 24 : 40)
} else {
ScrollView {
VStack(spacing: 8) {
@@ -83,6 +85,7 @@ struct UserTimersView: View {
index, timer in
UserTimerRow(
timer: $userTimers[index],
isCompact: isCompact,
onEdit: {
editingTimer = timer
},
@@ -97,10 +100,10 @@ struct UserTimersView: View {
}
}
}
.frame(maxHeight: 200)
.frame(maxHeight: isCompact ? 150 : 200)
}
}
.padding()
.padding(isCompact ? 10 : 16)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
Spacer()
@@ -141,51 +144,53 @@ struct UserTimersView: View {
struct UserTimerRow: View {
@Binding var timer: UserTimer
var isCompact: Bool = false
var onEdit: () -> Void
var onDelete: () -> Void
@State private var isHovered = false
@State private var showingDeleteConfirmation = false
var body: some View {
HStack(spacing: 12) {
HStack(spacing: isCompact ? 8 : 12) {
Circle()
.fill(timer.color)
.frame(width: 12, height: 12)
.frame(width: isCompact ? 10 : 12, height: isCompact ? 10 : 12)
Image(systemName: timer.type == .subtle ? "eye.circle" : "rectangle.on.rectangle")
.foregroundStyle(timer.color)
.frame(width: 24)
.frame(width: isCompact ? 20 : 24)
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: 2) {
Text(timer.title)
.font(.subheadline)
.font(isCompact ? .caption : .subheadline)
.fontWeight(.medium)
.lineLimit(1)
Text(
"\(timer.type.displayName)\(timer.timeOnScreenSeconds)s on screen • \(timer.intervalMinutes) min interval"
)
.font(.caption)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
HStack(spacing: 8) {
HStack(spacing: isCompact ? 4 : 8) {
Toggle("", isOn: $timer.enabled)
.labelsHidden()
.toggleStyle(.switch)
.controlSize(.small)
.controlSize(.mini)
Button(action: onEdit) {
Image(systemName: "pencil.circle.fill")
.font(.title3)
.font(isCompact ? .subheadline : .title3)
.foregroundStyle(Color.accentColor)
}
.buttonStyle(.plain)
Button(action: { showingDeleteConfirmation = true }) {
Image(systemName: "trash.circle.fill")
.font(.title3)
.font(isCompact ? .subheadline : .title3)
.foregroundStyle(.red)
}
.buttonStyle(.plain)
@@ -200,7 +205,7 @@ struct UserTimerRow: View {
}
}
}
.padding()
.padding(isCompact ? 8 : 12)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.secondary.opacity(isHovered ? 0.1 : 0.05))
@@ -251,29 +256,31 @@ struct UserTimerEditSheet: View {
}
var body: some View {
VStack(spacing: 24) {
VStack(spacing: 20) {
Text(timer == nil ? "Add Custom Timer" : "Edit Custom Timer")
.font(.title2)
.font(.title3)
.fontWeight(.bold)
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
Text("Title")
.font(.headline)
.font(.subheadline)
.fontWeight(.medium)
TextField("Timer title", text: $title)
.textFieldStyle(.roundedBorder)
Text("Example: \"Stretch Break\", \"Eye Rest\", \"Water Break\"")
.font(.caption)
.font(.caption2)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 6) {
Text("Color")
.font(.headline)
.font(.subheadline)
.fontWeight(.medium)
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 8),
spacing: 12
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 8),
spacing: 8
) {
ForEach(UserTimer.defaultColors, id: \.self) { colorHex in
Button(action: {
@@ -281,23 +288,23 @@ struct UserTimerEditSheet: View {
}) {
Circle()
.fill(Color(hex: colorHex) ?? .purple)
.frame(width: 32, height: 32)
.frame(width: 28, height: 28)
.overlay(
Circle()
.strokeBorder(
Color.white,
lineWidth: selectedColorHex == colorHex ? 3 : 0)
lineWidth: selectedColorHex == colorHex ? 2 : 0)
)
.shadow(
color: selectedColorHex == colorHex ? .accentColor : .clear,
radius: 4)
radius: 3)
}
.buttonStyle(.plain)
}
}
}
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 6) {
Picker("Display Type", selection: $type) {
ForEach(UserTimerType.allCases) { timerType in
Text(timerType.displayName).tag(timerType)
@@ -317,14 +324,15 @@ struct UserTimerEditSheet: View {
? "Small reminder at top of screen"
: "Full screen reminder with animation"
)
.font(.caption)
.font(.caption2)
.foregroundStyle(.secondary)
}
if type == .overlay {
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 6) {
Text("Duration on Screen")
.font(.headline)
.font(.subheadline)
.fontWeight(.medium)
HStack {
Slider(
value: Binding(
@@ -335,15 +343,17 @@ struct UserTimerEditSheet: View {
step: 1
)
Text("\(timeOnScreen)s")
.frame(width: 50, alignment: .trailing)
.frame(width: 40, alignment: .trailing)
.monospacedDigit()
.font(.caption)
}
}
}
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 6) {
Text("Interval")
.font(.headline)
.font(.subheadline)
.fontWeight(.medium)
HStack {
Slider(
value: Binding(
@@ -354,21 +364,23 @@ struct UserTimerEditSheet: View {
step: 1
)
Text("\(intervalMinutes) min")
.frame(width: 60, alignment: .trailing)
.frame(width: 50, alignment: .trailing)
.monospacedDigit()
.font(.caption)
}
Text("How often this reminder will appear (in minutes)")
.font(.caption)
.font(.caption2)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 6) {
Text("Message (Optional)")
.font(.headline)
.font(.subheadline)
.fontWeight(.medium)
TextField("Enter custom reminder message", text: $message)
.textFieldStyle(.roundedBorder)
Text("Leave blank to show a default timer notification")
.font(.caption)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
@@ -396,8 +408,8 @@ struct UserTimerEditSheet: View {
.buttonStyle(.borderedProminent)
}
}
.padding(24)
.frame(width: 450)
.padding(20)
.frame(minWidth: 360, idealWidth: 420, maxWidth: 480)
}
}

View File

@@ -8,22 +8,36 @@
import SwiftUI
struct WelcomeView: View {
@Environment(\.isCompactLayout) private var isCompact
private var iconSize: CGFloat {
isCompact ? AdaptiveLayout.Font.heroIconSmall : AdaptiveLayout.Font.heroIcon
}
private var titleSize: CGFloat {
isCompact ? AdaptiveLayout.Font.heroTitleSmall : AdaptiveLayout.Font.heroTitle
}
private var spacing: CGFloat {
isCompact ? AdaptiveLayout.Spacing.compact : AdaptiveLayout.Spacing.standard
}
var body: some View {
VStack(spacing: 30) {
VStack(spacing: spacing * 1.5) {
Spacer()
Image(systemName: "eye.fill")
.font(.system(size: 80))
.font(.system(size: iconSize))
.foregroundStyle(Color.accentColor)
Text("Welcome to Gaze")
.font(.system(size: 36, weight: .bold))
.font(.system(size: titleSize, weight: .bold))
Text("Take care of your eyes and posture")
.font(.title3)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: isCompact ? 12 : 16) {
FeatureRow(
icon: "eye.trianglebadge.exclamationmark", title: "Reduce Eye Strain",
description: "Regular breaks help prevent digital eye strain")
@@ -37,12 +51,12 @@ struct WelcomeView: View {
icon: "plus.circle", title: "Custom Timers",
description: "Create your own timers for specific needs")
}
.padding()
.padding(isCompact ? 12 : 16)
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
Spacer()
}
.frame(width: 600, height: 450)
.frame(maxWidth: AdaptiveLayout.Content.maxWidth)
.padding()
.background(.clear)
}
@@ -63,17 +77,18 @@ struct FeatureRow: View {
}
var body: some View {
HStack(alignment: .top, spacing: 16) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon)
.font(.title2)
.font(.title3)
.foregroundStyle(iconColor)
.frame(width: 30)
.frame(width: 24)
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.headline)
Text(description)
.font(.subheadline)
.fontWeight(.semibold)
Text(description)
.font(.caption)
.foregroundStyle(.secondary)
}
}