vibetunnel/ios/VibeTunnel/Views/Connection/EnhancedConnectionView.swift

442 lines
16 KiB
Swift

import SwiftUI
/// Enhanced connection view with server profiles support
struct EnhancedConnectionView: View {
@Environment(ConnectionManager.self)
var connectionManager
@State private var networkMonitor = NetworkMonitor.shared
@State private var viewModel = ConnectionViewModel()
@State private var profilesViewModel = ServerProfilesViewModel()
@State private var logoScale: CGFloat = 0.8
@State private var contentOpacity: Double = 0
@State private var showingNewServerForm = false
@State private var selectedProfile: ServerProfile?
@State private var showingProfileEditor = false
#if targetEnvironment(macCatalyst)
@StateObject private var windowManager = MacCatalystWindowManager.shared
#endif
var body: some View {
NavigationStack {
ZStack {
ScrollView {
VStack(spacing: Theme.Spacing.extraLarge) {
// Logo and Title
headerView
.padding(.top, {
#if targetEnvironment(macCatalyst)
return windowManager.windowStyle == .inline ? 60 : 40
#else
return 40
#endif
}())
// Quick Connect Section
if !profilesViewModel.profiles.isEmpty && !showingNewServerForm {
quickConnectSection
.opacity(contentOpacity)
.onAppear {
withAnimation(Theme.Animation.smooth.delay(0.3)) {
contentOpacity = 1.0
}
}
}
// New Connection Form
if showingNewServerForm || profilesViewModel.profiles.isEmpty {
newConnectionSection
.opacity(contentOpacity)
.onAppear {
withAnimation(Theme.Animation.smooth.delay(0.3)) {
contentOpacity = 1.0
}
}
}
Spacer(minLength: 50)
}
.padding()
}
.scrollBounceBehavior(.basedOnSize)
}
.toolbar(.hidden, for: .navigationBar)
.background(Theme.Colors.terminalBackground.ignoresSafeArea())
.sheet(item: $selectedProfile) { profile in
ServerProfileEditView(
profile: profile,
onSave: { updatedProfile, password in
Task {
try await profilesViewModel.updateProfile(updatedProfile, password: password)
selectedProfile = nil
}
},
onDelete: {
Task {
try await profilesViewModel.deleteProfile(profile)
selectedProfile = nil
}
}
)
}
}
.navigationViewStyle(StackNavigationViewStyle())
.preferredColorScheme(.dark)
.onAppear {
profilesViewModel.loadProfiles()
}
}
// MARK: - Header View
private var headerView: some View {
VStack(spacing: Theme.Spacing.large) {
ZStack {
// Glow effect
Image(systemName: "terminal.fill")
.font(.system(size: 80))
.foregroundColor(Theme.Colors.primaryAccent)
.blur(radius: 20)
.opacity(0.5)
// Main icon
Image(systemName: "terminal.fill")
.font(.system(size: 80))
.foregroundColor(Theme.Colors.primaryAccent)
.glowEffect()
}
.scaleEffect(logoScale)
.onAppear {
withAnimation(Theme.Animation.smooth.delay(0.1)) {
logoScale = 1.0
}
}
VStack(spacing: Theme.Spacing.small) {
Text("VibeTunnel")
.font(.system(size: 42, weight: .bold, design: .rounded))
.foregroundColor(Theme.Colors.terminalForeground)
Text("Terminal Multiplexer")
.font(Theme.Typography.terminalSystem(size: 16))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
.tracking(2)
// Network status
ConnectionStatusView()
.padding(.top, Theme.Spacing.small)
}
}
}
// MARK: - Quick Connect Section
private var quickConnectSection: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.medium) {
HStack {
Text("Saved Servers")
.font(Theme.Typography.terminalSystem(size: 18, weight: .semibold))
.foregroundColor(Theme.Colors.terminalForeground)
Spacer()
Button(action: {
withAnimation {
showingNewServerForm.toggle()
}
}) {
Image(systemName: showingNewServerForm ? "minus.circle" : "plus.circle")
.font(.system(size: 20))
.foregroundColor(Theme.Colors.primaryAccent)
}
}
VStack(spacing: Theme.Spacing.small) {
ForEach(profilesViewModel.profiles) { profile in
ServerProfileCard(
profile: profile,
isLoading: profilesViewModel.isLoading,
onConnect: {
connectToProfile(profile)
},
onEdit: {
selectedProfile = profile
}
)
}
}
}
}
// MARK: - New Connection Section
private var newConnectionSection: some View {
VStack(spacing: Theme.Spacing.large) {
if !profilesViewModel.profiles.isEmpty {
HStack {
Text("New Server Connection")
.font(Theme.Typography.terminalSystem(size: 18, weight: .semibold))
.foregroundColor(Theme.Colors.terminalForeground)
Spacer()
}
}
ServerConfigForm(
host: $viewModel.host,
port: $viewModel.port,
name: $viewModel.name,
password: $viewModel.password,
isConnecting: viewModel.isConnecting,
errorMessage: viewModel.errorMessage,
onConnect: saveAndConnect
)
if !profilesViewModel.profiles.isEmpty {
Button(action: {
withAnimation {
showingNewServerForm = false
}
}) {
Text("Cancel")
.font(Theme.Typography.terminalSystem(size: 16))
.foregroundColor(Theme.Colors.secondaryText)
}
.padding(.top, Theme.Spacing.small)
}
}
}
// MARK: - Actions
private func connectToProfile(_ profile: ServerProfile) {
guard networkMonitor.isConnected else {
viewModel.errorMessage = "No internet connection available"
return
}
Task {
do {
try await profilesViewModel.connectToProfile(profile, connectionManager: connectionManager)
} catch {
viewModel.errorMessage = "Failed to connect: \(error.localizedDescription)"
}
}
}
private func saveAndConnect() {
guard networkMonitor.isConnected else {
viewModel.errorMessage = "No internet connection available"
return
}
// Create profile from form data
let urlString = viewModel.port.isEmpty ? viewModel.host : "\(viewModel.host):\(viewModel.port)"
guard let profile = profilesViewModel.createProfileFromURL(urlString) else {
viewModel.errorMessage = "Invalid server URL"
return
}
var updatedProfile = profile
updatedProfile.name = viewModel.name.isEmpty ? profile.name : viewModel.name
updatedProfile.requiresAuth = !viewModel.password.isEmpty
updatedProfile.username = updatedProfile.requiresAuth ? "admin" : nil
// Save profile and password
Task {
try await profilesViewModel.addProfile(updatedProfile, password: viewModel.password)
// Connect
connectToProfile(updatedProfile)
}
// Reset form
viewModel = ConnectionViewModel()
showingNewServerForm = false
}
}
// MARK: - Server Profile Card
struct ServerProfileCard: View {
let profile: ServerProfile
let isLoading: Bool
let onConnect: () -> Void
let onEdit: () -> Void
@State private var isPressed = false
var body: some View {
HStack(spacing: Theme.Spacing.medium) {
// Icon
Image(systemName: profile.iconSymbol)
.font(.system(size: 24))
.foregroundColor(Theme.Colors.primaryAccent)
.frame(width: 40, height: 40)
.background(Theme.Colors.primaryAccent.opacity(0.1))
.cornerRadius(Theme.CornerRadius.small)
// Server Info
VStack(alignment: .leading, spacing: 2) {
Text(profile.name)
.font(Theme.Typography.terminalSystem(size: 16, weight: .medium))
.foregroundColor(Theme.Colors.terminalForeground)
HStack(spacing: 4) {
Text(profile.url)
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.secondaryText)
if profile.requiresAuth {
Image(systemName: "lock.fill")
.font(.system(size: 10))
.foregroundColor(Theme.Colors.warningAccent)
}
}
if let lastConnected = profile.lastConnected {
Text(RelativeDateTimeFormatter().localizedString(for: lastConnected, relativeTo: Date()))
.font(Theme.Typography.terminalSystem(size: 11))
.foregroundColor(Theme.Colors.secondaryText.opacity(0.7))
}
}
Spacer()
// Action Buttons
HStack(spacing: Theme.Spacing.small) {
Button(action: onEdit) {
Image(systemName: "ellipsis.circle")
.font(.system(size: 20))
.foregroundColor(Theme.Colors.secondaryText)
}
.buttonStyle(.plain)
Button(action: onConnect) {
HStack(spacing: 4) {
if isLoading {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "arrow.right.circle.fill")
.font(.system(size: 24))
}
}
.foregroundColor(Theme.Colors.primaryAccent)
}
.buttonStyle(.plain)
.disabled(isLoading)
}
}
.padding(Theme.Spacing.medium)
.background(Theme.Colors.cardBackground)
.cornerRadius(Theme.CornerRadius.card)
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.card)
.stroke(Theme.Colors.cardBorder, lineWidth: 1)
)
.scaleEffect(isPressed ? 0.98 : 1.0)
.animation(.easeInOut(duration: 0.1), value: isPressed)
.onTapGesture {
onConnect()
}
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in isPressed = true }
.onEnded { _ in isPressed = false }
)
}
}
// MARK: - Server Profile Edit View
struct ServerProfileEditView: View {
@State var profile: ServerProfile
let onSave: (ServerProfile, String?) -> Void
let onDelete: () -> Void
@State private var password: String = ""
@State private var showingDeleteConfirmation = false
@Environment(\.dismiss)
private var dismiss
var body: some View {
NavigationStack {
Form {
Section("Server Details") {
HStack {
Text("Icon")
Spacer()
Image(systemName: profile.iconSymbol)
.font(.system(size: 24))
.foregroundColor(Theme.Colors.primaryAccent)
}
TextField("Name", text: $profile.name)
TextField("URL", text: $profile.url)
Toggle("Requires Authentication", isOn: $profile.requiresAuth)
if profile.requiresAuth {
TextField("Username", text: Binding(
get: { profile.username ?? "admin" },
set: { profile.username = $0 }
))
SecureField("Password", text: $password)
.textContentType(.password)
}
}
Section {
Button(role: .destructive, action: {
showingDeleteConfirmation = true
}) {
Label("Delete Server", systemImage: "trash")
.foregroundColor(.red)
}
}
}
.navigationTitle("Edit Server")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
onSave(profile, profile.requiresAuth ? password : nil)
dismiss()
}
.fontWeight(.semibold)
}
}
.alert("Delete Server?", isPresented: $showingDeleteConfirmation) {
Button("Delete", role: .destructive) {
onDelete()
dismiss()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Are you sure you want to delete \"\(profile.name)\"? This action cannot be undone.")
}
}
.task {
// Load existing password from keychain
if profile.requiresAuth,
let existingPassword = try? KeychainService.getPassword(for: profile.id)
{
password = existingPassword
}
}
}
}
// MARK: - Preview
#Preview {
EnhancedConnectionView()
.environment(ConnectionManager())
}