diff --git a/Gaze/Constants/AdaptiveLayout.swift b/Gaze/Constants/AdaptiveLayout.swift new file mode 100644 index 0000000..fac09f0 --- /dev/null +++ b/Gaze/Constants/AdaptiveLayout.swift @@ -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)) + } +} diff --git a/Gaze/Views/Containers/AdditionalModifiersView.swift b/Gaze/Views/Containers/AdditionalModifiersView.swift index 44bd12e..06b3b0f 100644 --- a/Gaze/Views/Containers/AdditionalModifiersView.swift +++ b/Gaze/Views/Containers/AdditionalModifiersView.swift @@ -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) -> some View { + private func smartModeToggle( + icon: String, iconColor: Color, title: String, subtitle: String, isOn: Binding + ) -> 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 diff --git a/Gaze/Views/Containers/OnboardingContainerView.swift b/Gaze/Views/Containers/OnboardingContainerView.swift index ea745c5..b9b3dc9 100644 --- a/Gaze/Views/Containers/OnboardingContainerView.swift +++ b/Gaze/Views/Containers/OnboardingContainerView.swift @@ -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() { diff --git a/Gaze/Views/Containers/SettingsWindowView.swift b/Gaze/Views/Containers/SettingsWindowView.swift index dd6e76a..772af2e 100644 --- a/Gaze/Views/Containers/SettingsWindowView.swift +++ b/Gaze/Views/Containers/SettingsWindowView.swift @@ -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 diff --git a/Gaze/Views/Setup/CompletionView.swift b/Gaze/Views/Setup/CompletionView.swift index 1514422..251a4c2 100644 --- a/Gaze/Views/Setup/CompletionView.swift +++ b/Gaze/Views/Setup/CompletionView.swift @@ -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") { diff --git a/Gaze/Views/Setup/EnforceModeSetupView.swift b/Gaze/Views/Setup/EnforceModeSetupView.swift index 406373e..51560cf 100644 --- a/Gaze/Views/Setup/EnforceModeSetupView.swift +++ b/Gaze/Views/Setup/EnforceModeSetupView.swift @@ -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 { diff --git a/Gaze/Views/Setup/MenuBarWelcomeView.swift b/Gaze/Views/Setup/MenuBarWelcomeView.swift index a507db1..247e82e 100644 --- a/Gaze/Views/Setup/MenuBarWelcomeView.swift +++ b/Gaze/Views/Setup/MenuBarWelcomeView.swift @@ -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) } diff --git a/Gaze/Views/Setup/UserTimersView.swift b/Gaze/Views/Setup/UserTimersView.swift index d747110..ced272e 100644 --- a/Gaze/Views/Setup/UserTimersView.swift +++ b/Gaze/Views/Setup/UserTimersView.swift @@ -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) } } diff --git a/Gaze/Views/Setup/WelcomeView.swift b/Gaze/Views/Setup/WelcomeView.swift index ba6c7c0..10cf9b8 100644 --- a/Gaze/Views/Setup/WelcomeView.swift +++ b/Gaze/Views/Setup/WelcomeView.swift @@ -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) } }