diff --git a/Packages/VNCUI/Sources/VNCUI/List/ConnectionListView.swift b/Packages/VNCUI/Sources/VNCUI/List/ConnectionListView.swift index c9d9e18..189fb1b 100644 --- a/Packages/VNCUI/Sources/VNCUI/List/ConnectionListView.swift +++ b/Packages/VNCUI/Sources/VNCUI/List/ConnectionListView.swift @@ -19,51 +19,30 @@ public struct ConnectionListView: View { public var body: some View { NavigationStack(path: $path) { - List { - if !discovery.hosts.isEmpty || discovery.isBrowsing { - discoveredSection - } - savedSection - } - #if os(iOS) - .listStyle(.insetGrouped) - .scrollContentBackground(.hidden) - #endif - .softScrollEdge() - .background(backgroundGradient.ignoresSafeArea()) - .navigationTitle("Screens") - #if os(iOS) - .searchable(text: $search, - placement: .navigationBarDrawer(displayMode: .always), - prompt: "Search connections") - #else - .searchable(text: $search, prompt: "Search connections") - #endif - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - addPrefill = nil - showingAdd = true - } label: { - Image(systemName: "plus") + ZStack(alignment: .top) { + backgroundGradient.ignoresSafeArea() + + ScrollView { + VStack(spacing: 22) { + topChrome + + if !discovery.hosts.isEmpty || discovery.isBrowsing { + discoveredSection + } + + savedSection } - .accessibilityLabel("Add connection") + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 32) } #if os(iOS) - ToolbarItem(placement: .topBarLeading) { - Button { - showingSettings = true - } label: { - Image(systemName: "gear") - } - .accessibilityLabel("Settings") - } - #else - ToolbarItem { - Button { showingSettings = true } label: { Image(systemName: "gear") } - } + .scrollIndicators(.hidden) #endif } + #if os(iOS) + .toolbar(.hidden, for: .navigationBar) + #endif .sheet(isPresented: $showingAdd) { AddConnectionView(prefill: addPrefill) } @@ -90,143 +69,279 @@ public struct ConnectionListView: View { } } + // MARK: Background + private var backgroundGradient: some View { - LinearGradient( - colors: [ - Color(red: 0.04, green: 0.05, blue: 0.10), - Color(red: 0.02, green: 0.02, blue: 0.05) - ], - startPoint: .top, - endPoint: .bottom - ) + ZStack { + LinearGradient( + colors: [ + Color(red: 0.06, green: 0.08, blue: 0.16), + Color(red: 0.02, green: 0.02, blue: 0.06) + ], + startPoint: .top, + endPoint: .bottom + ) + // soft accent bloom up top so the floating buttons feel anchored + RadialGradient( + colors: [Color.blue.opacity(0.22), .clear], + center: .init(x: 0.5, y: 0.0), + startRadius: 20, + endRadius: 320 + ) + .blendMode(.screen) + .allowsHitTesting(false) + } } + // MARK: Top chrome (custom — replaces nav bar) + + private var topChrome: some View { + VStack(spacing: 14) { + HStack(alignment: .center) { + circularGlassButton(systemName: "gearshape", label: "Settings") { + showingSettings = true + } + Spacer() + Text("Screens") + .font(.system(size: 28, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + Spacer() + circularGlassButton(systemName: "plus", label: "Add connection") { + addPrefill = nil + showingAdd = true + } + } + + searchField + } + .padding(.top, 4) + } + + private func circularGlassButton(systemName: String, + label: String, + action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: systemName) + .font(.callout.weight(.semibold)) + .foregroundStyle(.white) + .frame(width: 40, height: 40) + } + .buttonStyle(.plain) + .glassSurface(in: Circle()) + .accessibilityLabel(label) + } + + private var searchField: some View { + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.white.opacity(0.65)) + TextField("", text: $search, prompt: Text("Search connections") + .foregroundStyle(.white.opacity(0.45))) + .textFieldStyle(.plain) + .foregroundStyle(.white) + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + .autocorrectionDisabled() + .submitLabel(.search) + if !search.isEmpty { + Button { + search = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.white.opacity(0.45)) + } + .buttonStyle(.plain) + .accessibilityLabel("Clear search") + } + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + .glassSurface(in: Capsule()) + } + + // MARK: Discovered + private var discoveredSection: some View { - Section { - if discovery.hosts.isEmpty { - HStack(spacing: 12) { - ProgressView().controlSize(.small) - Text("Looking for computers on this network…") - .font(.callout) - .foregroundStyle(.secondary) - } - .padding(.vertical, 6) - } else { - ForEach(discovery.hosts) { host in - Button { - Task { await prepareDiscoveredHost(host) } - } label: { - HStack(spacing: 14) { - ZStack { - Circle() - .fill(.tint.opacity(0.15)) - .frame(width: 38, height: 38) - Image(systemName: "wifi") - .font(.callout) - .foregroundStyle(.tint) - } - VStack(alignment: .leading, spacing: 2) { - Text(host.displayName) - .font(.headline) - .foregroundStyle(.primary) - Text(serviceLabel(host.serviceType)) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - if resolvingHostID == host.id { - ProgressView().controlSize(.small) - } else { - Image(systemName: "plus.circle.fill") - .font(.title3) - .foregroundStyle(.tint) - } - } - .padding(.vertical, 4) - .contentShape(Rectangle()) + VStack(alignment: .leading, spacing: 10) { + sectionHeader("On this network", + systemImage: "antenna.radiowaves.left.and.right") + + VStack(spacing: 10) { + if discovery.hosts.isEmpty { + HStack(spacing: 12) { + ProgressView().controlSize(.small).tint(.white) + Text("Looking for computers…") + .font(.callout) + .foregroundStyle(.white.opacity(0.7)) + Spacer() + } + .padding(16) + .glassSurface(in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + } else { + ForEach(discovery.hosts) { host in + Button { + Task { await prepareDiscoveredHost(host) } + } label: { + HStack(spacing: 14) { + ZStack { + Circle() + .fill(Color.blue.opacity(0.25)) + .frame(width: 38, height: 38) + Image(systemName: "wifi") + .font(.callout) + .foregroundStyle(.blue) + } + VStack(alignment: .leading, spacing: 2) { + Text(host.displayName) + .font(.headline) + .foregroundStyle(.white) + Text(serviceLabel(host.serviceType)) + .font(.caption) + .foregroundStyle(.white.opacity(0.6)) + } + Spacer() + if resolvingHostID == host.id { + ProgressView().controlSize(.small).tint(.white) + } else { + Image(systemName: "plus.circle.fill") + .font(.title3) + .foregroundStyle(.blue) + } + } + .padding(14) + } + .buttonStyle(.plain) + .glassSurface(in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .disabled(resolvingHostID != nil) } - .buttonStyle(.plain) - .disabled(resolvingHostID != nil) } } - } header: { - Label("On this network", systemImage: "antenna.radiowaves.left.and.right") - .font(.footnote.weight(.semibold)) - .textCase(nil) - .foregroundStyle(.secondary) } } + // MARK: Saved + private var savedSection: some View { - Section { + VStack(alignment: .leading, spacing: 10) { + sectionHeader("Saved", systemImage: "bookmark.fill") + if filteredConnections.isEmpty { if connections.isEmpty { - ContentUnavailableView { - Label("No saved connections", systemImage: "display") - } description: { - Text("Tap the + button above to add a Mac, Linux box, or Raspberry Pi.") - } actions: { - Button { - addPrefill = nil - showingAdd = true - } label: { - Label("Add a computer", systemImage: "plus") - } - .glassButton(prominent: true) - } - .padding(.vertical, 10) + emptySavedState } else { - ContentUnavailableView.search(text: search) + noSearchResultsState } } else { - ForEach(filteredConnections) { connection in - NavigationLink(value: SessionRoute(connectionID: connection.id)) { - ConnectionCard(connection: connection) - } - .contextMenu { - Button { - editingConnection = connection - } label: { - Label("Edit", systemImage: "pencil") - } - #if os(iOS) - Button { - openWindow(value: connection.id) - } label: { - Label("Open in New Window", systemImage: "rectangle.stack.badge.plus") - } - #endif - Button(role: .destructive) { - delete(connection) - } label: { - Label("Delete", systemImage: "trash") - } - } - .swipeActions(edge: .leading) { - Button { - editingConnection = connection - } label: { - Label("Edit", systemImage: "pencil") - } - .tint(.blue) - } - .swipeActions(edge: .trailing) { - Button(role: .destructive) { - delete(connection) - } label: { - Label("Delete", systemImage: "trash") - } + VStack(spacing: 10) { + ForEach(filteredConnections) { connection in + connectionRow(connection) } } } - } header: { - Label("Saved", systemImage: "bookmark") - .font(.footnote.weight(.semibold)) - .textCase(nil) - .foregroundStyle(.secondary) } } + private var emptySavedState: some View { + VStack(spacing: 14) { + ZStack { + Circle() + .fill(LinearGradient(colors: [.blue.opacity(0.4), .purple.opacity(0.3)], + startPoint: .topLeading, + endPoint: .bottomTrailing)) + .frame(width: 64, height: 64) + Image(systemName: "display") + .font(.title.weight(.semibold)) + .foregroundStyle(.white) + } + Text("No saved connections") + .font(.headline) + .foregroundStyle(.white) + Text("Tap the + button up top to add a Mac, Linux box, or Raspberry Pi.") + .font(.callout) + .foregroundStyle(.white.opacity(0.65)) + .multilineTextAlignment(.center) + .padding(.horizontal, 8) + Button { + addPrefill = nil + showingAdd = true + } label: { + Label("Add a computer", systemImage: "plus") + .font(.callout.weight(.semibold)) + .padding(.horizontal, 18) + .padding(.vertical, 12) + } + .buttonStyle(.plain) + .background( + Capsule().fill(LinearGradient(colors: [.blue, .purple], + startPoint: .leading, + endPoint: .trailing)) + ) + .foregroundStyle(.white) + } + .frame(maxWidth: .infinity) + .padding(28) + .glassSurface(in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + } + + private var noSearchResultsState: some View { + VStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.title) + .foregroundStyle(.white.opacity(0.5)) + Text("No matches for \"\(search)\"") + .font(.callout) + .foregroundStyle(.white.opacity(0.65)) + } + .frame(maxWidth: .infinity) + .padding(24) + .glassSurface(in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + + private func connectionRow(_ connection: SavedConnection) -> some View { + NavigationLink(value: SessionRoute(connectionID: connection.id)) { + ConnectionCard(connection: connection) + .padding(14) + } + .buttonStyle(.plain) + .glassSurface(in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .contextMenu { + Button { + editingConnection = connection + } label: { + Label("Edit", systemImage: "pencil") + } + #if os(iOS) + Button { + openWindow(value: connection.id) + } label: { + Label("Open in New Window", systemImage: "rectangle.stack.badge.plus") + } + #endif + Divider() + Button(role: .destructive) { + delete(connection) + } label: { + Label("Delete", systemImage: "trash") + } + } + } + + // MARK: Helpers + + private func sectionHeader(_ title: String, systemImage: String) -> some View { + HStack(spacing: 8) { + Image(systemName: systemImage) + .font(.caption.weight(.semibold)) + Text(title) + .font(.subheadline.weight(.semibold)) + .textCase(.uppercase) + .tracking(0.5) + } + .foregroundStyle(.white.opacity(0.55)) + .padding(.horizontal, 4) + } + private var filteredConnections: [SavedConnection] { let trimmed = search.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return connections }