mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Better animations for welcome view
This commit is contained in:
parent
997b4ab416
commit
bb462c8826
11 changed files with 319 additions and 223 deletions
|
|
@ -37,8 +37,14 @@ struct AboutView: View {
|
||||||
|
|
||||||
private var appInfoSection: some View {
|
private var appInfoSection: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
InteractiveAppIcon()
|
GlowingAppIcon(
|
||||||
.padding(.bottom, 20)
|
size: 128,
|
||||||
|
enableFloating: true,
|
||||||
|
enableInteraction: true,
|
||||||
|
glowIntensity: 0.3,
|
||||||
|
action: openWebsite
|
||||||
|
)
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
|
||||||
Text(appName)
|
Text(appName)
|
||||||
.font(.largeTitle)
|
.font(.largeTitle)
|
||||||
|
|
@ -50,6 +56,12 @@ struct AboutView: View {
|
||||||
}
|
}
|
||||||
.padding(.top, 40)
|
.padding(.top, 40)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func openWebsite() {
|
||||||
|
guard let url = URL(string: "https://vibetunnel.sh") else { return }
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
|
||||||
private var descriptionSection: some View {
|
private var descriptionSection: some View {
|
||||||
Text("Turn any browser into your terminal & command your agents on the go.")
|
Text("Turn any browser into your terminal & command your agents on the go.")
|
||||||
|
|
@ -133,120 +145,6 @@ struct HoverableLink: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Interactive app icon component with shadow effects and website link.
|
|
||||||
///
|
|
||||||
/// This component displays the VibeTunnel app icon with dynamic shadow effects that respond
|
|
||||||
/// to user interaction. It includes hover effects for visual feedback and opens the
|
|
||||||
/// VibeTunnel website when clicked.
|
|
||||||
struct InteractiveAppIcon: View {
|
|
||||||
@State private var isHovering = false
|
|
||||||
@State private var isPressed = false
|
|
||||||
@State private var floatingOffset: CGFloat = 0
|
|
||||||
@Environment(\.colorScheme)
|
|
||||||
private var colorScheme
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button(action: openWebsite) {
|
|
||||||
ZStack {
|
|
||||||
// Glow effect layers (multiple shadows for a more intense glow)
|
|
||||||
Image(nsImage: NSApp.applicationIconImage)
|
|
||||||
.resizable()
|
|
||||||
.frame(width: 128, height: 128)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 22))
|
|
||||||
.opacity(0.3)
|
|
||||||
.blur(radius: 20)
|
|
||||||
.scaleEffect(1.2)
|
|
||||||
.shadow(color: glowColor, radius: 30, x: 0, y: 0)
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
|
|
||||||
// Secondary glow layer
|
|
||||||
Image(nsImage: NSApp.applicationIconImage)
|
|
||||||
.resizable()
|
|
||||||
.frame(width: 128, height: 128)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 22))
|
|
||||||
.opacity(0.2)
|
|
||||||
.blur(radius: 10)
|
|
||||||
.scaleEffect(1.1)
|
|
||||||
.shadow(color: glowColor, radius: 20, x: 0, y: 0)
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
|
|
||||||
// Main icon with shadow
|
|
||||||
Image(nsImage: NSApp.applicationIconImage)
|
|
||||||
.resizable()
|
|
||||||
.frame(width: 128, height: 128)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 22))
|
|
||||||
.scaleEffect(isPressed ? 0.95 : (isHovering ? 1.05 : 1.0))
|
|
||||||
.shadow(
|
|
||||||
color: shadowColor,
|
|
||||||
radius: shadowRadius,
|
|
||||||
x: 0,
|
|
||||||
y: shadowOffset
|
|
||||||
)
|
|
||||||
.animation(.easeInOut(duration: 0.2), value: isHovering)
|
|
||||||
.animation(.easeInOut(duration: 0.1), value: isPressed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(PlainButtonStyle())
|
|
||||||
.offset(y: floatingOffset)
|
|
||||||
.pointingHandCursor()
|
|
||||||
.onHover { hovering in
|
|
||||||
isHovering = hovering
|
|
||||||
}
|
|
||||||
.simultaneousGesture(
|
|
||||||
DragGesture(minimumDistance: 0)
|
|
||||||
.onChanged { _ in
|
|
||||||
isPressed = true
|
|
||||||
}
|
|
||||||
.onEnded { _ in
|
|
||||||
isPressed = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.onAppear {
|
|
||||||
startFloatingAnimation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var glowColor: Color {
|
|
||||||
if colorScheme == .dark {
|
|
||||||
// Greenish-gold glow for dark mode
|
|
||||||
Color(red: 0.6, green: 0.8, blue: 0.4).opacity(isHovering ? 0.8 : 0.6)
|
|
||||||
} else {
|
|
||||||
// Softer golden glow for light mode
|
|
||||||
Color(red: 0.8, green: 0.7, blue: 0.3).opacity(isHovering ? 0.6 : 0.4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var shadowColor: Color {
|
|
||||||
if colorScheme == .dark {
|
|
||||||
.black.opacity(isHovering ? 0.8 : 0.6)
|
|
||||||
} else {
|
|
||||||
.black.opacity(isHovering ? 0.4 : 0.3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var shadowRadius: CGFloat {
|
|
||||||
isHovering ? 25 : 15
|
|
||||||
}
|
|
||||||
|
|
||||||
private var shadowOffset: CGFloat {
|
|
||||||
isHovering ? 10 : 6
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startFloatingAnimation() {
|
|
||||||
withAnimation(
|
|
||||||
Animation.easeInOut(duration: 3.0)
|
|
||||||
.repeatForever(autoreverses: true)
|
|
||||||
) {
|
|
||||||
floatingOffset = -8
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func openWebsite() {
|
|
||||||
guard let url = URL(string: "https://vibetunnel.sh") else { return }
|
|
||||||
NSWorkspace.shared.open(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Preview
|
// MARK: - Preview
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -231,7 +231,7 @@ private struct PermissionsSection: View {
|
||||||
.foregroundColor(.green)
|
.foregroundColor(.green)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
"Terminals can be controlled without permissions, however new sessions won't load."
|
"Terminals can be captured without permissions, however new sessions won't load."
|
||||||
)
|
)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
|
||||||
175
mac/VibeTunnel/Presentation/Views/Shared/GlowingAppIcon.swift
Normal file
175
mac/VibeTunnel/Presentation/Views/Shared/GlowingAppIcon.swift
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Shared glowing app icon component with configurable animation and effects.
|
||||||
|
///
|
||||||
|
/// This component displays the VibeTunnel app icon with customizable glow effects,
|
||||||
|
/// floating animation, and interactive behaviors. It can be used in both the Welcome
|
||||||
|
/// and About views with different configurations.
|
||||||
|
struct GlowingAppIcon: View {
|
||||||
|
// Configuration
|
||||||
|
let size: CGFloat
|
||||||
|
let enableFloating: Bool
|
||||||
|
let enableInteraction: Bool
|
||||||
|
let glowIntensity: Double
|
||||||
|
let action: (() -> Void)?
|
||||||
|
|
||||||
|
// State
|
||||||
|
@State private var isHovering = false
|
||||||
|
@State private var isPressed = false
|
||||||
|
@State private var breathingScale: CGFloat = 1.0
|
||||||
|
@State private var breathingPhase: CGFloat = 0
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
init(
|
||||||
|
size: CGFloat = 128,
|
||||||
|
enableFloating: Bool = true,
|
||||||
|
enableInteraction: Bool = true,
|
||||||
|
glowIntensity: Double = 0.3,
|
||||||
|
action: (() -> Void)? = nil
|
||||||
|
) {
|
||||||
|
self.size = size
|
||||||
|
self.enableFloating = enableFloating
|
||||||
|
self.enableInteraction = enableInteraction
|
||||||
|
self.glowIntensity = glowIntensity
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if enableInteraction {
|
||||||
|
Button(action: { action?() }) {
|
||||||
|
iconContent
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.pointingHandCursor()
|
||||||
|
.onHover { hovering in
|
||||||
|
isHovering = hovering
|
||||||
|
}
|
||||||
|
.simultaneousGesture(
|
||||||
|
DragGesture(minimumDistance: 0)
|
||||||
|
.onChanged { _ in isPressed = true }
|
||||||
|
.onEnded { _ in isPressed = false }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
iconContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scaleEffect(breathingScale)
|
||||||
|
.onAppear {
|
||||||
|
if enableFloating {
|
||||||
|
startBreathingAnimation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconContent: some View {
|
||||||
|
ZStack {
|
||||||
|
// Subtle glow effect that changes with breathing
|
||||||
|
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
||||||
|
.resizable()
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||||
|
.opacity(dynamicGlowOpacity)
|
||||||
|
.blur(radius: dynamicGlowBlur)
|
||||||
|
.scaleEffect(1.15)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
|
||||||
|
// Main icon with shadow
|
||||||
|
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
||||||
|
.resizable()
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||||
|
.scaleEffect(iconScale)
|
||||||
|
.shadow(
|
||||||
|
color: dynamicShadowColor,
|
||||||
|
radius: dynamicShadowRadius,
|
||||||
|
x: 0,
|
||||||
|
y: dynamicShadowOffset
|
||||||
|
)
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: isHovering)
|
||||||
|
.animation(.easeInOut(duration: 0.1), value: isPressed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cornerRadius: CGFloat {
|
||||||
|
size * 0.172 // Maintains the same corner radius ratio as original (22/128)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconScale: CGFloat {
|
||||||
|
if !enableInteraction { return 1.0 }
|
||||||
|
return isPressed ? 0.95 : (isHovering ? 1.05 : 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic properties that change with breathing
|
||||||
|
private var dynamicGlowOpacity: Double {
|
||||||
|
let baseOpacity = glowIntensity * 0.5
|
||||||
|
// Glow gets stronger when "coming forward" (breathingPhase > 0)
|
||||||
|
return baseOpacity + (breathingPhase * glowIntensity * 0.3)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dynamicGlowBlur: CGFloat {
|
||||||
|
// Blur increases when coming forward for a softer, larger glow
|
||||||
|
return 15 + (breathingPhase * 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dynamicShadowColor: Color {
|
||||||
|
let baseOpacity = colorScheme == .dark ? 0.4 : 0.2
|
||||||
|
let hoverOpacity = colorScheme == .dark ? 0.6 : 0.3
|
||||||
|
let opacity = isHovering ? hoverOpacity : baseOpacity
|
||||||
|
// Shadow gets stronger when coming forward
|
||||||
|
let breathingOpacity = opacity + (breathingPhase * 0.1)
|
||||||
|
return .black.opacity(breathingOpacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dynamicShadowRadius: CGFloat {
|
||||||
|
let baseRadius = size * 0.117
|
||||||
|
let hoverMultiplier: CGFloat = enableInteraction && isHovering ? 1.5 : 1.0
|
||||||
|
// Shadow gets softer/larger when coming forward
|
||||||
|
let breathingMultiplier = 1.0 + (breathingPhase * 0.2)
|
||||||
|
return baseRadius * hoverMultiplier * breathingMultiplier
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dynamicShadowOffset: CGFloat {
|
||||||
|
let baseOffset = size * 0.047
|
||||||
|
let hoverMultiplier: CGFloat = enableInteraction && isHovering ? 1.5 : 1.0
|
||||||
|
// Shadow moves down more when coming forward
|
||||||
|
let breathingMultiplier = 1.0 + (breathingPhase * 0.3)
|
||||||
|
return baseOffset * hoverMultiplier * breathingMultiplier
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startBreathingAnimation() {
|
||||||
|
withAnimation(
|
||||||
|
Animation.easeInOut(duration: 4.0)
|
||||||
|
.repeatForever(autoreverses: true)
|
||||||
|
) {
|
||||||
|
breathingScale = 1.04 // Very subtle scale change
|
||||||
|
breathingPhase = 1.0 // Used to calculate dynamic effects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview("Glowing App Icons") {
|
||||||
|
VStack(spacing: 40) {
|
||||||
|
// Welcome style - larger, subtle floating
|
||||||
|
GlowingAppIcon(
|
||||||
|
size: 156,
|
||||||
|
enableFloating: true,
|
||||||
|
enableInteraction: false,
|
||||||
|
glowIntensity: 0.3
|
||||||
|
)
|
||||||
|
|
||||||
|
// About style - smaller, interactive
|
||||||
|
GlowingAppIcon(
|
||||||
|
size: 128,
|
||||||
|
enableFloating: true,
|
||||||
|
enableInteraction: true,
|
||||||
|
glowIntensity: 0.3
|
||||||
|
) {
|
||||||
|
print("Icon clicked!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(width: 400, height: 600)
|
||||||
|
}
|
||||||
|
|
@ -27,12 +27,6 @@ struct AccessDashboardPageView: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 30) {
|
VStack(spacing: 30) {
|
||||||
// App icon
|
|
||||||
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
|
||||||
.resizable()
|
|
||||||
.frame(width: 156, height: 156)
|
|
||||||
.shadow(radius: 10)
|
|
||||||
|
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Text("Accessing Your Dashboard")
|
Text("Accessing Your Dashboard")
|
||||||
.font(.largeTitle)
|
.font(.largeTitle)
|
||||||
|
|
@ -89,8 +83,8 @@ struct AccessDashboardPageView: View {
|
||||||
CreditLink(name: "@steipete", url: "https://steipete.me")
|
CreditLink(name: "@steipete", url: "https://steipete.me")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,6 @@ struct ProtectDashboardPageView: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 30) {
|
VStack(spacing: 30) {
|
||||||
// App icon
|
|
||||||
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
|
||||||
.resizable()
|
|
||||||
.frame(width: 156, height: 156)
|
|
||||||
.shadow(radius: 10)
|
|
||||||
|
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Text("Protect Your Dashboard")
|
Text("Protect Your Dashboard")
|
||||||
.font(.largeTitle)
|
.font(.largeTitle)
|
||||||
|
|
@ -100,8 +94,8 @@ struct ProtectDashboardPageView: View {
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,12 +42,6 @@ struct RequestPermissionsPageView: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 30) {
|
VStack(spacing: 30) {
|
||||||
// App icon
|
|
||||||
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
|
||||||
.resizable()
|
|
||||||
.frame(width: 156, height: 156)
|
|
||||||
.shadow(radius: 10)
|
|
||||||
|
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Text("Request Permissions")
|
Text("Request Permissions")
|
||||||
.font(.largeTitle)
|
.font(.largeTitle)
|
||||||
|
|
@ -105,8 +99,8 @@ struct RequestPermissionsPageView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.padding()
|
.padding()
|
||||||
.task {
|
.task {
|
||||||
// Check permissions before first render to avoid UI flashing
|
// Check permissions before first render to avoid UI flashing
|
||||||
|
|
|
||||||
|
|
@ -22,23 +22,19 @@ struct SelectTerminalPageView: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 30) {
|
VStack(spacing: 30) {
|
||||||
// App icon
|
|
||||||
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
|
||||||
.resizable()
|
|
||||||
.frame(width: 156, height: 156)
|
|
||||||
.shadow(radius: 10)
|
|
||||||
|
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Text("Select Terminal")
|
Text("Select Terminal")
|
||||||
.font(.largeTitle)
|
.font(.largeTitle)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
Text("VibeTunnel can spawn new sessions and open a terminal for you.\nThis will require permissions.")
|
Text(
|
||||||
.font(.body)
|
"VibeTunnel can spawn new sessions and open a terminal for you.\nSelect your preferred Terminal and test permissions."
|
||||||
.foregroundColor(.secondary)
|
)
|
||||||
.multilineTextAlignment(.center)
|
.font(.body)
|
||||||
.frame(maxWidth: 480)
|
.foregroundColor(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(maxWidth: 480)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
// Terminal selector and test button
|
// Terminal selector and test button
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
|
|
@ -66,8 +62,8 @@ struct SelectTerminalPageView: View {
|
||||||
.frame(width: 200)
|
.frame(width: 200)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.padding()
|
.padding()
|
||||||
.alert(errorTitle, isPresented: $showingError) {
|
.alert(errorTitle, isPresented: $showingError) {
|
||||||
Button("OK") {}
|
Button("OK") {}
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,6 @@ struct VTCommandPageView: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 30) {
|
VStack(spacing: 30) {
|
||||||
// App icon
|
|
||||||
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
|
||||||
.resizable()
|
|
||||||
.frame(width: 156, height: 156)
|
|
||||||
.shadow(radius: 10)
|
|
||||||
|
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Text("Capturing Terminal Apps")
|
Text("Capturing Terminal Apps")
|
||||||
.font(.largeTitle)
|
.font(.largeTitle)
|
||||||
|
|
@ -88,8 +82,8 @@ struct VTCommandPageView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.padding()
|
.padding()
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Check installation status synchronously on appear
|
// Check installation status synchronously on appear
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Content-only version of the welcome page for use in the scrolling view.
|
||||||
|
///
|
||||||
|
/// This view displays only the textual content of the welcome page,
|
||||||
|
/// excluding the app icon which is shown in the fixed header.
|
||||||
|
struct WelcomeContentView: View {
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 30) {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text("Welcome to VibeTunnel")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Text("Turn any browser into your terminal. Command your agents on the go.")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(maxWidth: 480)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"You'll be quickly guided through the basics of VibeTunnel.\nThis screen can always be opened from the settings."
|
||||||
|
)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,53 +25,89 @@ struct WelcomeView: View {
|
||||||
private var welcomeVersion = 0
|
private var welcomeVersion = 0
|
||||||
@State private var cliInstaller = CLIInstaller()
|
@State private var cliInstaller = CLIInstaller()
|
||||||
@State private var permissionManager = SystemPermissionManager.shared
|
@State private var permissionManager = SystemPermissionManager.shared
|
||||||
|
|
||||||
|
private let pageWidth: CGFloat = 640
|
||||||
|
private let contentHeight: CGFloat = 468 // Total height minus navigation area
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Custom page view implementation for macOS
|
// Fixed header with animated app icon
|
||||||
ZStack {
|
GlowingAppIcon(
|
||||||
// Page 1: Welcome
|
size: 156,
|
||||||
if currentPage == 0 {
|
enableFloating: true,
|
||||||
WelcomePageView()
|
enableInteraction: false,
|
||||||
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
|
glowIntensity: 0.3
|
||||||
}
|
)
|
||||||
|
.padding(.top, 40)
|
||||||
// Page 2: VT Command
|
.padding(.bottom, 20) // Add padding below icon
|
||||||
if currentPage == 1 {
|
.frame(height: 240)
|
||||||
|
|
||||||
|
// Scrollable content area
|
||||||
|
GeometryReader { geometry in
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
// Page 1: Welcome content (without icon)
|
||||||
|
WelcomeContentView()
|
||||||
|
.frame(width: pageWidth)
|
||||||
|
|
||||||
|
// Page 2: VT Command
|
||||||
VTCommandPageView(cliInstaller: cliInstaller)
|
VTCommandPageView(cliInstaller: cliInstaller)
|
||||||
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
|
.frame(width: pageWidth)
|
||||||
}
|
|
||||||
|
// Page 3: Request Permissions
|
||||||
// Page 3: Request Permissions
|
|
||||||
if currentPage == 2 {
|
|
||||||
RequestPermissionsPageView()
|
RequestPermissionsPageView()
|
||||||
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
|
.frame(width: pageWidth)
|
||||||
}
|
|
||||||
|
// Page 4: Select Terminal
|
||||||
// Page 4: Select Terminal
|
|
||||||
if currentPage == 3 {
|
|
||||||
SelectTerminalPageView()
|
SelectTerminalPageView()
|
||||||
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
|
.frame(width: pageWidth)
|
||||||
}
|
|
||||||
|
// Page 5: Protect Your Dashboard
|
||||||
// Page 5: Protect Your Dashboard
|
|
||||||
if currentPage == 4 {
|
|
||||||
ProtectDashboardPageView()
|
ProtectDashboardPageView()
|
||||||
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
|
.frame(width: pageWidth)
|
||||||
}
|
|
||||||
|
// Page 6: Accessing Dashboard
|
||||||
// Page 6: Accessing Dashboard
|
|
||||||
if currentPage == 5 {
|
|
||||||
AccessDashboardPageView()
|
AccessDashboardPageView()
|
||||||
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
|
.frame(width: pageWidth)
|
||||||
}
|
}
|
||||||
|
.offset(x: CGFloat(-currentPage) * pageWidth)
|
||||||
|
.animation(.interactiveSpring(response: 0.5, dampingFraction: 0.86, blendDuration: 0.25), value: currentPage)
|
||||||
}
|
}
|
||||||
.frame(maxHeight: .infinity)
|
.frame(height: 260) // Total height (560) - header (240) - navigation (60)
|
||||||
.animation(.easeInOut, value: currentPage)
|
.clipped()
|
||||||
|
|
||||||
// Custom page indicators and navigation - Fixed height container
|
// Navigation bar with dots and buttons in same row
|
||||||
VStack(spacing: 0) {
|
HStack(spacing: 20) {
|
||||||
// Page indicators
|
// Back button - only visible when not on first page
|
||||||
|
// Back button with consistent space reservation
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
// Invisible placeholder that's always there
|
||||||
|
Button(action: {}) {
|
||||||
|
Label("Back", systemImage: "chevron.left")
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.opacity(0)
|
||||||
|
.disabled(true)
|
||||||
|
|
||||||
|
// Actual back button when needed
|
||||||
|
if currentPage > 0 {
|
||||||
|
Button(action: handleBackAction) {
|
||||||
|
Label("Back", systemImage: "chevron.left")
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.opacity(0.7)
|
||||||
|
.pointingHandCursor()
|
||||||
|
.help("Go back to previous page")
|
||||||
|
.transition(.opacity.combined(with: .scale(scale: 0.8)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 80, alignment: .leading) // Same width as Next button, left-aligned
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Page indicators centered
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(0..<6) { index in
|
ForEach(0..<6) { index in
|
||||||
Button {
|
Button {
|
||||||
|
|
@ -87,37 +123,18 @@ struct WelcomeView: View {
|
||||||
.pointingHandCursor()
|
.pointingHandCursor()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 32) // Fixed height for indicator area
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
// Navigation buttons
|
Button(action: handleNextAction) {
|
||||||
HStack {
|
Text(buttonTitle)
|
||||||
// Back button - only visible when not on first page
|
.frame(minWidth: 80)
|
||||||
if currentPage > 0 {
|
|
||||||
Button(action: handleBackAction) {
|
|
||||||
Label("Back", systemImage: "chevron.left")
|
|
||||||
.labelStyle(.iconOnly)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.opacity(0.7)
|
|
||||||
.pointingHandCursor()
|
|
||||||
.help("Go back to previous page")
|
|
||||||
.transition(.opacity.combined(with: .scale(scale: 0.8)))
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: handleNextAction) {
|
|
||||||
Text(buttonTitle)
|
|
||||||
.frame(minWidth: 80)
|
|
||||||
}
|
|
||||||
.keyboardShortcut(.return)
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.keyboardShortcut(.return)
|
||||||
.frame(height: 60) // Fixed height for button area
|
.buttonStyle(.borderedProminent)
|
||||||
}
|
}
|
||||||
.frame(height: 92) // Total fixed height: 32 + 60
|
.padding(.horizontal, 20)
|
||||||
|
.frame(height: 60)
|
||||||
}
|
}
|
||||||
.frame(width: 640, height: 560)
|
.frame(width: 640, height: 560)
|
||||||
.background(Color(NSColor.windowBackgroundColor))
|
.background(Color(NSColor.windowBackgroundColor))
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ final class WelcomeWindowController: NSWindowController, NSWindowDelegate {
|
||||||
let hostingController = NSHostingController(rootView: welcomeView)
|
let hostingController = NSHostingController(rootView: welcomeView)
|
||||||
|
|
||||||
let window = NSWindow(contentViewController: hostingController)
|
let window = NSWindow(contentViewController: hostingController)
|
||||||
window.title = ""
|
window.title = "Welcome to VibeTunnel"
|
||||||
window.styleMask = [.titled, .closable, .fullSizeContentView]
|
window.styleMask = [.titled, .closable, .fullSizeContentView]
|
||||||
window.titlebarAppearsTransparent = true
|
window.titlebarAppearsTransparent = true
|
||||||
window.titleVisibility = .hidden
|
window.titleVisibility = .hidden
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue